From 236e1ba885487db25d6e6bc6d2a2655bb1046b3a Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 8 Feb 2021 15:39:04 -0500 Subject: [PATCH] Updated image compression parameters. --- .../jobs/AttachmentCompressionJob.java | 78 +++++++---- .../securesms/mms/MediaConstraints.java | 7 + .../securesms/mms/MmsMediaConstraints.java | 13 ++ .../securesms/mms/PushMediaConstraints.java | 13 +- .../profiles/ProfileMediaConstraints.java | 5 + .../securesms/util/BitmapUtil.java | 13 ++ .../securesms/util/ImageCompressionUtil.java | 130 ++++++++++++++++++ 7 files changed, 227 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index ad65cc6fa4..36094e664e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -2,8 +2,10 @@ package org.thoughtcrime.securesms.jobs; import android.content.Context; import android.media.MediaDataSource; +import android.net.Uri; import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; import com.google.android.exoplayer2.util.MimeTypes; @@ -31,8 +33,8 @@ import org.thoughtcrime.securesms.service.GenericForegroundService; import org.thoughtcrime.securesms.service.NotificationController; import org.thoughtcrime.securesms.transport.UndeliverableMessageException; import org.thoughtcrime.securesms.util.BitmapDecodingException; -import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ImageCompressionUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MemoryFileDescriptor; import org.thoughtcrime.securesms.util.MemoryFileDescriptor.MemoryFileException; @@ -141,7 +143,7 @@ public final class AttachmentCompressionJob extends BaseJob { MediaConstraints mediaConstraints = mms ? MediaConstraints.getMmsMediaConstraints(mmsSubscriptionId) : MediaConstraints.getPushMediaConstraints(); - scaleAndStripExif(database, mediaConstraints, databaseAttachment); + compress(database, mediaConstraints, databaseAttachment); } @Override @@ -152,30 +154,25 @@ public final class AttachmentCompressionJob extends BaseJob { return exception instanceof IOException; } - private void scaleAndStripExif(@NonNull AttachmentDatabase attachmentDatabase, - @NonNull MediaConstraints constraints, - @NonNull DatabaseAttachment attachment) + private void compress(@NonNull AttachmentDatabase attachmentDatabase, + @NonNull MediaConstraints constraints, + @NonNull DatabaseAttachment attachment) throws UndeliverableMessageException { try { if (MediaUtil.isVideo(attachment)) { + Log.i(TAG, "Compressing video."); attachment = transcodeVideoIfNeededToDatabase(context, attachmentDatabase, attachment, constraints, EventBus.getDefault(), this::isCanceled); if (!constraints.isSatisfied(context, attachment)) { throw new UndeliverableMessageException("Size constraints could not be met on video!"); } - } else if (MediaUtil.isHeic(attachment) || MediaUtil.isHeif(attachment)) { - MediaStream converted = getResizedMedia(context, attachment, constraints); + } else if (constraints.canResize(attachment)) { + Log.i(TAG, "Compressing image."); + MediaStream converted = compressImage(context, attachment, constraints); attachmentDatabase.updateAttachmentData(attachment, converted, false); attachmentDatabase.markAttachmentAsTransformed(attachmentId); } else if (constraints.isSatisfied(context, attachment)) { - if (MediaUtil.isJpeg(attachment)) { - MediaStream stripped = getResizedMedia(context, attachment, constraints); - attachmentDatabase.updateAttachmentData(attachment, stripped, false); - } - attachmentDatabase.markAttachmentAsTransformed(attachmentId); - } else if (constraints.canResize(attachment)) { - MediaStream resized = getResizedMedia(context, attachment, constraints); - attachmentDatabase.updateAttachmentData(attachment, resized, false); + Log.i(TAG, "Not compressing."); attachmentDatabase.markAttachmentAsTransformed(attachmentId); } else { throw new UndeliverableMessageException("Size constraints could not be met!"); @@ -295,27 +292,48 @@ public final class AttachmentCompressionJob extends BaseJob { return attachment; } - private static MediaStream getResizedMedia(@NonNull Context context, - @NonNull Attachment attachment, - @NonNull MediaConstraints constraints) - throws IOException + /** + * Compresses the images. Given that we compress every image, this has the fun side effect of + * stripping all EXIF data. + */ + @WorkerThread + private static MediaStream compressImage(@NonNull Context context, + @NonNull Attachment attachment, + @NonNull MediaConstraints mediaConstraints) + throws UndeliverableMessageException { - if (!constraints.canResize(attachment)) { - throw new UnsupportedOperationException("Cannot resize this content type"); + Uri uri = attachment.getUri(); + + if (uri == null) { + throw new UndeliverableMessageException("No attachment URI!"); } + ImageCompressionUtil.Result result = null; + try { - BitmapUtil.ScaleResult scaleResult = BitmapUtil.createScaledBytes(context, - new DecryptableStreamUriLoader.DecryptableUri(attachment.getUri()), - constraints); - - return new MediaStream(new ByteArrayInputStream(scaleResult.getBitmap()), - MediaUtil.IMAGE_JPEG, - scaleResult.getWidth(), - scaleResult.getHeight()); + for (int size : mediaConstraints.getImageDimensionTargets(context)) { + result = ImageCompressionUtil.compressWithinConstraints(context, + attachment.getContentType(), + new DecryptableStreamUriLoader.DecryptableUri(uri), + size, + mediaConstraints.getImageMaxSize(context), + 70); + if (result != null) { + break; + } + } } catch (BitmapDecodingException e) { - throw new IOException(e); + throw new UndeliverableMessageException(e); } + + if (result == null) { + throw new UndeliverableMessageException("Somehow couldn't meet the constraints!"); + } + + return new MediaStream(new ByteArrayInputStream(result.getData()), + result.getMimeType(), + result.getWidth(), + result.getHeight()); } public static final class Factory implements Job.Factory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java index 8c22a1b0db..5810a18411 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java @@ -33,6 +33,13 @@ public abstract class MediaConstraints { public abstract int getImageMaxHeight(Context context); public abstract int getImageMaxSize(Context context); + /** + * Provide a list of dimensions that should be attempted during compression. We will keep moving + * down the list until the image can be scaled to fit under {@link #getImageMaxSize(Context)}. + * The first entry in the list should match your max width/height. + */ + public abstract int[] getImageDimensionTargets(Context context); + public abstract int getGifMaxSize(Context context); public abstract int getVideoMaxSize(Context context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsMediaConstraints.java index 892c610b0e..7b3b3bb08e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsMediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsMediaConstraints.java @@ -24,6 +24,19 @@ final class MmsMediaConstraints extends MediaConstraints { return Math.max(MIN_IMAGE_DIMEN, getOverriddenMmsConfig(context).getMaxImageHeight()); } + @Override + public int[] getImageDimensionTargets(Context context) { + int[] targets = new int[4]; + + targets[0] = getImageMaxHeight(context); + + for (int i = 1; i < targets.length; i++) { + targets[i] = targets[i - 1] / 2; + } + + return targets; + } + @Override public int getImageMaxSize(Context context) { return getMaxMessageSize(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java index b87a4d463f..41c167c28e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java @@ -7,10 +7,13 @@ import org.thoughtcrime.securesms.util.Util; public class PushMediaConstraints extends MediaConstraints { private static final int MAX_IMAGE_DIMEN_LOWMEM = 768; - private static final int MAX_IMAGE_DIMEN = 4096; + private static final int MAX_IMAGE_DIMEN = 1599; private static final int KB = 1024; private static final int MB = 1024 * KB; + private static final int[] FALLBACKS = { MAX_IMAGE_DIMEN, 1024, 768, 512 }; + private static final int[] FALLBACKS_LOWMEM = { MAX_IMAGE_DIMEN_LOWMEM, 512 }; + @Override public int getImageMaxWidth(Context context) { return Util.isLowMemory(context) ? MAX_IMAGE_DIMEN_LOWMEM : MAX_IMAGE_DIMEN; @@ -23,7 +26,13 @@ public class PushMediaConstraints extends MediaConstraints { @Override public int getImageMaxSize(Context context) { - return 6 * MB; + //noinspection PointlessArithmeticExpression + return 1 * MB; + } + + @Override + public int[] getImageDimensionTargets(Context context) { + return Util.isLowMemory(context) ? FALLBACKS_LOWMEM : FALLBACKS; } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java index c456d80eeb..c0a0fd9eb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileMediaConstraints.java @@ -21,6 +21,11 @@ public class ProfileMediaConstraints extends MediaConstraints { return 5 * 1024 * 1024; } + @Override + public int[] getImageDimensionTargets(Context context) { + return new int[] { getImageMaxWidth(context) }; + } + @Override public int getGifMaxSize(Context context) { return 0; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java index fba9971be7..196b9f553a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java @@ -48,6 +48,11 @@ public class BitmapUtil { private static final int MIN_COMPRESSION_QUALITY_DECREASE = 5; private static final int MAX_IMAGE_HALF_SCALES = 3; + /** + * @deprecated You probably want to use {@link ImageCompressionUtil} instead, which has a clearer + * contract and handles mimetypes properly. + */ + @Deprecated @WorkerThread public static ScaleResult createScaledBytes(@NonNull Context context, @NonNull T model, @NonNull MediaConstraints constraints) throws BitmapDecodingException @@ -58,6 +63,10 @@ public class BitmapUtil { constraints.getImageMaxSize(context)); } + /** + * @deprecated You probably want to use {@link ImageCompressionUtil} instead, which has a clearer + * contract and handles mimetypes properly. + */ @WorkerThread public static ScaleResult createScaledBytes(@NonNull Context context, @NonNull T model, @@ -69,6 +78,10 @@ public class BitmapUtil { return createScaledBytes(context, model, maxImageWidth, maxImageHeight, maxImageSize, CompressFormat.JPEG); } + /** + * @deprecated You probably want to use {@link ImageCompressionUtil} instead, which has a clearer + * contract and handles mimetypes properly. + */ @WorkerThread public static ScaleResult createScaledBytes(Context context, T model, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java new file mode 100644 index 0000000000..9e92fab024 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ImageCompressionUtil.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.Bitmap; + +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.mms.GlideApp; + +import java.io.ByteArrayOutputStream; +import java.util.concurrent.ExecutionException; + +public final class ImageCompressionUtil { + + private ImageCompressionUtil () {} + + /** + * A result satisfying the provided constraints, or null if they could not be met. + */ + @WorkerThread + public static @Nullable Result compressWithinConstraints(@NonNull Context context, + @NonNull String mimeType, + @NonNull Object glideModel, + int maxDimension, + int maxBytes, + @IntRange(from = 0, to = 100) int quality) + throws BitmapDecodingException + { + Result result = compress(context, mimeType, glideModel, maxDimension, quality); + + if (result.getData().length <= maxBytes) { + return result; + } else { + return null; + } + } + + /** + * Compresses the image to match the requested parameters. + */ + @WorkerThread + public static @NonNull Result compress(@NonNull Context context, + @NonNull String mimeType, + @NonNull Object glideModel, + int maxDimension, + @IntRange(from = 0, to = 100) int quality) + throws BitmapDecodingException + { + Bitmap scaledBitmap; + + try { + scaledBitmap = GlideApp.with(context.getApplicationContext()) + .asBitmap() + .load(glideModel) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerInside() + .submit(maxDimension, maxDimension) + .get(); + } catch (ExecutionException | InterruptedException e) { + throw new BitmapDecodingException(e); + } + + if (scaledBitmap == null) { + throw new BitmapDecodingException("Unable to decode image"); + } + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Bitmap.CompressFormat format = mimeTypeToCompressFormat(mimeType); + scaledBitmap.compress(format, quality, output); + + byte[] data = output.toByteArray(); + + return new Result(data, compressFormatToMimeType(format), scaledBitmap.getWidth(), scaledBitmap.getHeight()); + } + + private static @NonNull Bitmap.CompressFormat mimeTypeToCompressFormat(@NonNull String mimeType) { + if (MediaUtil.isJpegType(mimeType) || MediaUtil.isHeicType(mimeType) || MediaUtil.isHeifType(mimeType)) { + return Bitmap.CompressFormat.JPEG; + } else { + return Bitmap.CompressFormat.PNG; + } + } + + private static @NonNull String compressFormatToMimeType(@NonNull Bitmap.CompressFormat format) { + switch (format) { + case JPEG: + return MediaUtil.IMAGE_JPEG; + case PNG: + return MediaUtil.IMAGE_PNG; + default: + throw new AssertionError("Unsupported format!"); + } + } + + public static final class Result { + private final byte[] data; + private final String mimeType; + private final int height; + private final int width; + + public Result(@NonNull byte[] data, @NonNull String mimeType, int width, int height) { + this.data = data; + this.mimeType = mimeType; + this.width = width; + this.height = height; + } + + public byte[] getData() { + return data; + } + + public @NonNull String getMimeType() { + return mimeType; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + } +}