Ensure we are restoring media per spec with full media and thumbnail rules.

This commit is contained in:
Alex Hart
2025-07-14 15:56:41 -03:00
committed by Jeffrey Starke
parent 1137bbd8a5
commit 049e9460a0
4 changed files with 166 additions and 7 deletions

View File

@@ -6,24 +6,30 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest import androidx.test.filters.FlakyTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo import assertk.assertions.isEqualTo
import assertk.assertions.isNotEqualTo import assertk.assertions.isNotEqualTo
import assertk.assertions.isTrue
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
import org.junit.Before import org.junit.Before
import org.junit.Ignore import org.junit.Ignore
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.signal.core.util.Base64.decodeBase64OrThrow import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.core.util.copyTo import org.signal.core.util.copyTo
import org.signal.core.util.stream.NullOutputStream import org.signal.core.util.stream.NullOutputStream
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.PointerAttachment import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.UriAttachment import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.MediaStream import org.thoughtcrime.securesms.mms.MediaStream
import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
import org.whispersystems.signalservice.api.crypto.NoCipherOutputStream import org.whispersystems.signalservice.api.crypto.NoCipherOutputStream
@@ -32,10 +38,19 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.util.Optional import java.util.Optional
import java.util.UUID
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class AttachmentTableTest { class AttachmentTableTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@Before @Before
fun setUp() { fun setUp() {
SignalDatabase.attachments.deleteAllAttachments() SignalDatabase.attachments.deleteAllAttachments()
@@ -195,6 +210,57 @@ class AttachmentTableTest {
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE) assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
} }
@Test
fun given10NewerAnd10OlderAttachments_whenIGetEachBatch_thenIExpectProperBucketing() {
val now = System.currentTimeMillis().milliseconds
val attachments = (0 until 20).map {
createArchivedAttachment()
}
val newMessages = attachments.take(10).mapIndexed { index, attachment ->
createIncomingMessage(serverTime = now - index.seconds, attachment = attachment)
}
val twoMonthsAgo = now - 60.days
val oldMessages = attachments.drop(10).mapIndexed { index, attachment ->
createIncomingMessage(serverTime = twoMonthsAgo - index.seconds, attachment = attachment)
}
(newMessages + oldMessages).forEach {
SignalDatabase.messages.insertMessageInbox(it)
}
val firstAttachmentsToDownload = SignalDatabase.attachments.getLast30DaysOfRestorableAttachments(500)
val nextAttachmentsToDownload = SignalDatabase.attachments.getOlderRestorableAttachments(500)
assertThat(firstAttachmentsToDownload).hasSize(10)
val resultNewMessages = SignalDatabase.messages.getMessages(firstAttachmentsToDownload.map { it.mmsId })
resultNewMessages.forEach {
assertThat(it.serverTimestamp.milliseconds >= now - 30.days).isTrue()
}
assertThat(nextAttachmentsToDownload).hasSize(10)
val resultOldMessages = SignalDatabase.messages.getMessages(nextAttachmentsToDownload.map { it.mmsId })
resultOldMessages.forEach {
assertThat(it.serverTimestamp.milliseconds < now - 30.days).isTrue()
}
}
private fun createIncomingMessage(
serverTime: Duration,
attachment: Attachment
): IncomingMessage {
return IncomingMessage(
type = MessageType.NORMAL,
from = harness.others[0],
body = null,
sentTimeMillis = serverTime.inWholeMilliseconds,
serverTimeMillis = serverTime.inWholeMilliseconds,
receivedTimeMillis = serverTime.inWholeMilliseconds,
attachments = listOf(attachment)
)
}
private fun createAttachmentPointer(key: ByteArray, digest: ByteArray, size: Int): Attachment { private fun createAttachmentPointer(key: ByteArray, digest: ByteArray, size: Int): Attachment {
return PointerAttachment.forPointer( return PointerAttachment.forPointer(
pointer = Optional.of( pointer = Optional.of(
@@ -223,6 +289,32 @@ class AttachmentTableTest {
).get() ).get()
} }
private fun createArchivedAttachment(): Attachment {
return ArchivedAttachment(
contentType = "image/jpeg",
size = 1024,
cdn = 3,
uploadTimestamp = 0,
key = Random.nextBytes(8),
cdnKey = "password",
archiveCdn = 3,
plaintextHash = Random.nextBytes(8),
incrementalMac = Random.nextBytes(8),
incrementalMacChunkSize = 8,
width = 100,
height = 100,
caption = null,
blurHash = null,
voiceNote = false,
borderless = false,
stickerLocator = null,
gif = false,
quote = false,
uuid = UUID.randomUUID(),
fileName = null
)
}
private fun createAttachment(id: Long, uri: Uri, transformProperties: AttachmentTable.TransformProperties): UriAttachment { private fun createAttachment(id: Long, uri: Uri, transformProperties: AttachmentTable.TransformProperties): UriAttachment {
return UriAttachmentBuilder.build( return UriAttachmentBuilder.build(
id, id,

View File

@@ -119,6 +119,7 @@ import java.util.Optional
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
class AttachmentTable( class AttachmentTable(
context: Context, context: Context,
@@ -510,6 +511,54 @@ class AttachmentTable(
} }
} }
/**
* Grabs the last 30 days worth of restorable attachments with respect to the message's server timestamp,
* up to the given batch size.
*/
fun getLast30DaysOfRestorableAttachments(batchSize: Int): List<RestorableAttachment> {
val thirtyDaysAgo = System.currentTimeMillis().milliseconds - 30.days
return readableDatabase
.select("$TABLE_NAME.$ID", MESSAGE_ID, DATA_SIZE, DATA_HASH_END, REMOTE_KEY)
.from("$TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $TABLE_NAME.$MESSAGE_ID")
.where("$TRANSFER_STATE = ? AND ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} >= ?", TRANSFER_NEEDS_RESTORE, thirtyDaysAgo.inWholeMilliseconds)
.limit(batchSize)
.orderBy("$TABLE_NAME.$ID DESC")
.run()
.readToList {
RestorableAttachment(
attachmentId = AttachmentId(it.requireLong(ID)),
mmsId = it.requireLong(MESSAGE_ID),
size = it.requireLong(DATA_SIZE),
plaintextHash = it.requireBlob(DATA_HASH_END),
remoteKey = it.requireBlob(REMOTE_KEY)
)
}
}
/**
* Grabs attachments outside of the last 30 days with respect to the message's server timestamp,
* up to the given batch size.
*/
fun getOlderRestorableAttachments(batchSize: Int): List<RestorableAttachment> {
val thirtyDaysAgo = System.currentTimeMillis().milliseconds - 30.days
return readableDatabase
.select("$TABLE_NAME.$ID", MESSAGE_ID, DATA_SIZE, DATA_HASH_END, REMOTE_KEY)
.from("$TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $TABLE_NAME.$MESSAGE_ID")
.where("$TRANSFER_STATE = ? AND ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} < ?", TRANSFER_NEEDS_RESTORE, thirtyDaysAgo.inWholeMilliseconds)
.limit(batchSize)
.orderBy("$TABLE_NAME.$ID DESC")
.run()
.readToList {
RestorableAttachment(
attachmentId = AttachmentId(it.requireLong(ID)),
mmsId = it.requireLong(MESSAGE_ID),
size = it.requireLong(DATA_SIZE),
plaintextHash = it.requireBlob(DATA_HASH_END),
remoteKey = it.requireBlob(REMOTE_KEY)
)
}
}
fun getRestorableAttachments(batchSize: Int): List<RestorableAttachment> { fun getRestorableAttachments(batchSize: Int): List<RestorableAttachment> {
return readableDatabase return readableDatabase
.select(ID, MESSAGE_ID, DATA_SIZE, DATA_HASH_END, REMOTE_KEY) .select(ID, MESSAGE_ID, DATA_SIZE, DATA_HASH_END, REMOTE_KEY)

View File

@@ -61,7 +61,16 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo
val restoreThumbnailOnlyAttachmentsIds: MutableList<AttachmentId> = mutableListOf() val restoreThumbnailOnlyAttachmentsIds: MutableList<AttachmentId> = mutableListOf()
val notRestorable: MutableList<AttachmentId> = mutableListOf() val notRestorable: MutableList<AttachmentId> = mutableListOf()
val attachmentBatch = SignalDatabase.attachments.getRestorableAttachments(batchSize) val last30DaysAttachments = SignalDatabase.attachments.getLast30DaysOfRestorableAttachments(batchSize)
val remainingSize = batchSize - last30DaysAttachments.size
val remaining = if (remainingSize > 0) {
SignalDatabase.attachments.getOlderRestorableAttachments(batchSize = remainingSize)
} else {
listOf()
}
val attachmentBatch = last30DaysAttachments + remaining
val messageIds = attachmentBatch.map { it.mmsId }.toSet() val messageIds = attachmentBatch.map { it.mmsId }.toSet()
val messageMap = SignalDatabase.messages.getMessages(messageIds).associate { it.id to (it as MmsMessageRecord) } val messageMap = SignalDatabase.messages.getMessages(messageIds).associate { it.id to (it as MmsMessageRecord) }
@@ -75,18 +84,18 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo
continue continue
} }
restoreThumbnailJobs += RestoreAttachmentThumbnailJob(
messageId = attachment.mmsId,
attachmentId = attachment.attachmentId,
highPriority = false
)
if (isWallpaper || shouldRestoreFullSize(message!!, restoreTime, SignalStore.backup.optimizeStorage)) { if (isWallpaper || shouldRestoreFullSize(message!!, restoreTime, SignalStore.backup.optimizeStorage)) {
restoreFullAttachmentJobs += RestoreAttachmentJob.forInitialRestore( restoreFullAttachmentJobs += RestoreAttachmentJob.forInitialRestore(
messageId = attachment.mmsId, messageId = attachment.mmsId,
attachmentId = attachment.attachmentId attachmentId = attachment.attachmentId
) )
} else { } else {
restoreThumbnailJobs += RestoreAttachmentThumbnailJob(
messageId = attachment.mmsId,
attachmentId = attachment.attachmentId,
highPriority = false
)
restoreThumbnailOnlyAttachmentsIds += attachment.attachmentId restoreThumbnailOnlyAttachmentsIds += attachment.attachmentId
} }
} }

View File

@@ -203,6 +203,15 @@ class RestoreAttachmentJob private constructor(
Log.w(TAG, format(this, "onFailure() messageId: $messageId attachmentId: $attachmentId")) Log.w(TAG, format(this, "onFailure() messageId: $messageId attachmentId: $attachmentId"))
markFailed(attachmentId) markFailed(attachmentId)
Log.w(TAG, "onFailure(): Attempting to fall back on attachment thumbnail.")
val restoreThumbnailAttachmentJob = RestoreAttachmentThumbnailJob(
messageId = messageId,
attachmentId = attachmentId,
highPriority = manual
)
AppDependencies.jobManager.add(restoreThumbnailAttachmentJob)
} }
} }