From 6df1a682139f2bbfa711d78d24991cb8a89f075e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 12 Mar 2024 13:38:16 -0400 Subject: [PATCH] Refactor and improve attachment deduping logic. --- .../securesms/database/AttachmentTableTest.kt | 35 +- .../database/AttachmentTableTest_deduping.kt | 596 +++++++++++ .../securesms/attachments/AttachmentId.kt | 7 +- .../attachments/PointerAttachment.kt | 4 +- .../securesms/backup/FullBackupImporter.java | 2 +- .../securesms/database/AttachmentTable.kt | 984 +++++++++--------- .../securesms/database/MediaTable.kt | 10 +- .../helpers/SignalDatabaseMigrations.kt | 6 +- .../migration/V222_DataHashRefactor.kt | 25 + .../jobs/AttachmentCompressionJob.java | 10 +- .../securesms/jobs/AttachmentDownloadJob.java | 12 +- .../securesms/jobs/AttachmentUploadJob.kt | 5 +- .../jobs/LegacyAttachmentUploadJob.java | 2 +- .../stickers/StickerSearchRepository.java | 5 - .../signal/core/util/InputStreamExtensions.kt | 9 +- .../api/SignalServiceMessageReceiver.java | 3 +- 16 files changed, 1150 insertions(+), 565 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V222_DataHashRefactor.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest.kt index af351f00ab..ec4efe7922 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest.kt @@ -51,18 +51,16 @@ class AttachmentTableTest { SignalDatabase.attachments.updateAttachmentData( attachment, - createMediaStream(byteArrayOf(1, 2, 3, 4, 5)), - false + createMediaStream(byteArrayOf(1, 2, 3, 4, 5)) ) SignalDatabase.attachments.updateAttachmentData( attachment2, - createMediaStream(byteArrayOf(1, 2, 3)), - false + createMediaStream(byteArrayOf(1, 2, 3)) ) - val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA_FILE) - val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA_FILE) + val attachment1Info = SignalDatabase.attachments.getDataFileInfo(attachment.attachmentId) + val attachment2Info = SignalDatabase.attachments.getDataFileInfo(attachment2.attachmentId) assertNotEquals(attachment1Info, attachment2Info) } @@ -79,18 +77,16 @@ class AttachmentTableTest { SignalDatabase.attachments.updateAttachmentData( attachment, - createMediaStream(byteArrayOf(1, 2, 3, 4, 5)), - true + createMediaStream(byteArrayOf(1, 2, 3, 4, 5)) ) SignalDatabase.attachments.updateAttachmentData( attachment2, - createMediaStream(byteArrayOf(1, 2, 3, 4)), - true + createMediaStream(byteArrayOf(1, 2, 3, 4)) ) - val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA_FILE) - val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA_FILE) + val attachment1Info = SignalDatabase.attachments.getDataFileInfo(attachment.attachmentId) + val attachment2Info = SignalDatabase.attachments.getDataFileInfo(attachment2.attachmentId) assertNotEquals(attachment1Info, attachment2Info) } @@ -121,15 +117,14 @@ class AttachmentTableTest { val highDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityPreUpload) // WHEN - SignalDatabase.attachments.updateAttachmentData(standardDatabaseAttachment, createMediaStream(compressedData), false) + SignalDatabase.attachments.updateAttachmentData(standardDatabaseAttachment, createMediaStream(compressedData)) // THEN - val previousInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(previousDatabaseAttachmentId, AttachmentTable.DATA_FILE)!! - val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!! - val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!! + val previousInfo = SignalDatabase.attachments.getDataFileInfo(previousDatabaseAttachmentId)!! + val standardInfo = SignalDatabase.attachments.getDataFileInfo(standardDatabaseAttachment.attachmentId)!! + val highInfo = SignalDatabase.attachments.getDataFileInfo(highDatabaseAttachment.attachmentId)!! assertNotEquals(standardInfo, highInfo) - standardInfo.file assertIs previousInfo.file highInfo.file assertIsNot standardInfo.file highInfo.file.exists() assertIs true } @@ -158,9 +153,9 @@ class AttachmentTableTest { val secondHighDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(secondHighQualityPreUpload) // THEN - val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!! - val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!! - val secondHighInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(secondHighDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!! + val standardInfo = SignalDatabase.attachments.getDataFileInfo(standardDatabaseAttachment.attachmentId)!! + val highInfo = SignalDatabase.attachments.getDataFileInfo(highDatabaseAttachment.attachmentId)!! + val secondHighInfo = SignalDatabase.attachments.getDataFileInfo(secondHighDatabaseAttachment.attachmentId)!! highInfo.file assertIsNot standardInfo.file secondHighInfo.file assertIs highInfo.file diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt new file mode 100644 index 0000000000..7236b3db5f --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTest_deduping.kt @@ -0,0 +1,596 @@ +package org.thoughtcrime.securesms.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.attachments.AttachmentId +import org.thoughtcrime.securesms.attachments.PointerAttachment +import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.mms.MediaStream +import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.mms.QuoteModel +import org.thoughtcrime.securesms.mms.SentMediaQuality +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.MediaUtil +import org.whispersystems.signalservice.api.push.ServiceId +import java.io.File +import java.util.UUID +import kotlin.random.Random +import kotlin.time.Duration.Companion.days + +/** + * Collection of [AttachmentTable] tests focused around deduping logic. + */ +@RunWith(AndroidJUnit4::class) +class AttachmentTableTest_deduping { + + companion object { + val DATA_A = byteArrayOf(1, 2, 3) + val DATA_A_COMPRESSED = byteArrayOf(4, 5, 6) + + val DATA_B = byteArrayOf(7, 8, 9) + } + + @Before + fun setUp() { + SignalStore.account().setAci(ServiceId.ACI.from(UUID.randomUUID())) + SignalStore.account().setPni(ServiceId.PNI.from(UUID.randomUUID())) + SignalStore.account().setE164("+15558675309") + + SignalDatabase.attachments.deleteAllAttachments() + } + + /** + * Creates two different files with different data. Should not dedupe. + */ + @Test + fun differentFiles() { + test { + val id1 = insertWithData(DATA_A) + val id2 = insertWithData(DATA_B) + + assertDataFilesAreDifferent(id1, id2) + } + } + + /** + * Inserts files with identical data but with transform properties that make them incompatible. Should not dedupe. + */ + @Test + fun identicalFiles_incompatibleTransforms() { + // Non-matching qualities + test { + val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code)) + val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code)) + + assertDataFilesAreDifferent(id1, id2) + assertDataHashStartMatches(id1, id2) + } + + // Non-matching video trim flag + test { + val id1 = insertWithData(DATA_A, TransformProperties()) + val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true)) + + assertDataFilesAreDifferent(id1, id2) + assertDataHashStartMatches(id1, id2) + } + + // Non-matching video trim start time + test { + val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2)) + val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 2)) + + assertDataFilesAreDifferent(id1, id2) + assertDataHashStartMatches(id1, id2) + } + + // Non-matching video trim end time + test { + val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 1)) + val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 2)) + + assertDataFilesAreDifferent(id1, id2) + assertDataHashStartMatches(id1, id2) + } + + // Non-matching mp4 fast start + test { + val id1 = insertWithData(DATA_A, TransformProperties(mp4FastStart = true)) + val id2 = insertWithData(DATA_A, TransformProperties(mp4FastStart = false)) + + assertDataFilesAreDifferent(id1, id2) + assertDataHashStartMatches(id1, id2) + } + } + + /** + * Inserts files with identical data and compatible transform properties. Should dedupe. + */ + @Test + fun identicalFiles_compatibleTransforms() { + test { + val id1 = insertWithData(DATA_A) + val id2 = insertWithData(DATA_A) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertSkipTransform(id1, false) + assertSkipTransform(id2, false) + } + + test { + val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code)) + val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code)) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertSkipTransform(id1, false) + assertSkipTransform(id2, false) + } + + test { + val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code)) + val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code)) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertSkipTransform(id1, false) + assertSkipTransform(id2, false) + } + + test { + val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2)) + val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2)) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertSkipTransform(id1, false) + assertSkipTransform(id2, false) + } + } + + /** + * Walks through various scenarios where files are compressed and uploaded. + */ + @Test + fun compressionAndUploads() { + // Matches after the first is compressed, skip transform properly set + test { + val id1 = insertWithData(DATA_A) + compress(id1, DATA_A_COMPRESSED) + + val id2 = insertWithData(DATA_A) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id2, true) + } + + // Matches after the first is uploaded, skip transform and ending hash properly set + test { + val id1 = insertWithData(DATA_A) + compress(id1, DATA_A_COMPRESSED) + upload(id1) + + val id2 = insertWithData(DATA_A) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertDataHashEndMatches(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id2, true) + } + + // Mimics sending two files at once. Ensures all fields are kept in sync as we compress and upload. + test { + val id1 = insertWithData(DATA_A) + val id2 = insertWithData(DATA_A) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertSkipTransform(id1, false) + assertSkipTransform(id2, false) + + compress(id1, DATA_A_COMPRESSED) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id2, true) + + upload(id1) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertDataHashEndMatches(id1, id2) + assertRemoteFieldsMatch(id1, id2) + } + + // Re-use the upload when uploaded recently + test { + val id1 = insertWithData(DATA_A) + compress(id1, DATA_A_COMPRESSED) + upload(id1, uploadTimestamp = System.currentTimeMillis()) + + val id2 = insertWithData(DATA_A) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertDataHashEndMatches(id1, id2) + assertRemoteFieldsMatch(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id2, true) + } + + // Do not re-use old uploads + test { + val id1 = insertWithData(DATA_A) + compress(id1, DATA_A_COMPRESSED) + upload(id1, uploadTimestamp = System.currentTimeMillis() - 100.days.inWholeMilliseconds) + + val id2 = insertWithData(DATA_A) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashStartMatches(id1, id2) + assertDataHashEndMatches(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id2, true) + + assertDoesNotHaveRemoteFields(id2) + } + + // This isn't so much "desirable behavior" as it is documenting how things work. + // If an attachment is compressed but not uploaded yet, it will have a DATA_HASH_START that doesn't match the actual file content. + // This means that if we insert a new attachment with data that matches the compressed data, we won't find a match. + // This is ok because we don't allow forwarding unsent messages, so the chances of the user somehow sending a file that matches data we compressed are very low. + // What *is* more common is that the user may send DATA_A again, and in this case we will still catch the dedupe (which is already tested above). + test { + val id1 = insertWithData(DATA_A) + compress(id1, DATA_A_COMPRESSED) + + val id2 = insertWithData(DATA_A_COMPRESSED) + + assertDataFilesAreDifferent(id1, id2) + } + + // This represents what would happen if you forward an already-send compressed attachment. We should match, skip transform, and skip upload. + test { + val id1 = insertWithData(DATA_A) + compress(id1, DATA_A_COMPRESSED) + upload(id1, uploadTimestamp = System.currentTimeMillis()) + + val id2 = insertWithData(DATA_A_COMPRESSED) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashEndMatches(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id1, true) + assertRemoteFieldsMatch(id1, id2) + } + + // This represents what would happen if you edited a video, sent it, then forwarded it. We should match, skip transform, and skip upload. + test { + val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2)) + compress(id1, DATA_A_COMPRESSED) + upload(id1, uploadTimestamp = System.currentTimeMillis()) + + val id2 = insertWithData(DATA_A_COMPRESSED) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashEndMatches(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id1, true) + assertRemoteFieldsMatch(id1, id2) + } + + // This represents what would happen if you edited a video, sent it, then forwarded it, but *edited the forwarded video*. We should not dedupe. + test { + val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2)) + compress(id1, DATA_A_COMPRESSED) + upload(id1, uploadTimestamp = System.currentTimeMillis()) + + val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2)) + + assertDataFilesAreDifferent(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id2, false) + assertDoesNotHaveRemoteFields(id2) + } + + // This represents what would happen if you sent an image using standard quality, then forwarded it using high quality. + // Since you're forwarding, it doesn't matter if the new thing has a higher quality, we should still match and skip transform. + test { + val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code)) + compress(id1, DATA_A_COMPRESSED) + upload(id1, uploadTimestamp = System.currentTimeMillis()) + + val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code)) + + assertDataFilesAreTheSame(id1, id2) + assertDataHashEndMatches(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id1, true) + assertRemoteFieldsMatch(id1, id2) + } + + // This represents what would happen if you sent an image using high quality, then forwarded it using standard quality. + // Lowering the quality would change the output, so we shouldn't dedupe. + test { + val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code)) + compress(id1, DATA_A_COMPRESSED) + upload(id1, uploadTimestamp = System.currentTimeMillis()) + + val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code)) + + assertDataFilesAreDifferent(id1, id2) + assertSkipTransform(id1, true) + assertSkipTransform(id2, false) + assertDoesNotHaveRemoteFields(id2) + } + } + + /** + * Various deletion scenarios to ensure that duped files don't deleted while there's still references. + */ + @Test + fun deletions() { + // Delete original then dupe + test { + val id1 = insertWithData(DATA_A) + val id2 = insertWithData(DATA_A) + val dataFile = dataFile(id1) + + assertDataFilesAreTheSame(id1, id2) + + delete(id1) + + assertDeleted(id1) + assertRowAndFileExists(id2) + assertTrue(dataFile.exists()) + + delete(id2) + + assertDeleted(id2) + assertFalse(dataFile.exists()) + } + + // Delete dupe then original + test { + val id1 = insertWithData(DATA_A) + val id2 = insertWithData(DATA_A) + val dataFile = dataFile(id1) + + assertDataFilesAreTheSame(id1, id2) + + delete(id2) + assertDeleted(id2) + assertRowAndFileExists(id1) + assertTrue(dataFile.exists()) + + delete(id1) + assertDeleted(id1) + assertFalse(dataFile.exists()) + } + + // Delete original after it was compressed + test { + val id1 = insertWithData(DATA_A) + compress(id1, DATA_A_COMPRESSED) + + val id2 = insertWithData(DATA_A) + + delete(id1) + + assertDeleted(id1) + assertRowAndFileExists(id2) + assertSkipTransform(id2, true) + } + + // Quotes are weak references and should not prevent us from deleting the file + test { + val id1 = insertWithData(DATA_A) + val id2 = insertQuote(id1) + + val dataFile = dataFile(id1) + + delete(id1) + assertDeleted(id1) + assertRowExists(id2) + assertFalse(dataFile.exists()) + } + } + + private class TestContext { + fun insertWithData(data: ByteArray, transformProperties: TransformProperties = TransformProperties.empty()): AttachmentId { + val uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory() + + val attachment = UriAttachmentBuilder.build( + id = Random.nextLong(), + uri = uri, + contentType = MediaUtil.IMAGE_JPEG, + transformProperties = transformProperties + ) + + return SignalDatabase.attachments.insertAttachmentForPreUpload(attachment).attachmentId + } + + fun insertQuote(attachmentId: AttachmentId): AttachmentId { + val originalAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!! + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.self()) + val messageId = SignalDatabase.messages.insertMessageOutbox( + message = OutgoingMessage( + threadRecipient = Recipient.self(), + sentTimeMillis = System.currentTimeMillis(), + body = "some text", + outgoingQuote = QuoteModel( + id = 123, + author = Recipient.self().id, + text = "Some quote text", + isOriginalMissing = false, + attachments = listOf(originalAttachment), + mentions = emptyList(), + type = QuoteModel.Type.NORMAL, + bodyRanges = null + ) + ), + threadId = threadId, + forceSms = false, + insertListener = null + ) + + val attachments = SignalDatabase.attachments.getAttachmentsForMessage(messageId) + return attachments[0].attachmentId + } + + fun compress(attachmentId: AttachmentId, newData: ByteArray, mp4FastStart: Boolean = false) { + val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!! + SignalDatabase.attachments.updateAttachmentData(databaseAttachment, newData.asMediaStream()) + SignalDatabase.attachments.markAttachmentAsTransformed(attachmentId, withFastStart = mp4FastStart) + } + + fun upload(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()) { + SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, createPointerAttachment(attachmentId, uploadTimestamp), uploadTimestamp) + } + + fun delete(attachmentId: AttachmentId) { + SignalDatabase.attachments.deleteAttachment(attachmentId) + } + + fun dataFile(attachmentId: AttachmentId): File { + return SignalDatabase.attachments.getDataFileInfo(attachmentId)!!.file + } + + fun assertDeleted(attachmentId: AttachmentId) { + assertNull("$attachmentId exists, but it shouldn't!", SignalDatabase.attachments.getAttachment(attachmentId)) + } + + fun assertRowAndFileExists(attachmentId: AttachmentId) { + val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId) + assertNotNull("$attachmentId does not exist!", databaseAttachment) + + val dataFileInfo = SignalDatabase.attachments.getDataFileInfo(attachmentId) + assertTrue("The file for $attachmentId does not exist!", dataFileInfo!!.file.exists()) + } + + fun assertRowExists(attachmentId: AttachmentId) { + val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId) + assertNotNull("$attachmentId does not exist!", databaseAttachment) + } + + fun assertDataFilesAreTheSame(lhs: AttachmentId, rhs: AttachmentId) { + val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!! + val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!! + + assert(lhsInfo.file.exists()) + assert(rhsInfo.file.exists()) + + assertEquals(lhsInfo.file, rhsInfo.file) + assertEquals(lhsInfo.length, rhsInfo.length) + assertArrayEquals(lhsInfo.random, rhsInfo.random) + } + + fun assertDataFilesAreDifferent(lhs: AttachmentId, rhs: AttachmentId) { + val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!! + val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!! + + assert(lhsInfo.file.exists()) + assert(rhsInfo.file.exists()) + + assertNotEquals(lhsInfo.file, rhsInfo.file) + } + + fun assertDataHashStartMatches(lhs: AttachmentId, rhs: AttachmentId) { + val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!! + val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!! + + assertEquals("DATA_HASH_START's did not match!", lhsInfo.hashStart, rhsInfo.hashStart) + } + + fun assertDataHashEndMatches(lhs: AttachmentId, rhs: AttachmentId) { + val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!! + val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!! + + assertEquals("DATA_HASH_END's did not match!", lhsInfo.hashEnd, rhsInfo.hashEnd) + } + + fun assertRemoteFieldsMatch(lhs: AttachmentId, rhs: AttachmentId) { + val lhsAttachment = SignalDatabase.attachments.getAttachment(lhs)!! + val rhsAttachment = SignalDatabase.attachments.getAttachment(rhs)!! + + assertEquals(lhsAttachment.remoteLocation, rhsAttachment.remoteLocation) + assertEquals(lhsAttachment.remoteKey, rhsAttachment.remoteKey) + assertArrayEquals(lhsAttachment.remoteDigest, rhsAttachment.remoteDigest) + assertArrayEquals(lhsAttachment.incrementalDigest, rhsAttachment.incrementalDigest) + assertEquals(lhsAttachment.incrementalMacChunkSize, rhsAttachment.incrementalMacChunkSize) + assertEquals(lhsAttachment.cdnNumber, rhsAttachment.cdnNumber) + } + + fun assertDoesNotHaveRemoteFields(attachmentId: AttachmentId) { + val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!! + assertEquals(0, databaseAttachment.uploadTimestamp) + assertNull(databaseAttachment.remoteLocation) + assertNull(databaseAttachment.remoteDigest) + assertNull(databaseAttachment.remoteKey) + assertEquals(0, databaseAttachment.cdnNumber) + } + + fun assertSkipTransform(attachmentId: AttachmentId, state: Boolean) { + val transformProperties = SignalDatabase.attachments.getTransformProperties(attachmentId)!! + assertEquals("Incorrect skipTransform!", transformProperties.skipTransform, state) + } + + private fun ByteArray.asMediaStream(): MediaStream { + return MediaStream(this.inputStream(), MediaUtil.IMAGE_JPEG, 2, 2) + } + + private fun createPointerAttachment(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()): PointerAttachment { + val location = "somewhere-${Random.nextLong()}" + val key = "somekey-${Random.nextLong()}" + val digest = Random.nextBytes(32) + val incrementalDigest = Random.nextBytes(16) + + val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!! + + return PointerAttachment( + "image/jpeg", + AttachmentTable.TRANSFER_PROGRESS_DONE, + databaseAttachment.size, // size + null, + 3, // cdnNumber + location, + key, + digest, + incrementalDigest, + 5, // incrementalMacChunkSize + null, + databaseAttachment.voiceNote, + databaseAttachment.borderless, + databaseAttachment.videoGif, + databaseAttachment.width, + databaseAttachment.height, + uploadTimestamp, + databaseAttachment.caption, + databaseAttachment.stickerLocator, + databaseAttachment.blurHash + ) + } + } + + private fun test(content: TestContext.() -> Unit) { + SignalDatabase.attachments.deleteAllAttachments() + val context = TestContext() + context.content() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.kt index 981ba5d880..2af8d50af8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.kt @@ -3,13 +3,14 @@ package org.thoughtcrime.securesms.attachments import android.os.Parcelable import com.fasterxml.jackson.annotation.JsonProperty import kotlinx.parcelize.Parcelize +import org.signal.core.util.DatabaseId @Parcelize data class AttachmentId( @JsonProperty("rowId") @JvmField val id: Long -) : Parcelable { +) : Parcelable, DatabaseId { val isValid: Boolean get() = id >= 0 @@ -17,4 +18,8 @@ data class AttachmentId( override fun toString(): String { return "AttachmentId::$id" } + + override fun serialize(): String { + return id.toString() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt index 92d533c84f..66175b0a33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.attachments import android.net.Uri import android.os.Parcel +import androidx.annotation.VisibleForTesting import org.signal.core.util.Base64.encodeWithPadding import org.thoughtcrime.securesms.blurhash.BlurHash import org.thoughtcrime.securesms.database.AttachmentTable @@ -14,7 +15,8 @@ import org.whispersystems.signalservice.internal.push.DataMessage import java.util.Optional class PointerAttachment : Attachment { - private constructor( + @VisibleForTesting + constructor( contentType: String, transferState: Int, size: Long, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java index 2172975c1e..6c7212bf8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java @@ -194,7 +194,7 @@ public class FullBackupImporter extends FullBackupBase { private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream) throws IOException { - File dataFile = AttachmentTable.newFile(context); + File dataFile = AttachmentTable.newDataFile(context); Pair output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false); boolean isLegacyTable = SqlUtil.tableExists(db, "part"); 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 93d2c6e02d..bfe57fc180 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -32,14 +32,13 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.json.JSONArray import org.json.JSONException -import org.signal.core.util.Base64.encodeWithPadding -import org.signal.core.util.SqlUtil.buildArgs -import org.signal.core.util.SqlUtil.buildCollectionQuery -import org.signal.core.util.SqlUtil.buildSingleCollectionQuery +import org.signal.core.util.Base64 +import org.signal.core.util.SqlUtil import org.signal.core.util.StreamUtil import org.signal.core.util.ThreadUtil import org.signal.core.util.delete import org.signal.core.util.deleteAll +import org.signal.core.util.drain import org.signal.core.util.exists import org.signal.core.util.forEach import org.signal.core.util.groupBy @@ -55,6 +54,7 @@ import org.signal.core.util.requireNonNullBlob import org.signal.core.util.requireNonNullString import org.signal.core.util.requireString import org.signal.core.util.select +import org.signal.core.util.toInt import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.thoughtcrime.securesms.attachments.Attachment @@ -72,6 +72,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob +import org.thoughtcrime.securesms.jobs.AttachmentUploadJob import org.thoughtcrime.securesms.jobs.GenerateAudioWaveFormJob import org.thoughtcrime.securesms.mms.MediaStream import org.thoughtcrime.securesms.mms.MmsException @@ -93,6 +94,7 @@ import java.security.MessageDigest import java.security.NoSuchAlgorithmException import java.util.LinkedList import java.util.Optional +import kotlin.time.Duration.Companion.days class AttachmentTable( context: Context, @@ -118,7 +120,8 @@ class AttachmentTable( const val DATA_FILE = "data_file" const val DATA_SIZE = "data_size" const val DATA_RANDOM = "data_random" - const val DATA_HASH = "data_hash" + const val DATA_HASH_START = "data_hash_start" + const val DATA_HASH_END = "data_hash_end" const val FILE_NAME = "file_name" const val FAST_PREFLIGHT_ID = "fast_preflight_id" const val VOICE_NOTE = "voice_note" @@ -163,7 +166,6 @@ class AttachmentTable( DATA_FILE, DATA_SIZE, DATA_RANDOM, - DATA_HASH, FILE_NAME, FAST_PREFLIGHT_ID, VOICE_NOTE, @@ -180,7 +182,9 @@ class AttachmentTable( BLUR_HASH, TRANSFORM_PROPERTIES, DISPLAY_ORDER, - UPLOAD_TIMESTAMP + UPLOAD_TIMESTAMP, + DATA_HASH_START, + DATA_HASH_END ) const val CREATE_TABLE = """ @@ -199,7 +203,6 @@ class AttachmentTable( $DATA_FILE TEXT, $DATA_SIZE INTEGER, $DATA_RANDOM BLOB, - $DATA_HASH TEXT DEFAULT NULL, $FILE_NAME TEXT, $FAST_PREFLIGHT_ID TEXT, $VOICE_NOTE INTEGER DEFAULT 0, @@ -216,7 +219,9 @@ class AttachmentTable( $BLUR_HASH TEXT DEFAULT NULL, $TRANSFORM_PROPERTIES TEXT DEFAULT NULL, $DISPLAY_ORDER INTEGER DEFAULT 0, - $UPLOAD_TIMESTAMP INTEGER DEFAULT 0 + $UPLOAD_TIMESTAMP INTEGER DEFAULT 0, + $DATA_HASH_START TEXT DEFAULT NULL, + $DATA_HASH_END TEXT DEFAULT NULL ) """ @@ -225,14 +230,17 @@ class AttachmentTable( "CREATE INDEX IF NOT EXISTS attachment_message_id_index ON $TABLE_NAME ($MESSAGE_ID);", "CREATE INDEX IF NOT EXISTS attachment_transfer_state_index ON $TABLE_NAME ($TRANSFER_STATE);", "CREATE INDEX IF NOT EXISTS attachment_sticker_pack_id_index ON $TABLE_NAME ($STICKER_PACK_ID);", - "CREATE INDEX IF NOT EXISTS attachment_data_hash_index ON $TABLE_NAME ($DATA_HASH);", + "CREATE INDEX IF NOT EXISTS attachment_data_hash_start_index ON $TABLE_NAME ($DATA_HASH_START);", + "CREATE INDEX IF NOT EXISTS attachment_data_hash_end_index ON $TABLE_NAME ($DATA_HASH_END);", "CREATE INDEX IF NOT EXISTS attachment_data_index ON $TABLE_NAME ($DATA_FILE);" ) + val ATTACHMENT_POINTER_REUSE_THRESHOLD = 7.days.inWholeMilliseconds + @JvmStatic @JvmOverloads @Throws(IOException::class) - fun newFile(context: Context): File { + fun newDataFile(context: Context): File { val partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE) return PartFileProtector.protect { File.createTempFile("part", ".mms", partsDirectory) } } @@ -241,7 +249,7 @@ class AttachmentTable( @Throws(IOException::class) fun getAttachmentStream(attachmentId: AttachmentId, offset: Long): InputStream { return try { - getDataStream(attachmentId, DATA_FILE, offset) + getDataStream(attachmentId, offset) } catch (e: FileNotFoundException) { throw IOException("No stream for: $attachmentId", e) } ?: throw IOException("No stream for: $attachmentId") @@ -274,7 +282,7 @@ class AttachmentTable( return emptyMap() } - val query = buildSingleCollectionQuery(MESSAGE_ID, mmsIds) + val query = SqlUtil.buildSingleCollectionQuery(MESSAGE_ID, mmsIds) return readableDatabase .select(*PROJECTION) @@ -318,8 +326,8 @@ class AttachmentTable( ApplicationDependencies.getJobManager().cancelAllInQueue(AttachmentDownloadJob.constructQueueString(attachmentId)) - deleteAttachmentOnDisk( - data = cursor.requireString(DATA_FILE), + deleteDataFileIfPossible( + filePath = cursor.requireString(DATA_FILE), contentType = cursor.requireString(CONTENT_TYPE), attachmentId = attachmentId ) @@ -368,8 +376,8 @@ class AttachmentTable( .where("$MESSAGE_ID = ?", messageId) .run() .forEach { cursor -> - deleteAttachmentOnDisk( - data = cursor.requireString(DATA_FILE), + deleteDataFileIfPossible( + filePath = cursor.requireString(DATA_FILE), contentType = cursor.requireString(CONTENT_TYPE), attachmentId = AttachmentId(cursor.requireLong(ID)) ) @@ -379,7 +387,8 @@ class AttachmentTable( .values( DATA_FILE to null, DATA_RANDOM to null, - DATA_HASH to null, + DATA_HASH_START to null, + DATA_HASH_END to null, FILE_NAME to null, CAPTION to null, DATA_SIZE to 0, @@ -418,8 +427,8 @@ class AttachmentTable( val data = cursor.requireString(DATA_FILE) val contentType = cursor.requireString(CONTENT_TYPE) - deleteAttachmentOnDisk( - data = data, + deleteDataFileIfPossible( + filePath = data, contentType = contentType, attachmentId = id ) @@ -428,7 +437,7 @@ class AttachmentTable( .where("$ID = ?", id.id) .run() - deleteAttachmentOnDisk(data, contentType, id) + deleteDataFileIfPossible(data, contentType, id) notifyAttachmentListeners() } } @@ -516,27 +525,50 @@ class AttachmentTable( notifyConversationListeners(messages.getThreadIdForMessage(mmsId)) } + /** + * When we find out about a new inbound attachment pointer, we insert a row for it that contains all the info we need to download it via [insertAttachmentWithData]. + * Later, we download the data for that pointer. Call this method once you have the data to associate it with the attachment. At this point, it is assumed + * that the content of the attachment will never change. + */ @Throws(MmsException::class) - fun insertAttachmentsForPlaceholder(mmsId: Long, attachmentId: AttachmentId, inputStream: InputStream) { - val placeholder = getAttachment(attachmentId) - val oldInfo = getAttachmentDataFileInfo(attachmentId, DATA_FILE) - var dataInfo = storeAttachmentStream(inputStream) - val transferFile = getTransferFile(databaseHelper.signalReadableDatabase, attachmentId) + fun finalizeAttachmentAfterDownload(mmsId: Long, attachmentId: AttachmentId, inputStream: InputStream) { + Log.i(TAG, "[finalizeAttachmentAfterDownload] Finalizing downloaded data for $attachmentId. (MessageId: $mmsId, $attachmentId)") - val updated = writableDatabase.withinTransaction { db -> - dataInfo = deduplicateAttachment(dataInfo, attachmentId, placeholder?.transformProperties ?: TransformProperties.empty()) + val existingPlaceholder: DatabaseAttachment = getAttachment(attachmentId) ?: throw MmsException("No attachment found for id: $attachmentId") - if (oldInfo != null) { - updateAttachmentDataHash(db, oldInfo.hash, dataInfo) - } + val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), inputStream, TransformProperties.empty()) + val transferFile: File? = getTransferFile(databaseHelper.signalReadableDatabase, attachmentId) + + val foundDuplicate = writableDatabase.withinTransaction { db -> + // We can look and see if we have any exact matches on hash_ends and dedupe the file if we see one. + // We don't look at hash_start here because that could result in us matching on a file that got compressed down to something smaller, effectively lowering + // the quality of the attachment we received. + val hashMatch: DataFileInfo? = readableDatabase + .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) + .from(TABLE_NAME) + .where("$DATA_HASH_END = ? AND $DATA_HASH_END NOT NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_FILE NOT NULL", fileWriteResult.hash) + .run() + .readToList { it.readDataFileInfo() } + .firstOrNull() val values = ContentValues() - values.put(DATA_FILE, dataInfo.file.absolutePath) - values.put(DATA_SIZE, dataInfo.length) - values.put(DATA_RANDOM, dataInfo.random) - values.put(DATA_HASH, dataInfo.hash) - val visualHashString = placeholder.getVisualHashStringOrNull() + if (hashMatch != null) { + Log.i(TAG, "[finalizeAttachmentAfterDownload] Found that ${hashMatch.id} has the same DATA_HASH_END. Deduping. (MessageId: $mmsId, $attachmentId)") + values.put(DATA_FILE, hashMatch.file.absolutePath) + values.put(DATA_SIZE, hashMatch.length) + values.put(DATA_RANDOM, hashMatch.random) + values.put(DATA_HASH_START, hashMatch.hashEnd) + values.put(DATA_HASH_END, hashMatch.hashEnd) + } else { + values.put(DATA_FILE, fileWriteResult.file.absolutePath) + values.put(DATA_SIZE, fileWriteResult.length) + values.put(DATA_RANDOM, fileWriteResult.random) + values.put(DATA_HASH_START, fileWriteResult.hash) + values.put(DATA_HASH_END, fileWriteResult.hash) + } + + val visualHashString = existingPlaceholder.getVisualHashStringOrNull() if (visualHashString != null) { values.put(BLUR_HASH, visualHashString) } @@ -545,26 +577,26 @@ class AttachmentTable( values.put(TRANSFER_FILE, null as String?) values.put(TRANSFORM_PROPERTIES, TransformProperties.forSkipTransform().serialize()) - val updateCount = db.update(TABLE_NAME) + db.update(TABLE_NAME) .values(values) .where("$ID = ?", attachmentId.id) .run() - updateCount > 0 + hashMatch != null } - if (updated) { - val threadId = messages.getThreadIdForMessage(mmsId) + val threadId = messages.getThreadIdForMessage(mmsId) - if (!messages.isStory(mmsId)) { - threads.updateSnippetUriSilently(threadId, PartAuthority.getAttachmentDataUri(attachmentId)) - } + if (!messages.isStory(mmsId)) { + threads.updateSnippetUriSilently(threadId, PartAuthority.getAttachmentDataUri(attachmentId)) + } - notifyConversationListeners(threadId) - notifyConversationListListeners() - notifyAttachmentListeners() - } else { - if (!dataInfo.file.delete()) { + notifyConversationListeners(threadId) + notifyConversationListListeners() + notifyAttachmentListeners() + + if (foundDuplicate) { + if (!fileWriteResult.file.delete()) { Log.w(TAG, "Failed to delete unused attachment") } } @@ -575,21 +607,67 @@ class AttachmentTable( } } - if (placeholder != null && MediaUtil.isAudio(placeholder)) { - GenerateAudioWaveFormJob.enqueue(placeholder.attachmentId) + if (MediaUtil.isAudio(existingPlaceholder)) { + GenerateAudioWaveFormJob.enqueue(existingPlaceholder.attachmentId) + } + } + + /** + * Needs to be called after an attachment is successfully uploaded. Writes metadata around it's final remote location, as well as calculates + * it's ending hash, which is critical for backups. + */ + @Throws(IOException::class) + fun finalizeAttachmentAfterUpload(id: AttachmentId, attachment: Attachment, uploadTimestamp: Long) { + Log.i(TAG, "[finalizeAttachmentAfterUpload] Finalizing upload for $id.") + + val dataStream = getAttachmentStream(id, 0) + val messageDigest = MessageDigest.getInstance("SHA-256") + + DigestInputStream(dataStream, messageDigest).use { + it.drain() + } + + val dataHashEnd = Base64.encodeWithPadding(messageDigest.digest()) + + val values = contentValuesOf( + TRANSFER_STATE to TRANSFER_PROGRESS_DONE, + CDN_NUMBER to attachment.cdnNumber, + REMOTE_LOCATION to attachment.remoteLocation, + REMOTE_DIGEST to attachment.remoteDigest, + REMOTE_INCREMENTAL_DIGEST to attachment.incrementalDigest, + REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to attachment.incrementalMacChunkSize, + REMOTE_KEY to attachment.remoteKey, + DATA_SIZE to attachment.size, + DATA_HASH_END to dataHashEnd, + FAST_PREFLIGHT_ID to attachment.fastPreflightId, + BLUR_HASH to attachment.getVisualHashStringOrNull(), + UPLOAD_TIMESTAMP to uploadTimestamp + ) + + val dataFilePath = getDataFilePath(id) ?: throw IOException("No data file found for attachment!") + + val updateCount = writableDatabase + .update(TABLE_NAME) + .values(values) + .where("$ID = ? OR $DATA_FILE = ?", id.id, dataFilePath) + .run() + + if (updateCount <= 0) { + Log.w(TAG, "[finalizeAttachmentAfterUpload] Failed to update attachment after upload! $id") } } @Throws(MmsException::class) fun copyAttachmentData(sourceId: AttachmentId, destinationId: AttachmentId) { val sourceAttachment = getAttachment(sourceId) ?: throw MmsException("Cannot find attachment for source!") - val sourceDataInfo = getAttachmentDataFileInfo(sourceId, DATA_FILE) ?: throw MmsException("No attachment data found for source!") + val sourceDataInfo = getDataFileInfo(sourceId) ?: throw MmsException("No attachment data found for source!") writableDatabase .update(TABLE_NAME) .values( DATA_FILE to sourceDataInfo.file.absolutePath, - DATA_HASH to sourceDataInfo.hash, + DATA_HASH_START to sourceDataInfo.hashStart, + DATA_HASH_END to sourceDataInfo.hashEnd, DATA_SIZE to sourceDataInfo.length, DATA_RANDOM to sourceDataInfo.random, TRANSFER_STATE to sourceAttachment.transferState, @@ -630,33 +708,6 @@ class AttachmentTable( } } - fun updateAttachmentAfterUpload(id: AttachmentId, attachment: Attachment, uploadTimestamp: Long) { - val dataInfo = getAttachmentDataFileInfo(id, DATA_FILE) - val values = contentValuesOf( - TRANSFER_STATE to TRANSFER_PROGRESS_DONE, - CDN_NUMBER to attachment.cdnNumber, - REMOTE_LOCATION to attachment.remoteLocation, - REMOTE_DIGEST to attachment.remoteDigest, - REMOTE_INCREMENTAL_DIGEST to attachment.incrementalDigest, - REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to attachment.incrementalMacChunkSize, - REMOTE_KEY to attachment.remoteKey, - DATA_SIZE to attachment.size, - FAST_PREFLIGHT_ID to attachment.fastPreflightId, - BLUR_HASH to attachment.getVisualHashStringOrNull(), - UPLOAD_TIMESTAMP to uploadTimestamp - ) - - if (dataInfo?.hash != null) { - updateAttachmentAndMatchingHashes(writableDatabase, id, dataInfo.hash, values) - } else { - writableDatabase - .update(TABLE_NAME) - .values(values) - .where("$ID = ?", id.id) - .run() - } - } - @Throws(MmsException::class) fun insertAttachmentForPreUpload(attachment: Attachment): DatabaseAttachment { val result = insertAttachmentsForMessage(PREUPLOAD_MESSAGE_ID, listOf(attachment), emptyList()) @@ -692,26 +743,42 @@ class AttachmentTable( } } + /** + * Inserts new attachments in the table. The [Attachment]s may or may not have data, depending on whether it's an attachment we created locally or some + * inbound attachment that we haven't fetched yet. + * + * If the attachment has no data, it is assumed that you will later call [finalizeAttachmentAfterDownload]. + */ @Throws(MmsException::class) fun insertAttachmentsForMessage(mmsId: Long, attachments: List, quoteAttachment: List): Map { if (attachments.isEmpty() && quoteAttachment.isEmpty()) { return emptyMap() } - Log.d(TAG, "insertParts(${attachments.size})") + Log.d(TAG, "[insertAttachmentsForMessage] insertParts(${attachments.size})") val insertedAttachments: MutableMap = mutableMapOf() for (attachment in attachments) { - val attachmentId = insertAttachment(mmsId, attachment, attachment.quote) + val attachmentId = if (attachment.uri != null) { + insertAttachmentWithData(mmsId, attachment, attachment.quote) + } else { + insertUndownloadedAttachment(mmsId, attachment, attachment.quote) + } + insertedAttachments[attachment] = attachmentId - Log.i(TAG, "Inserted attachment at ID: $attachmentId") + Log.i(TAG, "[insertAttachmentsForMessage] Inserted attachment at $attachmentId") } try { for (attachment in quoteAttachment) { - val attachmentId = insertAttachment(mmsId, attachment, true) + val attachmentId = if (attachment.uri != null) { + insertAttachmentWithData(mmsId, attachment, true) + } else { + insertUndownloadedAttachment(mmsId, attachment, true) + } + insertedAttachments[attachment] = attachmentId - Log.i(TAG, "Inserted quoted attachment at ID: $attachmentId") + Log.i(TAG, "[insertAttachmentsForMessage] Inserted quoted attachment at $attachmentId") } } catch (e: MmsException) { Log.w(TAG, "Failed to insert quote attachment! messageId: $mmsId") @@ -721,46 +788,33 @@ class AttachmentTable( } /** - * @param onlyModifyThisAttachment If false and more than one attachment shares this file and quality, they will all - * be updated. If true, then guarantees not to affect other attachments. + * Updates the data stored for an existing attachment. This happens after transformations, like transcoding. */ @Throws(MmsException::class, IOException::class) fun updateAttachmentData( databaseAttachment: DatabaseAttachment, - mediaStream: MediaStream, - onlyModifyThisAttachment: Boolean + mediaStream: MediaStream ) { val attachmentId = databaseAttachment.attachmentId - val oldDataInfo = getAttachmentDataFileInfo(attachmentId, DATA_FILE) ?: throw MmsException("No attachment data found!") - var destination = oldDataInfo.file - val isSingleUseOfData = onlyModifyThisAttachment || oldDataInfo.hash == null + val existingDataFileInfo: DataFileInfo = getDataFileInfo(attachmentId) ?: throw MmsException("No attachment data found!") + val newDataFileInfo: DataFileWriteResult = writeToDataFile(existingDataFileInfo.file, mediaStream.stream, databaseAttachment.transformProperties ?: TransformProperties.empty()) - if (isSingleUseOfData && fileReferencedByMoreThanOneAttachment(destination)) { - Log.i(TAG, "Creating a new file as this one is used by more than one attachment") - destination = newFile(context) - } - - var dataInfo: DataInfo = storeAttachmentStream(destination, mediaStream.stream) + // TODO We don't dedupe here because we're assuming that we should have caught any dupe scenarios on first insert. We could consider doing dupe checks here though. writableDatabase.withinTransaction { db -> - dataInfo = deduplicateAttachment(dataInfo, attachmentId, databaseAttachment.transformProperties) - val contentValues = contentValuesOf( - DATA_SIZE to dataInfo.length, + DATA_SIZE to newDataFileInfo.length, CONTENT_TYPE to mediaStream.mimeType, WIDTH to mediaStream.width, HEIGHT to mediaStream.height, - DATA_FILE to dataInfo.file.absolutePath, - DATA_RANDOM to dataInfo.random, - DATA_HASH to dataInfo.hash + DATA_FILE to newDataFileInfo.file.absolutePath, + DATA_RANDOM to newDataFileInfo.random ) - val updateCount = updateAttachmentAndMatchingHashes( - db = db, - attachmentId = attachmentId, - dataHash = if (isSingleUseOfData) dataInfo.hash else oldDataInfo.hash, - contentValues = contentValues - ) + val updateCount = db.update(TABLE_NAME) + .values(contentValues) + .where("$ID = ? OR $DATA_FILE = ?", attachmentId.id, existingDataFileInfo.file.absolutePath) + .run() Log.i(TAG, "[updateAttachmentData] Updated $updateCount rows.") } @@ -768,14 +822,14 @@ class AttachmentTable( fun duplicateAttachmentsForMessage(destinationMessageId: Long, sourceMessageId: Long, excludedIds: Collection) { writableDatabase.withinTransaction { db -> - db.execSQL("CREATE TEMPORARY TABLE tmp_part AS SELECT * FROM $TABLE_NAME WHERE $MESSAGE_ID = ?", buildArgs(sourceMessageId)) + db.execSQL("CREATE TEMPORARY TABLE tmp_part AS SELECT * FROM $TABLE_NAME WHERE $MESSAGE_ID = ?", SqlUtil.buildArgs(sourceMessageId)) - val queries = buildCollectionQuery(ID, excludedIds) + val queries = SqlUtil.buildCollectionQuery(ID, excludedIds) for (query in queries) { db.delete("tmp_part", query.where, query.whereArgs) } - db.execSQL("UPDATE tmp_part SET $ID = NULL, $MESSAGE_ID = ?", buildArgs(destinationMessageId)) + db.execSQL("UPDATE tmp_part SET $ID = NULL, $MESSAGE_ID = ?", SqlUtil.buildArgs(destinationMessageId)) db.execSQL("INSERT INTO $TABLE_NAME SELECT * FROM tmp_part") db.execSQL("DROP TABLE tmp_part") } @@ -800,44 +854,54 @@ class AttachmentTable( } @VisibleForTesting - fun getAttachmentDataFileInfo(attachmentId: AttachmentId, dataType: String): DataInfo? { + fun getDataFileInfo(attachmentId: AttachmentId): DataFileInfo? { return readableDatabase - .select(dataType, DATA_SIZE, DATA_RANDOM, DATA_HASH, TRANSFORM_PROPERTIES) + .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) .from(TABLE_NAME) .where("$ID = ?", attachmentId.id) .run() .readToSingleObject { cursor -> - if (cursor.isNull(dataType)) { + if (cursor.isNull(DATA_FILE)) { null } else { - DataInfo( - file = File(cursor.getString(cursor.getColumnIndexOrThrow(dataType))), - length = cursor.requireLong(DATA_SIZE), - random = cursor.requireNonNullBlob(DATA_RANDOM), - hash = cursor.requireString(DATA_HASH), - transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)) - ) + cursor.readDataFileInfo() } } } + fun getDataFilePath(attachmentId: AttachmentId): String? { + return readableDatabase + .select(DATA_FILE) + .from(TABLE_NAME) + .where("$ID = ?", attachmentId.id) + .run() + .readToSingleObject { it.requireString(DATA_FILE) } + } + fun markAttachmentAsTransformed(attachmentId: AttachmentId, withFastStart: Boolean) { + Log.i(TAG, "[markAttachmentAsTransformed] Marking $attachmentId as transformed. withFastStart: $withFastStart") writableDatabase.withinTransaction { db -> try { - var transformProperties = getTransformProperties(attachmentId) - if (transformProperties == null) { - Log.w(TAG, "Failed to get transformation properties, attachment no longer exists.") + val dataInfo = getDataFileInfo(attachmentId) + if (dataInfo == null) { + Log.w(TAG, "[markAttachmentAsTransformed] Failed to get transformation properties, attachment no longer exists.") return@withinTransaction } - transformProperties = transformProperties.withSkipTransform() + var transformProperties = dataInfo.transformProperties.withSkipTransform() if (withFastStart) { transformProperties = transformProperties.withMp4FastStart() } - updateAttachmentTransformProperties(attachmentId, transformProperties) + val count = writableDatabase + .update(TABLE_NAME) + .values(TRANSFORM_PROPERTIES to transformProperties.serialize()) + .where("$ID = ? OR $DATA_FILE = ?", attachmentId.id, dataInfo.file.absolutePath) + .run() + + Log.i(TAG, "[markAttachmentAsTransformed] Updated $count rows.") } catch (e: Exception) { - Log.w(TAG, "Could not mark attachment as transformed.", e) + Log.w(TAG, "[markAttachmentAsTransformed] Could not mark attachment as transformed.", e) } } } @@ -854,7 +918,7 @@ class AttachmentTable( @RequiresApi(23) fun mediaDataSourceFor(attachmentId: AttachmentId, allowReadingFromTempFile: Boolean): MediaDataSource? { - val dataInfo = getAttachmentDataFileInfo(attachmentId, DATA_FILE) + val dataInfo = getDataFileInfo(attachmentId) if (dataInfo != null) { return EncryptedMediaDataSource.createFor(attachmentSecret, dataInfo.file, dataInfo.random, dataInfo.length) } @@ -950,7 +1014,7 @@ class AttachmentTable( transformProperties = TransformProperties.parse(jsonObject.getString(TRANSFORM_PROPERTIES)), displayOrder = jsonObject.getInt(DISPLAY_ORDER), uploadTimestamp = jsonObject.getLong(UPLOAD_TIMESTAMP), - dataHash = jsonObject.getString(DATA_HASH) + dataHash = jsonObject.getString(DATA_HASH_END) ) } } @@ -990,52 +1054,47 @@ class AttachmentTable( return readableDatabase.rawQuery(query, null) } - private fun deleteAttachmentOnDisk( - data: String?, + /** + * Deletes the data file if there's no strong references to other attachments. + * If deleted, it will also clear all weak references (i.e. quotes) of the attachment. + */ + private fun deleteDataFileIfPossible( + filePath: String?, contentType: String?, attachmentId: AttachmentId ) { check(writableDatabase.inTransaction()) { "Must be in a transaction!" } - val dataUsage = getAttachmentFileUsages(data, attachmentId) - if (dataUsage.hasStrongReference) { - Log.i(TAG, "[deleteAttachmentOnDisk] Attachment in use. Skipping deletion. $data $attachmentId") + if (filePath == null) { + Log.w(TAG, "[deleteDataFileIfPossible] Null data file path for $attachmentId! Can't delete anything.") return } - Log.i(TAG, "[deleteAttachmentOnDisk] No other strong uses of this attachment. Safe to delete. $data $attachmentId") - if (!data.isNullOrBlank()) { - if (File(data).delete()) { - Log.i(TAG, "[deleteAttachmentOnDisk] Deleted attachment file. $data $attachmentId") + val strongReferenceExists = readableDatabase + .exists(TABLE_NAME) + .where("$DATA_FILE = ? AND QUOTE = 0 AND $ID != ${attachmentId.id}", filePath) + .run() - if (dataUsage.removableWeakReferences.isNotEmpty()) { - Log.i(TAG, "[deleteAttachmentOnDisk] Deleting ${dataUsage.removableWeakReferences.size} weak references for $data") + if (strongReferenceExists) { + Log.i(TAG, "[deleteDataFileIfPossible] Attachment in use. Skipping deletion of $attachmentId. Path: $filePath") + return + } - var deletedCount = 0 - for (weakReference in dataUsage.removableWeakReferences) { - Log.i(TAG, "[deleteAttachmentOnDisk] Clearing weak reference for $data $weakReference") + val weakReferenceCount = writableDatabase + .update(TABLE_NAME) + .values( + DATA_FILE to null, + DATA_RANDOM to null, + DATA_HASH_START to null, + DATA_HASH_END to null + ) + .where("$DATA_FILE = ?", filePath) + .run() - deletedCount += writableDatabase - .update(TABLE_NAME) - .values( - DATA_FILE to null, - DATA_RANDOM to null, - DATA_HASH to null - ) - .where("$ID = ?", weakReference.id) - .run() - } + Log.i(TAG, "[deleteDataFileIfPossible] Cleared $weakReferenceCount weak references for $attachmentId. Path: $filePath") - val logMessage = "[deleteAttachmentOnDisk] Cleared $deletedCount/${dataUsage.removableWeakReferences.size} weak references for $data" - if (deletedCount != dataUsage.removableWeakReferences.size) { - Log.w(TAG, logMessage) - } else { - Log.i(TAG, logMessage) - } - } - } else { - Log.w(TAG, "[deleteAttachmentOnDisk] Failed to delete attachment. $data $attachmentId") - } + if (!File(filePath).delete()) { + Log.w(TAG, "[deleteDataFileIfPossible] Failed to delete $attachmentId. Path: $filePath") } if (MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType)) { @@ -1044,109 +1103,9 @@ class AttachmentTable( } } - private fun getAttachmentFileUsages(data: String?, attachmentId: AttachmentId): DataUsageResult { - check(writableDatabase.inTransaction()) { "Must be in a transaction!" } - - if (data == null) { - return DataUsageResult.NOT_IN_USE - } - - val quoteRows: MutableList = mutableListOf() - - readableDatabase - .select(ID, QUOTE) - .from(TABLE_NAME) - .where("$DATA_FILE = ? AND $ID != ?", data, attachmentId.id) - .run() - .forEach { cursor -> - if (cursor.requireBoolean(QUOTE)) { - quoteRows += AttachmentId(cursor.requireLong(ID)) - } else { - return DataUsageResult.IN_USE - } - } - - return DataUsageResult(quoteRows) - } - - /** - * Check if data file is in use by another attachment row with a different hash. Rows with the same data and hash - * will be fixed in a later call to [updateAttachmentAndMatchingHashes]. - */ - private fun isAttachmentFileUsedByOtherAttachments(attachmentId: AttachmentId?, dataInfo: DataInfo): Boolean { - return if (attachmentId == null || dataInfo.hash == null) { - false - } else { - readableDatabase - .exists(TABLE_NAME) - .where("$DATA_FILE = ? AND $DATA_HASH != ?", dataInfo.file.absolutePath, dataInfo.hash) - .run() - } - } - - private fun updateAttachmentDataHash( - db: SQLiteDatabase, - oldHash: String?, - newData: DataInfo - ) { - if (oldHash == null) { - return - } - - db.update(TABLE_NAME) - .values( - DATA_FILE to newData.file.absolutePath, - DATA_RANDOM to newData.random, - DATA_HASH to newData.hash - ) - .where("$DATA_HASH = ?", oldHash) - .run() - } - - private fun updateAttachmentTransformProperties(attachmentId: AttachmentId, transformProperties: TransformProperties) { - val dataInfo = getAttachmentDataFileInfo(attachmentId, DATA_FILE) - if (dataInfo == null) { - Log.w(TAG, "[updateAttachmentTransformProperties] No data info found!") - return - } - - val contentValues = contentValuesOf(TRANSFORM_PROPERTIES to transformProperties.serialize()) - val updateCount = updateAttachmentAndMatchingHashes(databaseHelper.signalWritableDatabase, attachmentId, dataInfo.hash, contentValues) - Log.i(TAG, "[updateAttachmentTransformProperties] Updated $updateCount rows.") - } - - private fun updateAttachmentAndMatchingHashes( - db: SQLiteDatabase, - attachmentId: AttachmentId, - dataHash: String?, - contentValues: ContentValues - ): Int { - return db - .update(TABLE_NAME) - .values(contentValues) - .where("$ID = ? OR ($DATA_HASH NOT NULL AND $DATA_HASH = ?)", attachmentId.id, dataHash.toString()) - .run() - } - - /** - * Returns true if the file referenced by two or more attachments. - * Returns false if the file is referenced by zero or one attachments. - */ - private fun fileReferencedByMoreThanOneAttachment(file: File): Boolean { - return readableDatabase - .select("1") - .from(TABLE_NAME) - .where("$DATA_FILE = ?", file.absolutePath) - .limit(2) - .run() - .use { cursor -> - cursor.moveToNext() && cursor.moveToNext() - } - } - @Throws(FileNotFoundException::class) - private fun getDataStream(attachmentId: AttachmentId, dataType: String, offset: Long): InputStream? { - val dataInfo = getAttachmentDataFileInfo(attachmentId, dataType) ?: return null + private fun getDataStream(attachmentId: AttachmentId, offset: Long): InputStream? { + val dataInfo = getDataFileInfo(attachmentId) ?: return null return try { if (dataInfo.random != null && dataInfo.random.size == 32) { @@ -1169,15 +1128,6 @@ class AttachmentTable( } } - @Throws(MmsException::class) - private fun storeAttachmentStream(inputStream: InputStream): DataInfo { - return try { - storeAttachmentStream(newFile(context), inputStream) - } catch (e: IOException) { - throw MmsException(e) - } - } - @Throws(IOException::class) private fun newTransferFile(): File { val partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE) @@ -1187,31 +1137,36 @@ class AttachmentTable( } /** - * Reads the entire stream and saves to disk. If you need to deduplicate attachments, call [deduplicateAttachment] - * afterwards and use the [DataInfo] returned by it instead. + * Reads the entire stream and saves to disk and returns a bunch of metadat about the write. */ @Throws(MmsException::class, IllegalStateException::class) - private fun storeAttachmentStream(destination: File, inputStream: InputStream): DataInfo { + private fun writeToDataFile(destination: File, inputStream: InputStream, transformProperties: TransformProperties): DataFileWriteResult { return try { - val tempFile = newFile(context) + // Sometimes the destination is a file that's already in use, sometimes it's not. + // To avoid writing to a file while it's in-use, we write to a temp file and then rename it to the destination file at the end. + val tempFile = newDataFile(context) val messageDigest = MessageDigest.getInstance("SHA-256") val digestInputStream = DigestInputStream(inputStream, messageDigest) - val out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, tempFile, false) - val length = StreamUtil.copy(digestInputStream, out.second) - val hash = encodeWithPadding(digestInputStream.messageDigest.digest()) + + val encryptingStreamData = ModernEncryptingPartOutputStream.createFor(attachmentSecret, tempFile, false) + val random = encryptingStreamData.first + val encryptingOutputStream = encryptingStreamData.second + + val length = StreamUtil.copy(digestInputStream, encryptingOutputStream) + val hash = Base64.encodeWithPadding(digestInputStream.messageDigest.digest()) if (!tempFile.renameTo(destination)) { - Log.w(TAG, "Couldn't rename ${tempFile.path} to ${destination.path}") + Log.w(TAG, "[writeToDataFile] Couldn't rename ${tempFile.path} to ${destination.path}") tempFile.delete() throw IllegalStateException("Couldn't rename ${tempFile.path} to ${destination.path}") } - DataInfo( + DataFileWriteResult( file = destination, length = length, - random = out.first, + random = random, hash = hash, - transformProperties = null + transformProperties = transformProperties ) } catch (e: IOException) { throw MmsException(e) @@ -1220,198 +1175,225 @@ class AttachmentTable( } } - private fun deduplicateAttachment( - dataInfo: DataInfo, - attachmentId: AttachmentId?, - transformProperties: TransformProperties? - ): DataInfo { - check(writableDatabase.inTransaction()) { "Must be in a transaction!" } - - val sharedDataInfos = findDuplicateDataFileInfos(writableDatabase, dataInfo.hash, attachmentId) - - for (sharedDataInfo in sharedDataInfos) { - if (dataInfo.file == sharedDataInfo.file) { - continue + private fun areTransformationsCompatible(newProperties: TransformProperties, potentialMatchProperties: TransformProperties, newHashStart: String, potentialMatchHashEnd: String?): Boolean { + // If we're starting now where another attachment finished, then it means we're forwarding an attachment. + if (newHashStart == potentialMatchHashEnd) { + // If the new attachment is an edited video, we can't re-use the file + if (newProperties.videoEdited) { + return false } - val isUsedElsewhere = isAttachmentFileUsedByOtherAttachments(attachmentId, dataInfo) - val isSameQuality = (transformProperties?.sentMediaQuality ?: 0) == (sharedDataInfo.transformProperties?.sentMediaQuality ?: 0) - - Log.i(TAG, "[deduplicateAttachment] Potential duplicate data file found. usedElsewhere: " + isUsedElsewhere + " sameQuality: " + isSameQuality + " otherFile: " + sharedDataInfo.file.absolutePath) - - if (!isSameQuality) { - continue + // If the potential match was sent using standard quality, we can re-use the file -- the new thing being high-quality can't make it any nicer + if (potentialMatchProperties.sentMediaQuality == SentMediaQuality.STANDARD.code) { + return true } - - if (!isUsedElsewhere) { - if (dataInfo.file.delete()) { - Log.i(TAG, "[deduplicateAttachment] Deleted original file. ${dataInfo.file}") - } else { - Log.w(TAG, "[deduplicateAttachment] Original file could not be deleted.") - } - } - - return sharedDataInfo } - Log.i(TAG, "[deduplicateAttachment] No acceptable matching attachment data found. ${dataInfo.file.absolutePath}") - return dataInfo - } - - private fun findDuplicateDataFileInfos( - database: SQLiteDatabase, - hash: String?, - excludedAttachmentId: AttachmentId? - ): List { - check(database.inTransaction()) { "Must be in a transaction!" } - - if (hash == null) { - return emptyList() + if (newProperties.sentMediaQuality != potentialMatchProperties.sentMediaQuality) { + return false } - val selectorArgs: Pair> = buildSharedFileSelectorArgs(hash, excludedAttachmentId) - - return database - .select(DATA_FILE, DATA_RANDOM, DATA_SIZE, TRANSFORM_PROPERTIES) - .from(TABLE_NAME) - .where(selectorArgs.first, selectorArgs.second) - .run() - .readToList { cursor -> - DataInfo( - file = File(cursor.requireNonNullString(DATA_FILE)), - length = cursor.requireLong(DATA_SIZE), - random = cursor.requireNonNullBlob(DATA_RANDOM), - hash = hash, - transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)) - ) - } - } - - private fun buildSharedFileSelectorArgs(newHash: String, attachmentId: AttachmentId?): Pair> { - return if (attachmentId == null) { - "$DATA_HASH = ?" to arrayOf(newHash) - } else { - "$ID != ? AND $DATA_HASH = ?" to arrayOf( - attachmentId.id.toString(), - newHash - ) + if (newProperties.videoEdited != potentialMatchProperties.videoEdited) { + return false } + + if (newProperties.videoTrimStartTimeUs != potentialMatchProperties.videoTrimStartTimeUs) { + return false + } + + if (newProperties.videoTrimEndTimeUs != potentialMatchProperties.videoTrimEndTimeUs) { + return false + } + + if (newProperties.mp4FastStart != potentialMatchProperties.mp4FastStart) { + return false + } + + return true } + /** + * Attachments need records in the database even if they haven't been downloaded yet. That allows us to store the info we need to download it, what message + * it's associated with, etc. We treat this case separately from attachments with data (see [insertAttachmentWithData]) because it's much simpler, + * and splitting the two use cases makes the code easier to understand. + * + * Callers are expected to later call [finalizeAttachmentAfterDownload] once they have downloaded the data for this attachment. + */ @Throws(MmsException::class) - private fun insertAttachment(mmsId: Long, attachment: Attachment, quote: Boolean): AttachmentId { - Log.d(TAG, "Inserting attachment for mms id: $mmsId") - - var notifyPacks = false + private fun insertUndownloadedAttachment(messageId: Long, attachment: Attachment, quote: Boolean): AttachmentId { + Log.d(TAG, "[insertAttachment] Inserting attachment for messageId $messageId.") val attachmentId: AttachmentId = writableDatabase.withinTransaction { db -> - try { - var dataInfo: DataInfo? = null - - if (attachment.uri != null) { - val storeDataInfo = storeAttachmentStream(PartAuthority.getAttachmentStream(context, attachment.uri!!)) - Log.d(TAG, "Wrote part to file: ${storeDataInfo.file.absolutePath}") - - dataInfo = deduplicateAttachment(storeDataInfo, null, attachment.transformProperties) - } - - var template = attachment - var useTemplateUpload = false - - if (dataInfo != null) { - val possibleTemplates = findTemplateAttachments(dataInfo.hash) - - for (possibleTemplate in possibleTemplates) { - useTemplateUpload = possibleTemplate.uploadTimestamp > attachment.uploadTimestamp && - possibleTemplate.transferState == TRANSFER_PROGRESS_DONE && - possibleTemplate.transformProperties?.shouldSkipTransform() == true && possibleTemplate.remoteDigest != null && - attachment.transformProperties?.videoEdited == false && possibleTemplate.transformProperties.sentMediaQuality == attachment.transformProperties.sentMediaQuality - - if (useTemplateUpload) { - Log.i(TAG, "Found a duplicate attachment upon insertion. Using it as a template.") - template = possibleTemplate - break - } - } - } - - val contentValues = ContentValues() - contentValues.put(MESSAGE_ID, mmsId) - contentValues.put(CONTENT_TYPE, template.contentType) - contentValues.put(TRANSFER_STATE, attachment.transferState) - contentValues.put(CDN_NUMBER, if (useTemplateUpload) template.cdnNumber else attachment.cdnNumber) - contentValues.put(REMOTE_LOCATION, if (useTemplateUpload) template.remoteLocation else attachment.remoteLocation) - contentValues.put(REMOTE_DIGEST, if (useTemplateUpload) template.remoteDigest else attachment.remoteDigest) - contentValues.put(REMOTE_INCREMENTAL_DIGEST, if (useTemplateUpload) template.incrementalDigest else attachment.incrementalDigest) - contentValues.put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, if (useTemplateUpload) template.incrementalMacChunkSize else attachment.incrementalMacChunkSize) - contentValues.put(REMOTE_KEY, if (useTemplateUpload) template.remoteKey else attachment.remoteKey) - contentValues.put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) - contentValues.put(DATA_SIZE, template.size) - contentValues.put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) - contentValues.put(VOICE_NOTE, if (attachment.voiceNote) 1 else 0) - contentValues.put(BORDERLESS, if (attachment.borderless) 1 else 0) - contentValues.put(VIDEO_GIF, if (attachment.videoGif) 1 else 0) - contentValues.put(WIDTH, template.width) - contentValues.put(HEIGHT, template.height) - contentValues.put(QUOTE, quote) - contentValues.put(CAPTION, attachment.caption) - contentValues.put(UPLOAD_TIMESTAMP, if (useTemplateUpload) template.uploadTimestamp else attachment.uploadTimestamp) - - if (attachment.transformProperties?.videoEdited == true) { - contentValues.putNull(BLUR_HASH) - contentValues.put(TRANSFORM_PROPERTIES, attachment.transformProperties?.serialize()) - } else { - contentValues.put(BLUR_HASH, template.getVisualHashStringOrNull()) - contentValues.put(TRANSFORM_PROPERTIES, (if (useTemplateUpload) template else attachment).transformProperties?.serialize()) - } + val contentValues = ContentValues().apply { + put(MESSAGE_ID, messageId) + put(CONTENT_TYPE, attachment.contentType) + put(TRANSFER_STATE, attachment.transferState) + put(CDN_NUMBER, attachment.cdnNumber) + put(REMOTE_LOCATION, attachment.remoteLocation) + put(REMOTE_DIGEST, attachment.remoteDigest) + put(REMOTE_INCREMENTAL_DIGEST, attachment.incrementalDigest) + put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, attachment.incrementalMacChunkSize) + put(REMOTE_KEY, attachment.remoteKey) + put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) + put(DATA_SIZE, attachment.size) + put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) + put(VOICE_NOTE, attachment.voiceNote.toInt()) + put(BORDERLESS, attachment.borderless.toInt()) + put(VIDEO_GIF, attachment.videoGif.toInt()) + put(WIDTH, attachment.width) + put(HEIGHT, attachment.height) + put(QUOTE, quote) + put(CAPTION, attachment.caption) + put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp) attachment.stickerLocator?.let { sticker -> - contentValues.put(STICKER_PACK_ID, sticker.packId) - contentValues.put(STICKER_PACK_KEY, sticker.packKey) - contentValues.put(STICKER_ID, sticker.stickerId) - contentValues.put(STICKER_EMOJI, sticker.emoji) + put(STICKER_PACK_ID, sticker.packId) + put(STICKER_PACK_KEY, sticker.packKey) + put(STICKER_ID, sticker.stickerId) + put(STICKER_EMOJI, sticker.emoji) } - - if (dataInfo != null) { - contentValues.put(DATA_FILE, dataInfo.file.absolutePath) - contentValues.put(DATA_SIZE, dataInfo.length) - contentValues.put(DATA_RANDOM, dataInfo.random) - - if (attachment.transformProperties?.videoEdited == true) { - contentValues.putNull(DATA_HASH) - } else { - contentValues.put(DATA_HASH, dataInfo.hash) - } - } - - notifyPacks = attachment.isSticker && !hasStickerAttachments() - - val rowId = db.insert(TABLE_NAME, null, contentValues) - AttachmentId(rowId) - } catch (e: IOException) { - throw MmsException(e) } - } - if (notifyPacks) { - notifyStickerPackListeners() + val rowId = db.insert(TABLE_NAME, null, contentValues) + AttachmentId(rowId) } notifyAttachmentListeners() return attachmentId } - private fun findTemplateAttachments(dataHash: String?): List { - if (dataHash == null) { - return emptyList() + /** + * Inserts an attachment with existing data. This is likely an outgoing attachment that we're in the process of sending. + */ + @Throws(MmsException::class) + private fun insertAttachmentWithData(messageId: Long, attachment: Attachment, quote: Boolean): AttachmentId { + requireNotNull(attachment.uri) { "Attachment must have a uri!" } + + Log.d(TAG, "[insertAttachmentWithData] Inserting attachment for messageId $messageId. (MessageId: $messageId, ${attachment.uri})") + + val dataStream = try { + PartAuthority.getAttachmentStream(context, attachment.uri!!) + } catch (e: IOException) { + throw MmsException(e) } - return readableDatabase - .select(*PROJECTION) - .from(TABLE_NAME) - .where("$DATA_HASH = ?", dataHash) - .run() - .readToList { it.readAttachment() } + // To avoid performing long-running operations in a transaction, we write the data to an independent file first in a way that doesn't rely on db state. + val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), dataStream, attachment.transformProperties ?: TransformProperties.empty()) + Log.d(TAG, "[insertAttachmentWithData] Wrote data to file: ${fileWriteResult.file.absolutePath} (MessageId: $messageId, ${attachment.uri})") + + val (attachmentId: AttachmentId, foundDuplicate: Boolean) = writableDatabase.withinTransaction { db -> + val contentValues = ContentValues() + var transformProperties = attachment.transformProperties ?: TransformProperties.empty() + + // First we'll check if our file hash matches the starting or ending hash of any other attachments and has compatible transform properties. + // We'll prefer the match with the most recent upload timestamp. + val hashMatch: DataFileInfo? = readableDatabase + .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) + .from(TABLE_NAME) + .where("$DATA_FILE NOT NULL AND ($DATA_HASH_START = ? OR $DATA_HASH_END = ?)", fileWriteResult.hash, fileWriteResult.hash) + .run() + .readToList { it.readDataFileInfo() } + .sortedByDescending { it.uploadTimestamp } + .firstOrNull { existingMatch -> + areTransformationsCompatible( + newProperties = transformProperties, + potentialMatchProperties = existingMatch.transformProperties, + newHashStart = fileWriteResult.hash, + potentialMatchHashEnd = existingMatch.hashEnd + ) + } + + if (hashMatch != null) { + if (fileWriteResult.hash == hashMatch.hashStart) { + Log.i(TAG, "[insertAttachmentWithData] Found that the new attachment has the same DATA_HASH_START as ${hashMatch.id}. Using all of it's fields. (MessageId: $messageId, ${attachment.uri})") + } else if (fileWriteResult.hash == hashMatch.hashEnd) { + Log.i(TAG, "[insertAttachmentWithData] Found that the new attachment has the same DATA_HASH_END as ${hashMatch.id}. Using all of it's fields. (MessageId: $messageId, ${attachment.uri})") + } else { + throw IllegalStateException("Should not be possible based on query.") + } + + contentValues.put(DATA_FILE, hashMatch.file.absolutePath) + contentValues.put(DATA_SIZE, hashMatch.length) + contentValues.put(DATA_RANDOM, hashMatch.random) + contentValues.put(DATA_HASH_START, fileWriteResult.hash) + contentValues.put(DATA_HASH_END, hashMatch.hashEnd) + + if (hashMatch.transformProperties.skipTransform) { + Log.i(TAG, "[insertAttachmentWithData] The hash match has a DATA_HASH_END and skipTransform=true, so skipping transform of the new file as well. (MessageId: $messageId, ${attachment.uri})") + transformProperties = transformProperties.copy(skipTransform = true) + } + } else { + Log.i(TAG, "[insertAttachmentWithData] No matching hash found. (MessageId: $messageId, ${attachment.uri})") + contentValues.put(DATA_FILE, fileWriteResult.file.absolutePath) + contentValues.put(DATA_SIZE, fileWriteResult.length) + contentValues.put(DATA_RANDOM, fileWriteResult.random) + contentValues.put(DATA_HASH_START, fileWriteResult.hash) + } + + // Our hashMatch already represents a transform-compatible attachment with the most recent upload timestamp. We just need to make sure it has all of the + // other necessary fields, and if so, we can use that to skip the upload. + var uploadTemplate: Attachment? = null + if (hashMatch?.hashEnd != null && System.currentTimeMillis() - hashMatch.uploadTimestamp < AttachmentUploadJob.UPLOAD_REUSE_THRESHOLD) { + uploadTemplate = readableDatabase + .select(*PROJECTION) + .from(TABLE_NAME) + .where("$ID = ${hashMatch.id.id} AND $REMOTE_DIGEST NOT NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_HASH_END NOT NULL") + .run() + .readToSingleObject { it.readAttachment() } + } + + if (uploadTemplate != null) { + Log.i(TAG, "[insertAttachmentWithData] Found a valid template we could use to skip upload. (MessageId: $messageId, ${attachment.uri})") + transformProperties = (uploadTemplate.transformProperties ?: transformProperties).copy(skipTransform = true) + } + + contentValues.put(MESSAGE_ID, messageId) + contentValues.put(CONTENT_TYPE, uploadTemplate?.contentType ?: attachment.contentType) + contentValues.put(TRANSFER_STATE, attachment.transferState) // Even if we have a template, we let AttachmentUploadJob have the final say so it can re-check and make sure the template is still valid + contentValues.put(CDN_NUMBER, uploadTemplate?.cdnNumber ?: attachment.cdnNumber) + contentValues.put(REMOTE_LOCATION, uploadTemplate?.remoteLocation ?: attachment.remoteLocation) + contentValues.put(REMOTE_DIGEST, uploadTemplate?.remoteDigest ?: attachment.remoteDigest) + contentValues.put(REMOTE_INCREMENTAL_DIGEST, uploadTemplate?.incrementalDigest ?: attachment.incrementalDigest) + contentValues.put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, uploadTemplate?.incrementalMacChunkSize ?: attachment.incrementalMacChunkSize) + contentValues.put(REMOTE_KEY, uploadTemplate?.remoteKey ?: attachment.remoteKey) + contentValues.put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) + contentValues.put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) + contentValues.put(VOICE_NOTE, if (attachment.voiceNote) 1 else 0) + contentValues.put(BORDERLESS, if (attachment.borderless) 1 else 0) + contentValues.put(VIDEO_GIF, if (attachment.videoGif) 1 else 0) + contentValues.put(WIDTH, uploadTemplate?.width ?: attachment.width) + contentValues.put(HEIGHT, uploadTemplate?.height ?: attachment.height) + contentValues.put(QUOTE, quote) + contentValues.put(CAPTION, attachment.caption) + contentValues.put(UPLOAD_TIMESTAMP, uploadTemplate?.uploadTimestamp ?: attachment.uploadTimestamp) + contentValues.put(TRANSFORM_PROPERTIES, transformProperties.serialize()) + + if (attachment.transformProperties?.videoEdited == true) { + contentValues.putNull(BLUR_HASH) + } else { + contentValues.put(BLUR_HASH, uploadTemplate.getVisualHashStringOrNull()) + } + + attachment.stickerLocator?.let { sticker -> + contentValues.put(STICKER_PACK_ID, sticker.packId) + contentValues.put(STICKER_PACK_KEY, sticker.packKey) + contentValues.put(STICKER_ID, sticker.stickerId) + contentValues.put(STICKER_EMOJI, sticker.emoji) + } + + val rowId = db.insert(TABLE_NAME, null, contentValues) + + AttachmentId(rowId) to (hashMatch != null) + } + + if (foundDuplicate) { + if (!fileWriteResult.file.delete()) { + Log.w(TAG, "[insertAttachmentWithData] Failed to delete duplicate file: ${fileWriteResult.file.absolutePath}") + } + } + + notifyAttachmentListeners() + return attachmentId } private fun getTransferFile(db: SQLiteDatabase, attachmentId: AttachmentId): File? { @@ -1458,7 +1440,7 @@ class AttachmentTable( transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)), displayOrder = cursor.requireInt(DISPLAY_ORDER), uploadTimestamp = cursor.requireLong(UPLOAD_TIMESTAMP), - dataHash = cursor.requireString(DATA_HASH) + dataHash = cursor.requireString(DATA_HASH_END) ) } @@ -1470,6 +1452,19 @@ class AttachmentTable( return getAttachment(this) } + private fun Cursor.readDataFileInfo(): DataFileInfo { + return DataFileInfo( + id = AttachmentId(this.requireLong(ID)), + file = File(this.requireNonNullString(DATA_FILE)), + length = this.requireLong(DATA_SIZE), + random = this.requireNonNullBlob(DATA_RANDOM), + hashStart = this.requireString(DATA_HASH_START), + hashEnd = this.requireString(DATA_HASH_END), + transformProperties = TransformProperties.parse(this.requireString(TRANSFORM_PROPERTIES)), + uploadTimestamp = this.requireLong(UPLOAD_TIMESTAMP) + ) + } + private fun Cursor.readStickerLocator(): StickerLocator? { return if (this.requireInt(STICKER_ID) >= 0) { StickerLocator( @@ -1486,8 +1481,8 @@ class AttachmentTable( private fun Attachment?.getVisualHashStringOrNull(): String? { return when { this == null -> null - this.blurHash != null -> this.blurHash!!.hash - this.audioHash != null -> this.audioHash!!.hash + this.blurHash != null -> this.blurHash.hash + this.audioHash != null -> this.audioHash.hash else -> null } } @@ -1496,7 +1491,7 @@ class AttachmentTable( return readableDatabase .select(*PROJECTION) .from(TABLE_NAME) - .where("$TRANSFER_STATE == $TRANSFER_PROGRESS_DONE AND $REMOTE_LOCATION IS NOT NULL AND $DATA_HASH IS NOT NULL") + .where("$TRANSFER_STATE == $TRANSFER_PROGRESS_DONE AND $REMOTE_LOCATION IS NOT NULL AND $DATA_HASH_END IS NOT NULL") .orderBy("$ID DESC") .limit(30) .run() @@ -1504,80 +1499,51 @@ class AttachmentTable( .flatten() } - @VisibleForTesting - class DataInfo( + class DataFileWriteResult( val file: File, val length: Long, val random: ByteArray, - val hash: String?, - val transformProperties: TransformProperties? - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false + val hash: String, + val transformProperties: TransformProperties + ) - other as DataInfo - - if (file != other.file) return false - if (length != other.length) return false - if (!random.contentEquals(other.random)) return false - if (hash != other.hash) return false - return transformProperties == other.transformProperties - } - - override fun hashCode(): Int { - var result = file.hashCode() - result = 31 * result + length.hashCode() - result = 31 * result + random.contentHashCode() - result = 31 * result + (hash?.hashCode() ?: 0) - result = 31 * result + (transformProperties?.hashCode() ?: 0) - return result - } - } - - /** - * @param removableWeakReferences Entries in here can be removed from the database. Only possible to be non-empty when [hasStrongReference] is false. - */ - private class DataUsageResult private constructor(val hasStrongReference: Boolean, val removableWeakReferences: List) { - constructor(removableWeakReferences: List) : this(false, removableWeakReferences) - - init { - if (hasStrongReference && removableWeakReferences.isNotEmpty()) { - throw IllegalStateException("There's a strong reference and removable weak references!") - } - } - - companion object { - val IN_USE = DataUsageResult(true, emptyList()) - val NOT_IN_USE = DataUsageResult(false, emptyList()) - } - } + @VisibleForTesting + class DataFileInfo( + val id: AttachmentId, + val file: File, + val length: Long, + val random: ByteArray, + val hashStart: String?, + val hashEnd: String?, + val transformProperties: TransformProperties, + val uploadTimestamp: Long + ) @Parcelize data class TransformProperties( @JsonProperty("skipTransform") @JvmField - val skipTransform: Boolean, + val skipTransform: Boolean = false, @JsonProperty("videoTrim") @JvmField - val videoTrim: Boolean, + val videoTrim: Boolean = false, @JsonProperty("videoTrimStartTimeUs") @JvmField - val videoTrimStartTimeUs: Long, + val videoTrimStartTimeUs: Long = 0, @JsonProperty("videoTrimEndTimeUs") @JvmField - val videoTrimEndTimeUs: Long, + val videoTrimEndTimeUs: Long = 0, @JsonProperty("sentMediaQuality") @JvmField - val sentMediaQuality: Int, + val sentMediaQuality: Int = SentMediaQuality.STANDARD.code, @JsonProperty("mp4Faststart") @JvmField - val mp4FastStart: Boolean + val mp4FastStart: Boolean = false ) : Parcelable { fun shouldSkipTransform(): Boolean { return skipTransform @@ -1589,11 +1555,7 @@ class AttachmentTable( fun withSkipTransform(): TransformProperties { return this.copy( - skipTransform = true, - videoTrim = false, - videoTrimStartTimeUs = 0, - videoTrimEndTimeUs = 0, - mp4FastStart = false + skipTransform = true ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt index 65d73b54d9..600a746024 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt @@ -49,7 +49,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD ${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP}, ${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST}, ${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE}, - ${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_HASH}, + ${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_HASH_END}, ${MessageTable.TABLE_NAME}.${MessageTable.TYPE}, ${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT}, ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED}, @@ -71,13 +71,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD ${MessageTable.VIEW_ONCE} = 0 AND ${MessageTable.STORY_TYPE} = 0 AND ${MessageTable.LATEST_REVISION_ID} IS NULL AND - ( - ${AttachmentTable.QUOTE} = 0 OR - ( - ${AttachmentTable.QUOTE} = 1 AND - ${AttachmentTable.DATA_HASH} IS NULL - ) - ) AND + ${AttachmentTable.QUOTE} = 0 AND ${AttachmentTable.STICKER_PACK_ID} IS NULL AND ${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} > 0 AND $THREAD_RECIPIENT_ID > 0 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index fafd9c7f5f..cdddc48cb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -79,6 +79,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V218_RecipientPniSi import org.thoughtcrime.securesms.database.helpers.migration.V219_PniPreKeyStores import org.thoughtcrime.securesms.database.helpers.migration.V220_PreKeyConstraints import org.thoughtcrime.securesms.database.helpers.migration.V221_AddReadColumnToCallEventsTable +import org.thoughtcrime.securesms.database.helpers.migration.V222_DataHashRefactor /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -160,10 +161,11 @@ object SignalDatabaseMigrations { 218 to V218_RecipientPniSignatureVerified, 219 to V219_PniPreKeyStores, 220 to V220_PreKeyConstraints, - 221 to V221_AddReadColumnToCallEventsTable + 221 to V221_AddReadColumnToCallEventsTable, + 222 to V222_DataHashRefactor ) - const val DATABASE_VERSION = 221 + const val DATABASE_VERSION = 222 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V222_DataHashRefactor.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V222_DataHashRefactor.kt new file mode 100644 index 0000000000..02e39ceec0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V222_DataHashRefactor.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Adds the new data hash columns and indexes. + */ +@Suppress("ClassName") +object V222_DataHashRefactor : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("DROP INDEX attachment_data_hash_index") + db.execSQL("ALTER TABLE attachment DROP COLUMN data_hash") + + db.execSQL("ALTER TABLE attachment ADD COLUMN data_hash_start TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE attachment ADD COLUMN data_hash_end TEXT DEFAULT NULL") + db.execSQL("CREATE INDEX attachment_data_hash_start_index ON attachment (data_hash_start)") + db.execSQL("CREATE INDEX attachment_data_hash_end_index ON attachment (data_hash_end)") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index ffbcfb6be0..1968050b23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -205,7 +205,7 @@ public final class AttachmentCompressionJob extends BaseJob { } else if (constraints.canResize(attachment)) { Log.i(TAG, "Compressing image."); try (MediaStream converted = compressImage(context, attachment, constraints)) { - attachmentDatabase.updateAttachmentData(attachment, converted, false); + attachmentDatabase.updateAttachmentData(attachment, converted); } attachmentDatabase.markAttachmentAsTransformed(attachmentId, false); } else if (constraints.isSatisfied(context, attachment)) { @@ -263,7 +263,7 @@ public final class AttachmentCompressionJob extends BaseJob { Log.i(TAG, "Compressing with streaming muxer"); AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); - File file = AttachmentTable.newFile(context); + File file = AttachmentTable.newDataFile(context); file.deleteOnExit(); boolean faststart = false; @@ -296,7 +296,7 @@ public final class AttachmentCompressionJob extends BaseJob { final long plaintextLength = ModernEncryptingPartOutputStream.getPlaintextLength(file.length()); try (MediaStream mediaStream = new MediaStream(postProcessor.process(plaintextLength), MimeTypes.VIDEO_MP4, 0, 0, true)) { - attachmentDatabase.updateAttachmentData(attachment, mediaStream, true); + attachmentDatabase.updateAttachmentData(attachment, mediaStream); faststart = true; } catch (VideoPostProcessingException e) { Log.w(TAG, "Exception thrown during post processing.", e); @@ -310,7 +310,7 @@ public final class AttachmentCompressionJob extends BaseJob { if (!faststart) { try (MediaStream mediaStream = new MediaStream(ModernDecryptingPartInputStream.createFor(attachmentSecret, file, 0), MimeTypes.VIDEO_MP4, 0, 0, false)) { - attachmentDatabase.updateAttachmentData(attachment, mediaStream, true); + attachmentDatabase.updateAttachmentData(attachment, mediaStream); } } } finally { @@ -339,7 +339,7 @@ public final class AttachmentCompressionJob extends BaseJob { 100, percent)); }, cancelationSignal)) { - attachmentDatabase.updateAttachmentData(attachment, mediaStream, true); + attachmentDatabase.updateAttachmentData(attachment, mediaStream); attachmentDatabase.markAttachmentAsTransformed(attachment.attachmentId, mediaStream.getFaststart()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java index 56d3049ba4..bd4d619e4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.java @@ -168,7 +168,7 @@ public final class AttachmentDownloadJob extends BaseJob { if (attachment.cdnNumber != ReleaseChannel.CDN_NUMBER) { retrieveAttachment(messageId, attachmentId, attachment); } else { - retrieveUrlAttachment(messageId, attachmentId, attachment); + retrieveAttachmentForReleaseChannel(messageId, attachmentId, attachment); } } @@ -216,7 +216,7 @@ public final class AttachmentDownloadJob extends BaseJob { return isCanceled(); } }); - database.insertAttachmentsForPlaceholder(messageId, attachmentId, stream); + database.finalizeAttachmentAfterDownload(messageId, attachmentId, stream); } catch (RangeException e) { Log.w(TAG, "Range exception, file size " + attachmentFile.length(), e); if (attachmentFile.delete()) { @@ -278,9 +278,9 @@ public final class AttachmentDownloadJob extends BaseJob { } } - private void retrieveUrlAttachment(long messageId, - final AttachmentId attachmentId, - final Attachment attachment) + private void retrieveAttachmentForReleaseChannel(long messageId, + final AttachmentId attachmentId, + final Attachment attachment) throws IOException { try (Response response = S3.getObject(Objects.requireNonNull(attachment.fileName))) { @@ -289,7 +289,7 @@ public final class AttachmentDownloadJob extends BaseJob { if (body.contentLength() > FeatureFlags.maxAttachmentReceiveSizeBytes()) { throw new MmsException("Attachment too large, failing download"); } - SignalDatabase.attachments().insertAttachmentsForPlaceholder(messageId, attachmentId, Okio.buffer(body.source()).inputStream()); + SignalDatabase.attachments().finalizeAttachmentAfterDownload(messageId, attachmentId, Okio.buffer(body.source()).inputStream()); } } catch (MmsException e) { Log.w(TAG, "Experienced exception while trying to download an attachment.", e); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt index c2d5ad5dff..e8af9f7247 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt @@ -42,6 +42,7 @@ import java.io.IOException import java.util.Objects import java.util.Optional import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds /** @@ -60,7 +61,7 @@ class AttachmentUploadJob private constructor( private val TAG = Log.tag(AttachmentUploadJob::class.java) - private val UPLOAD_REUSE_THRESHOLD = TimeUnit.DAYS.toMillis(3) + val UPLOAD_REUSE_THRESHOLD = 3.days.inWholeMilliseconds /** * Foreground notification shows while uploading attachments above this. @@ -162,7 +163,7 @@ class AttachmentUploadJob private constructor( buildAttachmentStream(databaseAttachment, notification, uploadSpec!!).use { localAttachment -> val remoteAttachment = messageSender.uploadAttachment(localAttachment) val attachment = PointerAttachment.forPointer(Optional.of(remoteAttachment), null, databaseAttachment.fastPreflightId).get() - SignalDatabase.attachments.updateAttachmentAfterUpload(databaseAttachment.attachmentId, attachment, remoteAttachment.uploadTimestamp) + SignalDatabase.attachments.finalizeAttachmentAfterUpload(databaseAttachment.attachmentId, attachment, remoteAttachment.uploadTimestamp) } } } catch (e: NonSuccessfulResumableUploadResponseCodeException) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LegacyAttachmentUploadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LegacyAttachmentUploadJob.java index 721f5e6a92..4e0a1ec2ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LegacyAttachmentUploadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LegacyAttachmentUploadJob.java @@ -139,7 +139,7 @@ public final class LegacyAttachmentUploadJob extends BaseJob { SignalServiceAttachmentPointer remoteAttachment = messageSender.uploadAttachment(localAttachment); Attachment attachment = PointerAttachment.forPointer(Optional.of(remoteAttachment), null, databaseAttachment.fastPreflightId).get(); - database.updateAttachmentAfterUpload(databaseAttachment.attachmentId, attachment, remoteAttachment.getUploadTimestamp()); + database.finalizeAttachmentAfterUpload(databaseAttachment.attachmentId, attachment, remoteAttachment.getUploadTimestamp()); } } catch (NonSuccessfulResumableUploadResponseCodeException e) { if (e.getCode() == 400) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java index 4dbef34281..47d1e7aa6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerSearchRepository.java @@ -64,11 +64,6 @@ public final class StickerSearchRepository { return out; } - public @NonNull Single getStickerFeatureAvailability() { - return Single.fromCallable(this::getStickerFeatureAvailabilitySync) - .observeOn(Schedulers.io()); - } - public void getStickerFeatureAvailability(@NonNull Callback callback) { SignalExecutors.BOUNDED.execute(() -> { callback.onResult(getStickerFeatureAvailabilitySync()); diff --git a/core-util-jvm/src/main/java/org/signal/core/util/InputStreamExtensions.kt b/core-util-jvm/src/main/java/org/signal/core/util/InputStreamExtensions.kt index 5a6880c835..c0dbdd2ef6 100644 --- a/core-util-jvm/src/main/java/org/signal/core/util/InputStreamExtensions.kt +++ b/core-util-jvm/src/main/java/org/signal/core/util/InputStreamExtensions.kt @@ -7,7 +7,6 @@ package org.signal.core.util import java.io.IOException import java.io.InputStream -import kotlin.jvm.Throws /** * Reads a 32-bit variable-length integer from the stream. @@ -80,3 +79,11 @@ fun InputStream.readLength(): Long { return count } + +/** + * Reads the contents of the stream and discards them. + */ +@Throws(IOException::class) +fun InputStream.drain() { + this.readLength() +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java index 1aea808a3a..d1399f6af9 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java @@ -142,7 +142,8 @@ public class SignalServiceMessageReceiver { } /** - * Retrieves a SignalServiceAttachment. + * Retrieves a SignalServiceAttachment. The encrypted data is written to @{code destination}, and then an {@link InputStream} is returned that decrypts the + * contents of the destination file, giving you access to the plaintext content. * * @param pointer The {@link SignalServiceAttachmentPointer} * received in a {@link SignalServiceDataMessage}.