Use dynamic quality and webp for archive thumbnail generation.

This commit is contained in:
Cody Henthorne
2025-06-04 11:05:51 -04:00
parent e3ee3d3dba
commit be4af1d560
5 changed files with 54 additions and 7 deletions

View File

@@ -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))

View File

@@ -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);
}
}

View File

@@ -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!");
}

View File

@@ -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);
}

View File

@@ -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")