Add a migration to generate thumbnails for existing quotes.

This commit is contained in:
Greyson Parrelli
2025-08-28 11:57:45 -04:00
parent c29d77d4a5
commit 631b51baf2
10 changed files with 328 additions and 23 deletions

View File

@@ -69,7 +69,7 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher {
try { try {
if (PartAuthority.isBlobUri(uri) && BlobProvider.isSingleUseMemoryBlob(uri)) { if (PartAuthority.isBlobUri(uri) && BlobProvider.isSingleUseMemoryBlob(uri)) {
return PartAuthority.getAttachmentThumbnailStream(context, uri); return PartAuthority.getAttachmentThumbnailStream(context, uri);
} else if (isSafeSize(PartAuthority.getAttachmentThumbnailStream(context, uri))) { } else if (isSafeSize(context, uri)) {
return PartAuthority.getAttachmentThumbnailStream(context, uri); return PartAuthority.getAttachmentThumbnailStream(context, uri);
} else { } else {
throw new IOException("File dimensions are too large!"); throw new IOException("File dimensions are too large!");
@@ -80,13 +80,15 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher {
} }
} }
private boolean isSafeSize(InputStream stream) { private boolean isSafeSize(Context context, Uri uri) throws IOException {
try { try {
InputStream stream = PartAuthority.getAttachmentThumbnailStream(context, uri);
Pair<Integer, Integer> dimensions = BitmapUtil.getDimensions(stream); Pair<Integer, Integer> dimensions = BitmapUtil.getDimensions(stream);
long totalPixels = (long) dimensions.first * dimensions.second; long totalPixels = (long) dimensions.first * dimensions.second;
return totalPixels < TOTAL_PIXEL_SIZE_LIMIT; return totalPixels < TOTAL_PIXEL_SIZE_LIMIT;
} catch (BitmapDecodingException e) { } catch (BitmapDecodingException e) {
return false; Long size = PartAuthority.getAttachmentSize(context, uri);
return size != null && size < GlideStreamConfig.getMarkReadLimitBytes();
} }
} }
} }

View File

@@ -9,9 +9,11 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.DeleteAbandonedAttachmentsJob; import org.thoughtcrime.securesms.jobs.DeleteAbandonedAttachmentsJob;
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob; import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
import org.thoughtcrime.securesms.jobs.QuoteThumbnailBackfillJob;
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations; import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.migrations.QuoteThumbnailBackfillMigrationJob;
import org.thoughtcrime.securesms.stickers.BlessedPacks; import org.thoughtcrime.securesms.stickers.BlessedPacks;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
@@ -62,6 +64,12 @@ public final class AppInitialization {
AppDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey())); AppDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_FACES.getPackId(), BlessedPacks.SWOON_FACES.getPackKey()));
EmojiSearchIndexDownloadJob.scheduleImmediately(); EmojiSearchIndexDownloadJob.scheduleImmediately();
DeleteAbandonedAttachmentsJob.enqueue(); DeleteAbandonedAttachmentsJob.enqueue();
if (SignalStore.misc().startedQuoteThumbnailMigration()) {
AppDependencies.getJobManager().add(new QuoteThumbnailBackfillJob());
} else {
AppDependencies.getJobManager().add(new QuoteThumbnailBackfillMigrationJob());
}
} }
/** /**

View File

@@ -460,7 +460,7 @@ class ChatItemArchiveExporter(
val attachmentsFuture = executor.submitTyped { val attachmentsFuture = executor.submitTyped {
extraDataTimer.timeEvent("attachments") { extraDataTimer.timeEvent("attachments") {
db.attachmentTable.getAttachmentsForMessages(messageIds) db.attachmentTable.getAttachmentsForMessages(messageIds, excludeTranscodingQuotes = true)
} }
} }

View File

@@ -302,8 +302,9 @@ class AttachmentTable(
ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP, ARCHIVE_CDN, ARCHIVE_TRANSFER_STATE, THUMBNAIL_FILE, THUMBNAIL_RESTORE_STATE, THUMBNAIL_RANDOM ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP, ARCHIVE_CDN, ARCHIVE_TRANSFER_STATE, THUMBNAIL_FILE, THUMBNAIL_RESTORE_STATE, THUMBNAIL_RANDOM
) )
private const val QUOTE_THUMBNAIL_DIMEN = 150 private const val QUOTE_THUMBNAIL_DIMEN = 200
private const val QUOTE_IMAGE_QUALITY = 80 private const val QUOTE_THUMBAIL_QUALITY = 50
const val QUOTE_PENDING_TRANSCODE = 2
@JvmStatic @JvmStatic
@Throws(IOException::class) @Throws(IOException::class)
@@ -453,17 +454,23 @@ class AttachmentTable(
.flatten() .flatten()
} }
fun getAttachmentsForMessages(mmsIds: Collection<Long?>): Map<Long, List<DatabaseAttachment>> { @JvmOverloads
fun getAttachmentsForMessages(mmsIds: Collection<Long?>, excludeTranscodingQuotes: Boolean = false): Map<Long, List<DatabaseAttachment>> {
if (mmsIds.isEmpty()) { if (mmsIds.isEmpty()) {
return emptyMap() return emptyMap()
} }
val query = SqlUtil.buildFastCollectionQuery(MESSAGE_ID, mmsIds) val query = SqlUtil.buildFastCollectionQuery(MESSAGE_ID, mmsIds)
val where = if (excludeTranscodingQuotes) {
"(${query.where}) AND $QUOTE != $QUOTE_PENDING_TRANSCODE"
} else {
query.where
}
return readableDatabase return readableDatabase
.select(*PROJECTION) .select(*PROJECTION)
.from(TABLE_NAME) .from(TABLE_NAME)
.where(query.where, query.whereArgs) .where(where, query.whereArgs)
.orderBy("$ID ASC") .orderBy("$ID ASC")
.run() .run()
.groupBy { cursor -> .groupBy { cursor ->
@@ -2027,12 +2034,12 @@ class AttachmentTable(
incrementalDigest = null, incrementalDigest = null,
incrementalMacChunkSize = 0, incrementalMacChunkSize = 0,
fastPreflightId = jsonObject.getString(FAST_PREFLIGHT_ID), fastPreflightId = jsonObject.getString(FAST_PREFLIGHT_ID),
voiceNote = jsonObject.getInt(VOICE_NOTE) == 1, voiceNote = jsonObject.getInt(VOICE_NOTE) != 0,
borderless = jsonObject.getInt(BORDERLESS) == 1, borderless = jsonObject.getInt(BORDERLESS) != 0,
videoGif = jsonObject.getInt(VIDEO_GIF) == 1, videoGif = jsonObject.getInt(VIDEO_GIF) != 0,
width = jsonObject.getInt(WIDTH), width = jsonObject.getInt(WIDTH),
height = jsonObject.getInt(HEIGHT), height = jsonObject.getInt(HEIGHT),
quote = jsonObject.getInt(QUOTE) == 1, quote = jsonObject.getInt(QUOTE) != 0,
caption = jsonObject.getString(CAPTION), caption = jsonObject.getString(CAPTION),
stickerLocator = if (jsonObject.getInt(STICKER_ID) >= 0) { stickerLocator = if (jsonObject.getInt(STICKER_ID) >= 0) {
StickerLocator( StickerLocator(
@@ -2386,7 +2393,7 @@ class AttachmentTable(
put(VIDEO_GIF, attachment.videoGif.toInt()) put(VIDEO_GIF, attachment.videoGif.toInt())
put(WIDTH, attachment.width) put(WIDTH, attachment.width)
put(HEIGHT, attachment.height) put(HEIGHT, attachment.height)
put(QUOTE, quote) put(QUOTE, quote.toInt())
put(CAPTION, attachment.caption) put(CAPTION, attachment.caption)
put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp) put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp)
put(BLUR_HASH, attachment.blurHash?.hash) put(BLUR_HASH, attachment.blurHash?.hash)
@@ -2467,7 +2474,7 @@ class AttachmentTable(
return attachmentId return attachmentId
} }
private fun generateQuoteThumbnail(uri: DecryptableUri, contentType: String?): ImageCompressionUtil.Result? { fun generateQuoteThumbnail(uri: DecryptableUri, contentType: String?, quiet: Boolean = false): ImageCompressionUtil.Result? {
return try { return try {
when { when {
MediaUtil.isImageType(contentType) -> { MediaUtil.isImageType(contentType) -> {
@@ -2480,7 +2487,8 @@ class AttachmentTable(
outputFormat, outputFormat,
uri, uri,
QUOTE_THUMBNAIL_DIMEN, QUOTE_THUMBNAIL_DIMEN,
QUOTE_IMAGE_QUALITY QUOTE_THUMBAIL_QUALITY,
true
) )
} }
MediaUtil.isVideoType(contentType) -> { MediaUtil.isVideoType(contentType) -> {
@@ -2492,7 +2500,7 @@ class AttachmentTable(
MediaUtil.IMAGE_JPEG, MediaUtil.IMAGE_JPEG,
uri, uri,
QUOTE_THUMBNAIL_DIMEN, QUOTE_THUMBNAIL_DIMEN,
QUOTE_IMAGE_QUALITY QUOTE_THUMBAIL_QUALITY
) )
} else { } else {
Log.w(TAG, "[generateQuoteThumbnail] Failed to extract video thumbnail") Log.w(TAG, "[generateQuoteThumbnail] Failed to extract video thumbnail")
@@ -2505,10 +2513,10 @@ class AttachmentTable(
} }
} }
} catch (e: BitmapDecodingException) { } catch (e: BitmapDecodingException) {
Log.w(TAG, "[generateQuoteThumbnail] Failed to decode image for thumbnail", e) Log.w(TAG, "[generateQuoteThumbnail] Failed to decode image for thumbnail", e.takeUnless { quiet })
null null
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "[generateQuoteThumbnail] Failed to generate thumbnail", e) Log.w(TAG, "[generateQuoteThumbnail] Failed to generate thumbnail", e.takeUnless { quiet })
null null
} }
} }
@@ -2543,7 +2551,7 @@ class AttachmentTable(
put(VIDEO_GIF, attachment.videoGif.toInt()) put(VIDEO_GIF, attachment.videoGif.toInt())
put(WIDTH, attachment.width) put(WIDTH, attachment.width)
put(HEIGHT, attachment.height) put(HEIGHT, attachment.height)
put(QUOTE, quote) put(QUOTE, quote.toInt())
put(CAPTION, attachment.caption) put(CAPTION, attachment.caption)
put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp) put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp)
put(ARCHIVE_CDN, attachment.archiveCdn) put(ARCHIVE_CDN, attachment.archiveCdn)
@@ -2799,7 +2807,7 @@ class AttachmentTable(
contentValues.put(VIDEO_GIF, if (attachment.videoGif) 1 else 0) contentValues.put(VIDEO_GIF, if (attachment.videoGif) 1 else 0)
contentValues.put(WIDTH, uploadTemplate?.width ?: attachment.width) contentValues.put(WIDTH, uploadTemplate?.width ?: attachment.width)
contentValues.put(HEIGHT, uploadTemplate?.height ?: attachment.height) contentValues.put(HEIGHT, uploadTemplate?.height ?: attachment.height)
contentValues.put(QUOTE, quote) contentValues.put(QUOTE, quote.toInt())
contentValues.put(CAPTION, attachment.caption) contentValues.put(CAPTION, attachment.caption)
contentValues.put(UPLOAD_TIMESTAMP, uploadTemplate?.uploadTimestamp ?: 0) contentValues.put(UPLOAD_TIMESTAMP, uploadTemplate?.uploadTimestamp ?: 0)
contentValues.put(TRANSFORM_PROPERTIES, transformProperties.serialize()) contentValues.put(TRANSFORM_PROPERTIES, transformProperties.serialize())
@@ -3130,6 +3138,38 @@ class AttachmentTable(
) )
} }
/**
* Used in an app migration that creates quote thumbnails. Updates all quote attachments that share the same
* [previousDataFile] to use the new thumbnail.
*
* Handling deduping shouldn't be necessary here because we're updating by the dataFile we used to generate
* the thumbnail. It *is* theoretically possible that generating thumbnails for two different dataFiles
* could result in the same output thumbnail... but that's fine. That rare scenario will result in some missed
* disk savings.
*/
@Throws(Exception::class)
fun migrationFinalizeQuoteWithData(previousDataFile: String, thumbnail: ImageCompressionUtil.Result): String {
val newDataFileInfo = writeToDataFile(newDataFile(context), thumbnail.data.inputStream(), TransformProperties.empty())
writableDatabase
.update(TABLE_NAME)
.values(
DATA_FILE to newDataFileInfo.file.absolutePath,
DATA_SIZE to newDataFileInfo.length,
DATA_RANDOM to newDataFileInfo.random,
DATA_HASH_START to newDataFileInfo.hash,
DATA_HASH_END to newDataFileInfo.hash,
CONTENT_TYPE to thumbnail.mimeType,
WIDTH to thumbnail.width,
HEIGHT to thumbnail.height,
QUOTE to 1
)
.where("$DATA_FILE = ? AND $QUOTE != 0", previousDataFile)
.run()
return newDataFileInfo.file.absolutePath
}
class DataFileWriteResult( class DataFileWriteResult(
val file: File, val file: File,
val length: Long, val length: Long,

View File

@@ -86,6 +86,7 @@ import org.thoughtcrime.securesms.migrations.PnpLaunchMigrationJob;
import org.thoughtcrime.securesms.migrations.PreKeysSyncMigrationJob; import org.thoughtcrime.securesms.migrations.PreKeysSyncMigrationJob;
import org.thoughtcrime.securesms.migrations.ProfileMigrationJob; import org.thoughtcrime.securesms.migrations.ProfileMigrationJob;
import org.thoughtcrime.securesms.migrations.ProfileSharingUpdateMigrationJob; import org.thoughtcrime.securesms.migrations.ProfileSharingUpdateMigrationJob;
import org.thoughtcrime.securesms.migrations.QuoteThumbnailBackfillMigrationJob;
import org.thoughtcrime.securesms.migrations.RebuildMessageSearchIndexMigrationJob; import org.thoughtcrime.securesms.migrations.RebuildMessageSearchIndexMigrationJob;
import org.thoughtcrime.securesms.migrations.RecheckPaymentsMigrationJob; import org.thoughtcrime.securesms.migrations.RecheckPaymentsMigrationJob;
import org.thoughtcrime.securesms.migrations.RecipientSearchMigrationJob; import org.thoughtcrime.securesms.migrations.RecipientSearchMigrationJob;
@@ -233,6 +234,7 @@ public final class JobManagerFactories {
put(PushProcessEarlyMessagesJob.KEY, new PushProcessEarlyMessagesJob.Factory()); put(PushProcessEarlyMessagesJob.KEY, new PushProcessEarlyMessagesJob.Factory());
put(PushProcessMessageErrorJob.KEY, new PushProcessMessageErrorJob.Factory()); put(PushProcessMessageErrorJob.KEY, new PushProcessMessageErrorJob.Factory());
put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory()); put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory());
put(QuoteThumbnailBackfillJob.KEY, new QuoteThumbnailBackfillJob.Factory());
put(ReactionSendJob.KEY, new ReactionSendJob.Factory()); put(ReactionSendJob.KEY, new ReactionSendJob.Factory());
put(RebuildMessageSearchIndexJob.KEY, new RebuildMessageSearchIndexJob.Factory()); put(RebuildMessageSearchIndexJob.KEY, new RebuildMessageSearchIndexJob.Factory());
put(ReclaimUsernameAndLinkJob.KEY, new ReclaimUsernameAndLinkJob.Factory()); put(ReclaimUsernameAndLinkJob.KEY, new ReclaimUsernameAndLinkJob.Factory());
@@ -326,6 +328,7 @@ public final class JobManagerFactories {
put(PreKeysSyncMigrationJob.KEY, new PreKeysSyncMigrationJob.Factory()); put(PreKeysSyncMigrationJob.KEY, new PreKeysSyncMigrationJob.Factory());
put(ProfileMigrationJob.KEY, new ProfileMigrationJob.Factory()); put(ProfileMigrationJob.KEY, new ProfileMigrationJob.Factory());
put(ProfileSharingUpdateMigrationJob.KEY, new ProfileSharingUpdateMigrationJob.Factory()); put(ProfileSharingUpdateMigrationJob.KEY, new ProfileSharingUpdateMigrationJob.Factory());
put(QuoteThumbnailBackfillMigrationJob.KEY, new QuoteThumbnailBackfillMigrationJob.Factory());
put(RebuildMessageSearchIndexMigrationJob.KEY, new RebuildMessageSearchIndexMigrationJob.Factory()); put(RebuildMessageSearchIndexMigrationJob.KEY, new RebuildMessageSearchIndexMigrationJob.Factory());
put(RecheckPaymentsMigrationJob.KEY, new RecheckPaymentsMigrationJob.Factory()); put(RecheckPaymentsMigrationJob.KEY, new RecheckPaymentsMigrationJob.Factory());
put(RecipientSearchMigrationJob.KEY, new RecipientSearchMigrationJob.Factory()); put(RecipientSearchMigrationJob.KEY, new RecipientSearchMigrationJob.Factory());

View File

@@ -0,0 +1,151 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import android.net.Uri
import org.signal.core.util.logging.Log
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.update
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.CONTENT_TYPE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_FILE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_HASH_END
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_HASH_START
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_RANDOM
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_SIZE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.ID
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.QUOTE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.QUOTE_PENDING_TRANSCODE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.TABLE_NAME
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.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
* and then reschedule itself to run again if necessary.
*/
class QuoteThumbnailBackfillJob private constructor(parameters: Parameters) : Job(parameters) {
companion object {
val TAG = Log.tag(QuoteThumbnailBackfillJob::class.java)
const val KEY = "QuoteThumbnailBackfillJob"
}
private var activeAttachmentInfo: AttachmentInfo? = null
constructor() : this(
Parameters.Builder()
.setQueue(KEY)
.setMaxInstancesForFactory(2)
.setLifespan(Parameters.IMMORTAL)
.build()
)
override fun serialize() = null
override fun getFactoryKey() = KEY
override fun run(): Result {
for (i in 1..10) {
val complete = peformSingleBackfill()
if (complete) {
return Result.success()
}
}
AppDependencies.jobManager.add(QuoteThumbnailBackfillJob())
return Result.success()
}
/** Returns true if the entire backfill process is complete, otherwise false */
private fun peformSingleBackfill(): Boolean {
val attachment = SignalDatabase.attachments.getNextQuoteAttachmentForThumbnailProcessing()
if (attachment == null) {
Log.i(TAG, "No more quote attachments to process! Task complete.")
return true
}
activeAttachmentInfo = attachment
val thumbnail = SignalDatabase.attachments.generateQuoteThumbnail(DecryptableUri(attachment.uri), attachment.contentType, quiet = true)
if (thumbnail != null) {
SignalDatabase.attachments.migrationFinalizeQuoteWithData(attachment.dataFile, thumbnail)
} else {
Log.w(TAG, "Failed to generate thumbnail for attachment: ${attachment.id}. Clearing data.")
SignalDatabase.attachments.finalizeQuoteWithNoData(attachment.dataFile)
}
return false
}
override fun onFailure() {
activeAttachmentInfo?.let { attachment ->
Log.w(TAG, "Failed during thumbnail generation. Clearing the quote data and continuing.", true)
SignalDatabase.attachments.finalizeQuoteWithNoData(attachment.dataFile)
} ?: Log.w(TAG, "Job failed, but no active file is set!")
AppDependencies.jobManager.add(QuoteThumbnailBackfillJob())
}
/** Gets the next quote that has a scheduled thumbnail generation, favoring newer ones. */
private fun AttachmentTable.getNextQuoteAttachmentForThumbnailProcessing(): AttachmentInfo? {
return readableDatabase
.select(ID, DATA_FILE, CONTENT_TYPE)
.from(TABLE_NAME)
.where("$QUOTE = $QUOTE_PENDING_TRANSCODE")
.orderBy("$ID DESC")
.limit(1)
.run()
.readToSingleObject {
AttachmentInfo(
id = AttachmentId(it.requireLong(ID)),
dataFile = it.requireNonNullString(DATA_FILE),
contentType = it.requireString(CONTENT_TYPE)
)
}
}
/** Finalizes all quote attachments that share the given [dataFile] with empty data (because we could not generate a thumbnail). */
private fun AttachmentTable.finalizeQuoteWithNoData(dataFile: String) {
writableDatabase
.update(TABLE_NAME)
.values(
DATA_FILE to null,
DATA_RANDOM to null,
DATA_HASH_START to null,
DATA_HASH_END to null,
DATA_SIZE to 0,
QUOTE to 1
)
.where("$DATA_FILE = ? AND $QUOTE != 0 ", dataFile)
.run()
}
private data class AttachmentInfo(
val id: AttachmentId,
val dataFile: String,
val contentType: String?
) {
val uri: Uri get() = PartAuthority.getAttachmentDataUri(id)
}
class Factory : Job.Factory<QuoteThumbnailBackfillJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): QuoteThumbnailBackfillJob {
return QuoteThumbnailBackfillJob(parameters)
}
}
}

View File

@@ -41,15 +41,17 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
private const val LAST_CONNECTIVITY_WARNING_TIME = "misc.last_connectivity_warning_time" private const val LAST_CONNECTIVITY_WARNING_TIME = "misc.last_connectivity_warning_time"
private const val NEW_LINKED_DEVICE_ID = "misc.new_linked_device_id" private const val NEW_LINKED_DEVICE_ID = "misc.new_linked_device_id"
private const val NEW_LINKED_DEVICE_CREATED_TIME = "misc.new_linked_device_created_time" private const val NEW_LINKED_DEVICE_CREATED_TIME = "misc.new_linked_device_created_time"
private const val STARTED_QUOTE_THUMBNAIL_MIGRATION = "misc.started_quote_thumbnail_migration"
} }
public override fun onFirstEverAppLaunch() { public override fun onFirstEverAppLaunch() {
putLong(MESSAGE_REQUEST_ENABLE_TIME, 0) putLong(MESSAGE_REQUEST_ENABLE_TIME, 0)
putBoolean(NEEDS_USERNAME_RESTORE, true) putBoolean(NEEDS_USERNAME_RESTORE, true)
putBoolean(STARTED_QUOTE_THUMBNAIL_MIGRATION, true)
} }
public override fun getKeysToIncludeInBackup(): List<String> { public override fun getKeysToIncludeInBackup(): List<String> {
return emptyList() return listOf(STARTED_QUOTE_THUMBNAIL_MIGRATION)
} }
/** /**
@@ -277,4 +279,13 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
* The time, in milliseconds, that the device was created at * The time, in milliseconds, that the device was created at
*/ */
var newLinkedDeviceCreatedTime: Long by longValue(NEW_LINKED_DEVICE_CREATED_TIME, 0) var newLinkedDeviceCreatedTime: Long by longValue(NEW_LINKED_DEVICE_CREATED_TIME, 0)
/**
* Whether or not we have started the quote thumbnail migration. We store this so that upon restoring from
* a local backup, we can know whether or not the user marked all of the quotes that need conversion in
* the database. If so, we can enqueue a job to continue any pending conversions, and if not we can start
* the conversion process from scratch.
*/
@get:JvmName("startedQuoteThumbnailMigration")
var startedQuoteThumbnailMigration: Boolean by booleanValue(STARTED_QUOTE_THUMBNAIL_MIGRATION, false)
} }

View File

@@ -187,9 +187,10 @@ public class ApplicationMigrations {
static final int SVR2_ENCLAVE_UPDATE_4 = 143; static final int SVR2_ENCLAVE_UPDATE_4 = 143;
static final int RESET_ARCHIVE_TIER = 144; static final int RESET_ARCHIVE_TIER = 144;
static final int ARCHIVE_BACKUP_ID = 145; static final int ARCHIVE_BACKUP_ID = 145;
static final int QUOTE_THUMBNAIL_BACKFILL = 146;
} }
public static final int CURRENT_VERSION = 145; public static final int CURRENT_VERSION = 146;
/** /**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@@ -864,6 +865,10 @@ public class ApplicationMigrations {
jobs.put(Version.ARCHIVE_BACKUP_ID, new ArchiveBackupIdReservationMigrationJob()); jobs.put(Version.ARCHIVE_BACKUP_ID, new ArchiveBackupIdReservationMigrationJob());
} }
if (lastSeenVersion < Version.QUOTE_THUMBNAIL_BACKFILL) {
jobs.put(Version.QUOTE_THUMBNAIL_BACKFILL, new QuoteThumbnailBackfillMigrationJob());
}
return jobs; return jobs;
} }

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.migrations
import org.signal.core.util.logging.Log
import org.signal.core.util.update
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_FILE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.QUOTE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.QUOTE_PENDING_TRANSCODE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.TABLE_NAME
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.QuoteThumbnailBackfillJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.lang.Exception
/**
* Kicks off the quote attachment thumbnail generation process by marking quote attachments
* for processing and enqueueing a [QuoteThumbnailBackfillJob].
*/
internal class QuoteThumbnailBackfillMigrationJob(parameters: Parameters = Parameters.Builder().build()) : MigrationJob(parameters) {
companion object {
val TAG = Log.tag(QuoteThumbnailBackfillMigrationJob::class.java)
const val KEY = "QuoteThumbnailBackfillMigrationJob"
}
override fun getFactoryKey(): String = KEY
override fun isUiBlocking(): Boolean = false
override fun performMigration() {
val markedCount = SignalDatabase.attachments.migrationMarkQuoteAttachmentsForThumbnailProcessing()
SignalStore.misc.startedQuoteThumbnailMigration = true
Log.i(TAG, "Marked $markedCount quote attachments for thumbnail processing")
if (markedCount > 0) {
AppDependencies.jobManager.add(QuoteThumbnailBackfillJob())
} else {
Log.i(TAG, "No quote attachments to process.")
}
}
override fun shouldRetry(e: Exception): Boolean = false
private fun AttachmentTable.migrationMarkQuoteAttachmentsForThumbnailProcessing(): Int {
return writableDatabase
.update(TABLE_NAME)
.values(QUOTE to QUOTE_PENDING_TRANSCODE)
.where("$QUOTE != 0 AND $DATA_FILE NOT NULL")
.run()
}
class Factory : Job.Factory<QuoteThumbnailBackfillMigrationJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): QuoteThumbnailBackfillMigrationJob {
return QuoteThumbnailBackfillMigrationJob(parameters)
}
}
}

View File

@@ -79,6 +79,22 @@ public final class ImageCompressionUtil {
int maxDimension, int maxDimension,
@IntRange(from = 0, to = 100) int quality) @IntRange(from = 0, to = 100) int quality)
throws BitmapDecodingException throws BitmapDecodingException
{
return compress(context, contentType, targetContentType, glideModel, maxDimension, quality, false);
}
/**
* Compresses the image to match the requested parameters.
*/
@WorkerThread
public static @NonNull Result compress(@NonNull Context context,
@Nullable String contentType,
@Nullable String targetContentType,
@NonNull Object glideModel,
int maxDimension,
@IntRange(from = 0, to = 100) int quality,
boolean quiet)
throws BitmapDecodingException
{ {
Bitmap scaledBitmap; Bitmap scaledBitmap;
@@ -93,6 +109,10 @@ public final class ImageCompressionUtil {
.submit(maxDimension, maxDimension) .submit(maxDimension, maxDimension)
.get(); .get();
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
if (quiet) {
throw new BitmapDecodingException(e);
}
Log.w(TAG, "Verbose logging to try to give all possible debug information for Glide issues. Exceptions below may be duplicated.", e); Log.w(TAG, "Verbose logging to try to give all possible debug information for Glide issues. Exceptions below may be duplicated.", e);
if (e.getCause() instanceof GlideException) { if (e.getCause() instanceof GlideException) {
List<Throwable> rootCauses = ((GlideException) e.getCause()).getRootCauses(); List<Throwable> rootCauses = ((GlideException) e.getCause()).getRootCauses();