diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt index 7d1c37dfa7..a3033531ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt @@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri import org.thoughtcrime.securesms.net.SignalNetwork import org.thoughtcrime.securesms.util.ImageCompressionUtil import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.messages.SignalServiceAttachment import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream @@ -31,6 +32,8 @@ import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec import java.io.ByteArrayInputStream import java.io.IOException import java.util.Optional +import kotlin.math.floor +import kotlin.math.max import kotlin.time.Duration.Companion.days /** @@ -45,6 +48,11 @@ class ArchiveThumbnailUploadJob private constructor( const val KEY = "ArchiveThumbnailUploadJob" private val TAG = Log.tag(ArchiveThumbnailUploadJob::class.java) + private const val STARTING_IMAGE_QUALITY = 75f + private const val MINIMUM_IMAGE_QUALITY = 10f + private const val MAX_PIXEL_DIMENSION = 256 + private const val ADDITIONAL_QUALITY_DECREASE = 10f + fun enqueueIfNecessary(attachmentId: AttachmentId) { if (SignalStore.backup.backsUpMedia) { AppDependencies.jobManager.add(ArchiveThumbnailUploadJob(attachmentId)) @@ -137,7 +145,7 @@ class ArchiveThumbnailUploadJob private constructor( // save attachment thumbnail SignalDatabase.attachments.finalizeAttachmentThumbnailAfterUpload( attachmentId = attachmentId, - attachmentDigest = attachment.remoteDigest!!, + attachmentDigest = attachment.remoteDigest, data = thumbnailResult.data ) @@ -163,16 +171,34 @@ class ArchiveThumbnailUploadJob private constructor( val uri: DecryptableUri = attachment.uri?.let { DecryptableUri(it) } ?: return null return if (MediaUtil.isImageType(attachment.contentType)) { - ImageCompressionUtil.compress(context, attachment.contentType ?: "", uri, 256, 50) + compress(uri, attachment.contentType ?: "") } else if (Build.VERSION.SDK_INT >= 23 && MediaUtil.isVideoType(attachment.contentType)) { MediaUtil.getVideoThumbnail(context, attachment.uri)?.let { - ImageCompressionUtil.compress(context, attachment.contentType ?: "", uri, 256, 50) + compress(uri, attachment.contentType ?: "") } } else { null } } + private fun compress(uri: DecryptableUri, contentType: String): ImageCompressionUtil.Result? { + val maxFileSize = RemoteConfig.backupMaxThumbnailFileSize.inWholeBytes.toFloat() + var attempts = 0 + var quality = STARTING_IMAGE_QUALITY + + var result: ImageCompressionUtil.Result? = ImageCompressionUtil.compress(context, contentType, MediaUtil.IMAGE_WEBP, uri, MAX_PIXEL_DIMENSION, quality.toInt()) + + while (result != null && result.data.size > maxFileSize && attempts < 5 && quality > MINIMUM_IMAGE_QUALITY) { + val maxSizeToActualRatio = maxFileSize / result.data.size.toFloat() + val newQuality = quality * maxSizeToActualRatio - ADDITIONAL_QUALITY_DECREASE + + quality = floor(max(MINIMUM_IMAGE_QUALITY, newQuality)) + result = ImageCompressionUtil.compress(context, contentType, MediaUtil.IMAGE_WEBP, uri, MAX_PIXEL_DIMENSION, quality.toInt()) + attempts++ + } + return result + } + private fun buildSignalServiceAttachmentStream(result: ImageCompressionUtil.Result, uploadSpec: ResumableUpload): SignalServiceAttachmentStream { return SignalServiceAttachment.newStreamBuilder() .withStream(ByteArrayInputStream(result.data)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index bd3f852feb..221842c75e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -373,12 +373,12 @@ public abstract class PushSendJob extends SendJob { try { if (MediaUtil.isImageType(attachment.contentType) && attachment.getUri() != null) { - thumbnailData = ImageCompressionUtil.compress(context, attachment.contentType, new DecryptableUri(attachment.getUri()), 100, 50); + thumbnailData = ImageCompressionUtil.compress(context, attachment.contentType, attachment.contentType, new DecryptableUri(attachment.getUri()), 100, 50); } else if (Build.VERSION.SDK_INT >= 23 && MediaUtil.isVideoType(attachment.contentType) && attachment.getUri() != null) { Bitmap bitmap = MediaUtil.getVideoThumbnail(context, attachment.getUri(), 1000); if (bitmap != null) { - thumbnailData = ImageCompressionUtil.compress(context, attachment.contentType, new DecryptableUri(attachment.getUri()), 100, 50); + thumbnailData = ImageCompressionUtil.compress(context, attachment.contentType, attachment.contentType, new DecryptableUri(attachment.getUri()), 100, 50); } } 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 dac71dbdd9..461c672674 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java @@ -59,7 +59,7 @@ public final class ImageCompressionUtil { @IntRange(from = 0, to = 100) int quality) throws BitmapDecodingException { - Result result = compress(context, mimeType, glideModel, maxDimension, quality); + Result result = compress(context, mimeType, mimeType, glideModel, maxDimension, quality); if (result.getData().length <= maxBytes) { return result; @@ -74,6 +74,7 @@ public final class ImageCompressionUtil { @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) @@ -121,7 +122,7 @@ public final class ImageCompressionUtil { } ByteArrayOutputStream output = new ByteArrayOutputStream(); - Bitmap.CompressFormat format = mimeTypeToCompressFormat(contentType); + Bitmap.CompressFormat format = mimeTypeToCompressFormat(targetContentType); scaledBitmap.compress(format, quality, output); byte[] data = output.toByteArray(); @@ -137,6 +138,8 @@ public final class ImageCompressionUtil { MediaUtil.isAvifType(mimeType) || MediaUtil.isVideoType(mimeType)) { return Bitmap.CompressFormat.JPEG; + } else if (MediaUtil.isWebpType(mimeType)) { + return Bitmap.CompressFormat.WEBP; } else { return Bitmap.CompressFormat.PNG; } @@ -148,6 +151,8 @@ public final class ImageCompressionUtil { return MediaUtil.IMAGE_JPEG; case PNG: return MediaUtil.IMAGE_PNG; + case WEBP: + return MediaUtil.IMAGE_WEBP; default: throw new AssertionError("Unsupported format!"); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java index 1a630fe124..c355cff0be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -347,6 +347,10 @@ public class MediaUtil { return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_AVIF); } + public static boolean isWebpType(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_WEBP); + } + public static boolean isFile(Attachment attachment) { return !isGif(attachment) && !isImage(attachment) && !isAudio(attachment) && !isVideo(attachment); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index ffe6c76a7a..dddb22c814 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -5,7 +5,10 @@ import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import org.json.JSONException import org.json.JSONObject +import org.signal.core.util.ByteSize +import org.signal.core.util.bytes import org.signal.core.util.gibiBytes +import org.signal.core.util.kibiBytes import org.signal.core.util.logging.Log import org.signal.core.util.mebiBytes import org.thoughtcrime.securesms.BuildConfig @@ -989,6 +992,15 @@ object RemoteConfig { defaultValue = 3 ) + /** Max plaintext unpadded file size for backup thumbnails. */ + val backupMaxThumbnailFileSize: ByteSize by remoteValue( + key = "global.backups.maxThumbnailFileSizeBytes", + hotSwappable = true, + active = true + ) { value -> + value.asLong(8.kibiBytes.inWholeBytes).bytes + } + /** Whether unauthenticated chat web socket is backed by libsignal-net */ @JvmStatic @get:JvmName("libSignalWebSocketEnabled")