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 87d6d79aa0..1e6342e462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -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. 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 448852b524..54fedf23f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt @@ -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}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckRestoreMediaLeftJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckRestoreMediaLeftJob.kt index 6ec5ff4cea..6be74760a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckRestoreMediaLeftJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckRestoreMediaLeftJob.kt @@ -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") diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index c74b1e597b..67dfa82b4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/QuoteThumbnailBackfillJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/QuoteThumbnailBackfillJob.kt index 5671c39bce..440789037a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/QuoteThumbnailBackfillJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/QuoteThumbnailBackfillJob.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/QuoteThumbnailReconstructionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/QuoteThumbnailReconstructionJob.kt new file mode 100644 index 0000000000..47fd5ea75f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/QuoteThumbnailReconstructionJob.kt @@ -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 { + override fun create(params: Parameters, data: ByteArray?): QuoteThumbnailReconstructionJob { + return QuoteThumbnailReconstructionJob(params) + } + } +}