From 631b51baf27aa8d928adc4b1664e654f28a267e8 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 28 Aug 2025 11:57:45 -0400 Subject: [PATCH] Add a migration to generate thumbnails for existing quotes. --- .../io/DecryptableStreamLocalUriFetcher.java | 8 +- .../securesms/AppInitialization.java | 8 + .../v2/exporters/ChatItemArchiveExporter.kt | 2 +- .../securesms/database/AttachmentTable.kt | 72 +++++++-- .../securesms/jobs/JobManagerFactories.java | 3 + .../jobs/QuoteThumbnailBackfillJob.kt | 151 ++++++++++++++++++ .../securesms/keyvalue/MiscellaneousValues.kt | 13 +- .../migrations/ApplicationMigrations.java | 7 +- .../QuoteThumbnailBackfillMigrationJob.kt | 65 ++++++++ .../securesms/util/ImageCompressionUtil.java | 22 ++- 10 files changed, 328 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/QuoteThumbnailBackfillJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/migrations/QuoteThumbnailBackfillMigrationJob.kt diff --git a/app/src/main/java/org/signal/glide/common/io/DecryptableStreamLocalUriFetcher.java b/app/src/main/java/org/signal/glide/common/io/DecryptableStreamLocalUriFetcher.java index 43f049e3a9..3c4704e6d9 100644 --- a/app/src/main/java/org/signal/glide/common/io/DecryptableStreamLocalUriFetcher.java +++ b/app/src/main/java/org/signal/glide/common/io/DecryptableStreamLocalUriFetcher.java @@ -69,7 +69,7 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher { try { if (PartAuthority.isBlobUri(uri) && BlobProvider.isSingleUseMemoryBlob(uri)) { return PartAuthority.getAttachmentThumbnailStream(context, uri); - } else if (isSafeSize(PartAuthority.getAttachmentThumbnailStream(context, uri))) { + } else if (isSafeSize(context, uri)) { return PartAuthority.getAttachmentThumbnailStream(context, uri); } else { 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 { + InputStream stream = PartAuthority.getAttachmentThumbnailStream(context, uri); Pair dimensions = BitmapUtil.getDimensions(stream); long totalPixels = (long) dimensions.first * dimensions.second; return totalPixels < TOTAL_PIXEL_SIZE_LIMIT; } catch (BitmapDecodingException e) { - return false; + Long size = PartAuthority.getAttachmentSize(context, uri); + return size != null && size < GlideStreamConfig.getMarkReadLimitBytes(); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java b/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java index 20d3e5501b..29398ba24d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java +++ b/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java @@ -9,9 +9,11 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobs.DeleteAbandonedAttachmentsJob; import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob; +import org.thoughtcrime.securesms.jobs.QuoteThumbnailBackfillJob; import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.migrations.ApplicationMigrations; +import org.thoughtcrime.securesms.migrations.QuoteThumbnailBackfillMigrationJob; import org.thoughtcrime.securesms.stickers.BlessedPacks; import org.thoughtcrime.securesms.util.TextSecurePreferences; 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())); EmojiSearchIndexDownloadJob.scheduleImmediately(); DeleteAbandonedAttachmentsJob.enqueue(); + + if (SignalStore.misc().startedQuoteThumbnailMigration()) { + AppDependencies.getJobManager().add(new QuoteThumbnailBackfillJob()); + } else { + AppDependencies.getJobManager().add(new QuoteThumbnailBackfillMigrationJob()); + } } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt index b947f1ef63..bbcbdedb58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt @@ -460,7 +460,7 @@ class ChatItemArchiveExporter( val attachmentsFuture = executor.submitTyped { extraDataTimer.timeEvent("attachments") { - db.attachmentTable.getAttachmentsForMessages(messageIds) + db.attachmentTable.getAttachmentsForMessages(messageIds, excludeTranscodingQuotes = true) } } 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 9ea4a6ebbf..d93c935004 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -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 ) - private const val QUOTE_THUMBNAIL_DIMEN = 150 - private const val QUOTE_IMAGE_QUALITY = 80 + private const val QUOTE_THUMBNAIL_DIMEN = 200 + private const val QUOTE_THUMBAIL_QUALITY = 50 + const val QUOTE_PENDING_TRANSCODE = 2 @JvmStatic @Throws(IOException::class) @@ -453,17 +454,23 @@ class AttachmentTable( .flatten() } - fun getAttachmentsForMessages(mmsIds: Collection): Map> { + @JvmOverloads + fun getAttachmentsForMessages(mmsIds: Collection, excludeTranscodingQuotes: Boolean = false): Map> { if (mmsIds.isEmpty()) { return emptyMap() } val query = SqlUtil.buildFastCollectionQuery(MESSAGE_ID, mmsIds) + val where = if (excludeTranscodingQuotes) { + "(${query.where}) AND $QUOTE != $QUOTE_PENDING_TRANSCODE" + } else { + query.where + } return readableDatabase .select(*PROJECTION) .from(TABLE_NAME) - .where(query.where, query.whereArgs) + .where(where, query.whereArgs) .orderBy("$ID ASC") .run() .groupBy { cursor -> @@ -2027,12 +2034,12 @@ class AttachmentTable( incrementalDigest = null, incrementalMacChunkSize = 0, fastPreflightId = jsonObject.getString(FAST_PREFLIGHT_ID), - voiceNote = jsonObject.getInt(VOICE_NOTE) == 1, - borderless = jsonObject.getInt(BORDERLESS) == 1, - videoGif = jsonObject.getInt(VIDEO_GIF) == 1, + voiceNote = jsonObject.getInt(VOICE_NOTE) != 0, + borderless = jsonObject.getInt(BORDERLESS) != 0, + videoGif = jsonObject.getInt(VIDEO_GIF) != 0, width = jsonObject.getInt(WIDTH), height = jsonObject.getInt(HEIGHT), - quote = jsonObject.getInt(QUOTE) == 1, + quote = jsonObject.getInt(QUOTE) != 0, caption = jsonObject.getString(CAPTION), stickerLocator = if (jsonObject.getInt(STICKER_ID) >= 0) { StickerLocator( @@ -2386,7 +2393,7 @@ class AttachmentTable( put(VIDEO_GIF, attachment.videoGif.toInt()) put(WIDTH, attachment.width) put(HEIGHT, attachment.height) - put(QUOTE, quote) + put(QUOTE, quote.toInt()) put(CAPTION, attachment.caption) put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp) put(BLUR_HASH, attachment.blurHash?.hash) @@ -2467,7 +2474,7 @@ class AttachmentTable( return attachmentId } - private fun generateQuoteThumbnail(uri: DecryptableUri, contentType: String?): ImageCompressionUtil.Result? { + fun generateQuoteThumbnail(uri: DecryptableUri, contentType: String?, quiet: Boolean = false): ImageCompressionUtil.Result? { return try { when { MediaUtil.isImageType(contentType) -> { @@ -2480,7 +2487,8 @@ class AttachmentTable( outputFormat, uri, QUOTE_THUMBNAIL_DIMEN, - QUOTE_IMAGE_QUALITY + QUOTE_THUMBAIL_QUALITY, + true ) } MediaUtil.isVideoType(contentType) -> { @@ -2492,7 +2500,7 @@ class AttachmentTable( MediaUtil.IMAGE_JPEG, uri, QUOTE_THUMBNAIL_DIMEN, - QUOTE_IMAGE_QUALITY + QUOTE_THUMBAIL_QUALITY ) } else { Log.w(TAG, "[generateQuoteThumbnail] Failed to extract video thumbnail") @@ -2505,10 +2513,10 @@ class AttachmentTable( } } } 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 } catch (e: Exception) { - Log.w(TAG, "[generateQuoteThumbnail] Failed to generate thumbnail", e) + Log.w(TAG, "[generateQuoteThumbnail] Failed to generate thumbnail", e.takeUnless { quiet }) null } } @@ -2543,7 +2551,7 @@ class AttachmentTable( put(VIDEO_GIF, attachment.videoGif.toInt()) put(WIDTH, attachment.width) put(HEIGHT, attachment.height) - put(QUOTE, quote) + put(QUOTE, quote.toInt()) put(CAPTION, attachment.caption) put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp) put(ARCHIVE_CDN, attachment.archiveCdn) @@ -2799,7 +2807,7 @@ class AttachmentTable( contentValues.put(VIDEO_GIF, if (attachment.videoGif) 1 else 0) contentValues.put(WIDTH, uploadTemplate?.width ?: attachment.width) contentValues.put(HEIGHT, uploadTemplate?.height ?: attachment.height) - contentValues.put(QUOTE, quote) + contentValues.put(QUOTE, quote.toInt()) contentValues.put(CAPTION, attachment.caption) contentValues.put(UPLOAD_TIMESTAMP, uploadTemplate?.uploadTimestamp ?: 0) 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( val file: File, val length: Long, 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 571a0f93df..c74b1e597b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -86,6 +86,7 @@ import org.thoughtcrime.securesms.migrations.PnpLaunchMigrationJob; import org.thoughtcrime.securesms.migrations.PreKeysSyncMigrationJob; import org.thoughtcrime.securesms.migrations.ProfileMigrationJob; import org.thoughtcrime.securesms.migrations.ProfileSharingUpdateMigrationJob; +import org.thoughtcrime.securesms.migrations.QuoteThumbnailBackfillMigrationJob; import org.thoughtcrime.securesms.migrations.RebuildMessageSearchIndexMigrationJob; import org.thoughtcrime.securesms.migrations.RecheckPaymentsMigrationJob; import org.thoughtcrime.securesms.migrations.RecipientSearchMigrationJob; @@ -233,6 +234,7 @@ public final class JobManagerFactories { put(PushProcessEarlyMessagesJob.KEY, new PushProcessEarlyMessagesJob.Factory()); put(PushProcessMessageErrorJob.KEY, new PushProcessMessageErrorJob.Factory()); put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory()); + put(QuoteThumbnailBackfillJob.KEY, new QuoteThumbnailBackfillJob.Factory()); put(ReactionSendJob.KEY, new ReactionSendJob.Factory()); put(RebuildMessageSearchIndexJob.KEY, new RebuildMessageSearchIndexJob.Factory()); put(ReclaimUsernameAndLinkJob.KEY, new ReclaimUsernameAndLinkJob.Factory()); @@ -326,6 +328,7 @@ public final class JobManagerFactories { put(PreKeysSyncMigrationJob.KEY, new PreKeysSyncMigrationJob.Factory()); put(ProfileMigrationJob.KEY, new ProfileMigrationJob.Factory()); put(ProfileSharingUpdateMigrationJob.KEY, new ProfileSharingUpdateMigrationJob.Factory()); + put(QuoteThumbnailBackfillMigrationJob.KEY, new QuoteThumbnailBackfillMigrationJob.Factory()); put(RebuildMessageSearchIndexMigrationJob.KEY, new RebuildMessageSearchIndexMigrationJob.Factory()); put(RecheckPaymentsMigrationJob.KEY, new RecheckPaymentsMigrationJob.Factory()); put(RecipientSearchMigrationJob.KEY, new RecipientSearchMigrationJob.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 new file mode 100644 index 0000000000..bc5218f6b4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/QuoteThumbnailBackfillJob.kt @@ -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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): QuoteThumbnailBackfillJob { + return QuoteThumbnailBackfillJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt index a61de8e499..02706ca9f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt @@ -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 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 STARTED_QUOTE_THUMBNAIL_MIGRATION = "misc.started_quote_thumbnail_migration" } public override fun onFirstEverAppLaunch() { putLong(MESSAGE_REQUEST_ENABLE_TIME, 0) putBoolean(NEEDS_USERNAME_RESTORE, true) + putBoolean(STARTED_QUOTE_THUMBNAIL_MIGRATION, true) } public override fun getKeysToIncludeInBackup(): List { - 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 */ 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index fdb0cf31d8..c7bf554fa6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -187,9 +187,10 @@ public class ApplicationMigrations { static final int SVR2_ENCLAVE_UPDATE_4 = 143; static final int RESET_ARCHIVE_TIER = 144; 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 @@ -864,6 +865,10 @@ public class ApplicationMigrations { jobs.put(Version.ARCHIVE_BACKUP_ID, new ArchiveBackupIdReservationMigrationJob()); } + if (lastSeenVersion < Version.QUOTE_THUMBNAIL_BACKFILL) { + jobs.put(Version.QUOTE_THUMBNAIL_BACKFILL, new QuoteThumbnailBackfillMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/QuoteThumbnailBackfillMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/QuoteThumbnailBackfillMigrationJob.kt new file mode 100644 index 0000000000..6fc5cb1fef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/QuoteThumbnailBackfillMigrationJob.kt @@ -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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): QuoteThumbnailBackfillMigrationJob { + return QuoteThumbnailBackfillMigrationJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java index 461c672674..817a414bd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java @@ -45,7 +45,7 @@ public final class ImageCompressionUtil { } }; - private ImageCompressionUtil () {} + private ImageCompressionUtil() {} /** * A result satisfying the provided constraints, or null if they could not be met. @@ -79,6 +79,22 @@ public final class ImageCompressionUtil { int maxDimension, @IntRange(from = 0, to = 100) int quality) 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; @@ -93,6 +109,10 @@ public final class ImageCompressionUtil { .submit(maxDimension, maxDimension) .get(); } 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); if (e.getCause() instanceof GlideException) { List rootCauses = ((GlideException) e.getCause()).getRootCauses();