Add quote reconstruction job for free-tier restores.

This commit is contained in:
Greyson Parrelli
2025-09-02 11:44:50 -04:00
parent 06b85cc3cb
commit a7ac138ea3
6 changed files with 225 additions and 1 deletions

View File

@@ -307,8 +307,13 @@ class AttachmentTable(
private const val QUOTE_THUMBNAIL_DIMEN = 200
private const val QUOTE_THUMBAIL_QUALITY = 50
/** Indicates a legacy quote is pending transcoding to a new quote thumbnail. */
const val QUOTE_PENDING_TRANSCODE = 2
/** Indicates a quote from a free-tier backup restore is pending potential reconstruction from a parent attachment. */
const val QUOTE_PENDING_RECONSTRUCTION = 3
@JvmStatic
@Throws(IOException::class)
fun newDataFile(context: Context): File {
@@ -3163,6 +3168,96 @@ class AttachmentTable(
)
}
/**
* After restoring from the free-tier, it's possible we'll be missing many of our quoted replies.
* This marks quotes with a special flag to indicate that they'd be eligible for reconstruction.
* See [QUOTE_PENDING_RECONSTRUCTION].
*/
fun markQuotesThatNeedReconstruction() {
writableDatabase
.update(TABLE_NAME)
.values(QUOTE to QUOTE_PENDING_RECONSTRUCTION)
.where("$QUOTE != 0 AND $DATA_FILE IS NULL AND $REMOTE_LOCATION IS NULL")
.run()
}
/**
* Retrieves data for the newest quote that is pending reconstruction (see [QUOTE_PENDING_RECONSTRUCTION]), if any.
*/
fun getNewestQuotePendingReconstruction(): DatabaseAttachment? {
return readableDatabase
.select(*PROJECTION)
.from(TABLE_NAME)
.where("$QUOTE = $QUOTE_PENDING_RECONSTRUCTION")
.orderBy("$ID DESC")
.limit(1)
.run()
.readToSingleObject { it.readAttachment() }
}
/**
* After reconstructing a thumbnail, this method can be used to write the data to the quote.
* It'll handle duplicates as well as clearing the [QUOTE_PENDING_RECONSTRUCTION] flag.
*/
@Throws(MmsException::class)
fun applyReconstructedQuoteData(attachmentId: AttachmentId, thumbnail: ImageCompressionUtil.Result) {
val newDataFileInfo = writeToDataFile(newDataFile(context), thumbnail.data.inputStream(), TransformProperties.empty())
val foundDuplicate = writableDatabase.withinTransaction { db ->
val existingMatch: DataFileInfo? = db
.select(*DATA_FILE_INFO_PROJECTION)
.from(TABLE_NAME)
.where("$DATA_HASH_END = ?", newDataFileInfo.hash)
.run()
.readToSingleObject { it.readDataFileInfo() }
db.update(TABLE_NAME)
.values(
DATA_FILE to (existingMatch?.file?.absolutePath ?: newDataFileInfo.file.absolutePath),
DATA_SIZE to (existingMatch?.length ?: newDataFileInfo.length),
DATA_RANDOM to (existingMatch?.random ?: newDataFileInfo.random),
DATA_HASH_START to (existingMatch?.hashStart ?: newDataFileInfo.hash),
DATA_HASH_END to (existingMatch?.hashEnd ?: newDataFileInfo.hash),
CONTENT_TYPE to thumbnail.mimeType,
WIDTH to thumbnail.width,
HEIGHT to thumbnail.height,
QUOTE to 1
)
.where("$ID = ?", attachmentId)
.run()
existingMatch != null
}
if (foundDuplicate) {
if (!newDataFileInfo.file.delete()) {
Log.w(TAG, "[applyReconstructedQuoteData] Failed to delete a duplicated file!")
}
}
}
/**
* Clears the [QUOTE_PENDING_RECONSTRUCTION] status of an attachment. Used for when an error occurs and you can't call [applyReconstructedQuoteData].
*/
fun clearQuotePendingReconstruction(attachmentId: AttachmentId) {
writableDatabase
.update(TABLE_NAME)
.values(QUOTE to 1)
.where("$ID = ?", attachmentId)
.run()
}
/**
* Clears all [QUOTE_PENDING_RECONSTRUCTION] flags on attachments.
*/
fun clearAllQuotesPendingReconstruction() {
writableDatabase
.update(TABLE_NAME)
.values(QUOTE to 1)
.where("$QUOTE = $QUOTE_PENDING_RECONSTRUCTION")
.run()
}
/**
* Used in an app migration that creates quote thumbnails. Updates all quote attachments that share the same
* [previousDataFile] to use the new thumbnail.

View File

@@ -39,6 +39,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
${AttachmentTable.TABLE_NAME}.${AttachmentTable.WIDTH},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.HEIGHT},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE_TARGET_CONTENT_TYPE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_KEY},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_ID},

View File

@@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.BackupMediaRestoreService
@@ -55,6 +56,11 @@ class CheckRestoreMediaLeftJob private constructor(parameters: Parameters) : Job
if (SignalStore.backup.deletionState == DeletionState.AWAITING_MEDIA_DOWNLOAD) {
SignalStore.backup.deletionState = DeletionState.MEDIA_DOWNLOAD_FINISHED
}
if (!SignalStore.backup.backsUpMedia) {
SignalDatabase.attachments.markQuotesThatNeedReconstruction()
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
}
}
} else if (runAttempt == 0) {
Log.w(TAG, "Still have remaining data to restore, will retry before checking job queues, queue: ${parameters.queue} estimated remaining: $remainingAttachmentSize")

View File

@@ -235,6 +235,7 @@ public final class JobManagerFactories {
put(PushProcessMessageErrorJob.KEY, new PushProcessMessageErrorJob.Factory());
put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory());
put(QuoteThumbnailBackfillJob.KEY, new QuoteThumbnailBackfillJob.Factory());
put(QuoteThumbnailReconstructionJob.KEY, new QuoteThumbnailReconstructionJob.Factory());
put(ReactionSendJob.KEY, new ReactionSendJob.Factory());
put(RebuildMessageSearchIndexJob.KEY, new RebuildMessageSearchIndexJob.Factory());
put(ReclaimUsernameAndLinkJob.KEY, new ReclaimUsernameAndLinkJob.Factory());

View File

@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority
/**
* This job processes quote attachments to generate thumbnails where possible.
* In order to avoid hammering the device, this job will process a single attachment
* In order to avoid hammering the device, this job will process a few attachments
* and then reschedule itself to run again if necessary.
*/
class QuoteThumbnailBackfillJob private constructor(parameters: Parameters) : Job(parameters) {

View File

@@ -0,0 +1,121 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.mms.DecryptableUri
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.getQuote
import kotlin.time.Duration.Companion.milliseconds
/**
* A job that should be enqueued after a free-tier backup restore completes.
* Before enqueueing this job, be sure to call [AttachmentTable.markQuotesThatNeedReconstruction].
*/
class QuoteThumbnailReconstructionJob private constructor(params: Parameters) : Job(params) {
companion object {
private val TAG = Log.tag(QuoteThumbnailReconstructionJob::class)
const val KEY = "QuoteThumbnailReconstructionJob"
}
private var activeQuoteAttachment: DatabaseAttachment? = null
constructor() : this(
Parameters.Builder()
.setLifespan(Parameters.IMMORTAL)
.setMaxInstancesForFactory(2)
.build()
)
override fun serialize(): ByteArray? = null
override fun getFactoryKey() = KEY
override fun run(): Result {
val quoteAttachment = SignalDatabase.attachments.getNewestQuotePendingReconstruction()
if (quoteAttachment == null) {
Log.i(TAG, "No remaining quotes to reconstruct. Done!")
return Result.success()
}
activeQuoteAttachment = quoteAttachment
val message = SignalDatabase.messages.getMessageRecordOrNull(quoteAttachment.mmsId)
if (message == null) {
Log.w(TAG, "Failed to find message for quote attachment. Possible race condition where it was just deleted. Marking as migrated and continuing.")
SignalDatabase.attachments.clearQuotePendingReconstruction(quoteAttachment.attachmentId)
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
return Result.success()
}
if (message.getQuote() == null) {
Log.w(TAG, "The target message has no quote data. Marking as migrated and continuing.")
SignalDatabase.attachments.clearQuotePendingReconstruction(quoteAttachment.attachmentId)
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
return Result.success()
}
val messageAge = System.currentTimeMillis().milliseconds - message.dateReceived.milliseconds
if (messageAge > RemoteConfig.messageQueueTime.milliseconds) {
Log.w(TAG, "Target message is older than the message queue time. Clearing remaining pending quotes and ending the reconstruction process.")
SignalDatabase.attachments.clearAllQuotesPendingReconstruction()
return Result.success()
}
val targetMessage = SignalDatabase.messages.getMessageFor(message.getQuote()!!.id, message.getQuote()!!.author)
if (targetMessage == null) {
Log.w(TAG, "Failed to find the target message of the quote. Marking as migrated and continuing.")
SignalDatabase.attachments.clearQuotePendingReconstruction(quoteAttachment.attachmentId)
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
return Result.success()
}
val targetAttachment = SignalDatabase.attachments.getAttachmentsForMessage(targetMessage.id).firstOrNull { MediaUtil.isImageOrVideoType(it.contentType) && it.uri != null }
if (targetAttachment == null) {
Log.w(TAG, "No applicable attachments found for the target message. Marking as migrated and continuing.")
SignalDatabase.attachments.clearQuotePendingReconstruction(quoteAttachment.attachmentId)
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
return Result.success()
}
val thumbnailData = SignalDatabase.attachments.generateQuoteThumbnail(DecryptableUri(targetAttachment.uri!!), targetAttachment.contentType, quiet = true)
if (thumbnailData == null) {
Log.w(TAG, "Failed to generate a thumbnail for the attachment. Marking as migrated and continuing.")
SignalDatabase.attachments.clearQuotePendingReconstruction(quoteAttachment.attachmentId)
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
return Result.success()
}
SignalDatabase.attachments.applyReconstructedQuoteData(quoteAttachment.attachmentId, thumbnailData)
Log.d(TAG, "Successfully reconstructed quote attachment for ${quoteAttachment.attachmentId}")
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
return Result.success()
}
override fun onFailure() {
activeQuoteAttachment?.let { attachment ->
Log.w(TAG, "Failed during reconstruction. Marking as migrated and continuing.", true)
SignalDatabase.attachments.clearQuotePendingReconstruction(attachment.attachmentId)
} ?: Log.w(TAG, "Job failed, but no active file is set!")
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
}
class Factory : Job.Factory<QuoteThumbnailReconstructionJob> {
override fun create(params: Parameters, data: ByteArray?): QuoteThumbnailReconstructionJob {
return QuoteThumbnailReconstructionJob(params)
}
}
}