mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-25 05:27:42 +00:00
Add quote reconstruction job for free-tier restores.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user