diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/Downsampler.java b/app/src/main/java/org/thoughtcrime/securesms/glide/Downsampler.java new file mode 100644 index 0000000000..873de754f7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/Downsampler.java @@ -0,0 +1,1015 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.glide; + +import android.annotation.TargetApi; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.ColorSpace; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import android.util.DisplayMetrics; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; + +import com.bumptech.glide.load.DecodeFormat; +import com.bumptech.glide.load.ImageHeaderParser; +import com.bumptech.glide.load.ImageHeaderParser.ImageType; +import com.bumptech.glide.load.Option; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.PreferredColorSpace; +import com.bumptech.glide.load.data.ParcelFileDescriptorRewinder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import com.bumptech.glide.load.resource.bitmap.BitmapResource; +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy; +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy.SampleSizeRounding; +import com.bumptech.glide.load.resource.bitmap.TransformationUtils; +import com.bumptech.glide.request.RequestOptions; +import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.util.LogTime; +import com.bumptech.glide.util.Preconditions; +import com.bumptech.glide.util.Util; + +import org.signal.core.util.logging.Log; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Queue; +import java.util.Set; + +/** + * Downsamples, decodes, and rotates images according to their exif orientation using {@link + * BitmapFactory}. + */ +public final class Downsampler { + static final String TAG = "Downsampler"; + private static final boolean enableVerboseLogging = false; + + /** + * Indicates the {@link com.bumptech.glide.load.DecodeFormat} that will be used in conjunction + * with the image format to determine the {@link android.graphics.Bitmap.Config} to provide to + * {@link android.graphics.BitmapFactory.Options#inPreferredConfig} when decoding the image. + */ + public static final Option DECODE_FORMAT = + Option.memory( + "com.bumptech.glide.load.resource.bitmap.Downsampler.DecodeFormat", DecodeFormat.DEFAULT); + + /** + * Sets the {@link PreferredColorSpace} that will be used along with the version of Android and + * color space of the requested image to determine the final color space used to decode the image. + * + *

Refer to {@link PreferredColorSpace} for details on how this option works and its various + * limitations. + */ + public static final Option PREFERRED_COLOR_SPACE = + Option.memory("com.bumptech.glide.load.resource.bitmap.Downsampler.PreferredColorSpace"); + /** + * Indicates the {@link com.bumptech.glide.load.resource.bitmap.DownsampleStrategy} option that + * will be used to calculate the sample size to use to downsample an image given the original and + * target dimensions of the image. + * + * @deprecated Use {@link DownsampleStrategy#OPTION} directly instead. + */ + @Deprecated + public static final Option DOWNSAMPLE_STRATEGY = DownsampleStrategy.OPTION; + /** + * Ensure that the size of the bitmap is fixed to the requested width and height of the resource + * from the caller. The final resource dimensions may differ from the requested width and height, + * and thus setting this to true may result in the bitmap size differing from the resource + * dimensions. + * + *

This can be used as a performance optimization for KitKat and above by fixing the size of + * the bitmap for a collection of requested resources so that the bitmap pool will not need to + * allocate new bitmaps for images of different sizes. + */ + // Public API + @SuppressWarnings("WeakerAccess") + public static final Option FIX_BITMAP_SIZE_TO_REQUESTED_DIMENSIONS = + Option.memory("com.bumptech.glide.load.resource.bitmap.Downsampler.FixBitmapSize", false); + + /** + * Indicates that it's safe or unsafe to decode {@link Bitmap}s with {@link + * Bitmap.Config#HARDWARE}. + * + *

Callers should almost never set this value to {@code true} manually. Glide will already do + * so when Glide believes it's safe to do (when no transformations are applied). Instead, callers + * can set this value to {@code false} to prevent Glide from decoding hardware bitmaps if Glide is + * unable to detect that hardware bitmaps are unsafe. For example, you should set this to {@code + * false} if you plan to draw it to a software {@link android.graphics.Canvas} or if you plan to + * inspect the {@link Bitmap}s pixels with {@link Bitmap#getPixel(int, int)} or {@link + * Bitmap#getPixels(int[], int, int, int, int, int, int)}. + * + *

Callers can disable hardware {@link Bitmap}s for all loads using {@link + * com.bumptech.glide.GlideBuilder#setDefaultRequestOptions(RequestOptions)}. + * + *

This option is ignored unless we're on Android O+. + */ + public static final Option ALLOW_HARDWARE_CONFIG = + Option.memory( + "com.bumptech.glide.load.resource.bitmap.Downsampler.AllowHardwareDecode", false); + + private static final String WBMP_MIME_TYPE = "image/vnd.wap.wbmp"; + private static final String ICO_MIME_TYPE = "image/x-ico"; + private static final Set NO_DOWNSAMPLE_PRE_N_MIME_TYPES = + Collections.unmodifiableSet(new HashSet<>(Arrays.asList(WBMP_MIME_TYPE, ICO_MIME_TYPE))); + private static final DecodeCallbacks EMPTY_CALLBACKS = + new DecodeCallbacks() { + @Override + public void onObtainBounds() { + // Do nothing. + } + + @Override + public void onDecodeComplete(BitmapPool bitmapPool, Bitmap downsampled) { + // Do nothing. + } + }; + private static final Set TYPES_THAT_USE_POOL_PRE_KITKAT = + Collections.unmodifiableSet( + EnumSet.of( + ImageType.JPEG, + ImageType.PNG_A, + ImageType.PNG)); + private static final Queue OPTIONS_QUEUE = Util.createQueue(0); + + private final BitmapPool bitmapPool; + private final DisplayMetrics displayMetrics; + private final ArrayPool byteArrayPool; + private final List parsers; + private final HardwareConfigState hardwareConfigState = HardwareConfigState.getInstance(); + + public Downsampler( + List parsers, + DisplayMetrics displayMetrics, + BitmapPool bitmapPool, + ArrayPool byteArrayPool) + { + this.parsers = parsers; + this.displayMetrics = Preconditions.checkNotNull(displayMetrics); + this.bitmapPool = Preconditions.checkNotNull(bitmapPool); + this.byteArrayPool = Preconditions.checkNotNull(byteArrayPool); + } + + public boolean handles(@SuppressWarnings("unused") InputStream is) { + // We expect Downsampler to handle any available type Android supports. + return true; + } + + public boolean handles(@SuppressWarnings("unused") ByteBuffer byteBuffer) { + // We expect downsampler to handle any available type Android supports. + return true; + } + + public boolean handles(@SuppressWarnings("unused") ParcelFileDescriptor source) { + return ParcelFileDescriptorRewinder.isSupported(); + } + + /** + * Returns a Bitmap decoded from the given {@link InputStream} that is rotated to match any EXIF + * data present in the stream and that is downsampled according to the given dimensions and any + * provided {@link com.bumptech.glide.load.resource.bitmap.DownsampleStrategy} option. + * + * @see #decode(InputStream, int, int, Options, DecodeCallbacks) + */ + public Resource decode(InputStream is, int outWidth, int outHeight, Options options) + throws IOException + { + return decode(is, outWidth, outHeight, options, EMPTY_CALLBACKS); + } + + /** + * Identical to {@link #decode(InputStream, int, int, Options)}, except that it accepts a {@link + * ByteBuffer} in place of an {@link InputStream}. + */ + public Resource decode( + ByteBuffer buffer, int requestedWidth, int requestedHeight, Options options) + throws IOException + { + return decode( + new ImageReader.ByteBufferReader(buffer, parsers, byteArrayPool), + requestedWidth, + requestedHeight, + options, + EMPTY_CALLBACKS); + } + + /** + * Returns a Bitmap decoded from the given {@link InputStream} that is rotated to match any EXIF + * data present in the stream and that is downsampled according to the given dimensions and any + * provided {@link com.bumptech.glide.load.resource.bitmap.DownsampleStrategy} option. + * + *

If a Bitmap is present in the {@link + * com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool} whose dimensions exactly match those + * of the image for the given InputStream is available, the operation is much less expensive in + * terms of memory. + * + * @param is An {@link InputStream} to the data for the image. + * @param requestedWidth The width the final image should be close to. + * @param requestedHeight The height the final image should be close to. + * @param options A set of options that may contain one or more supported options that influence + * how a Bitmap will be decoded from the given stream. + * @param callbacks A set of callbacks allowing callers to optionally respond to various + * significant events during the decode process. + * @return A new bitmap containing the image from the given InputStream, or recycle if recycle is + * not null. + */ + public Resource decode( + InputStream is, + int requestedWidth, + int requestedHeight, + Options options, + DecodeCallbacks callbacks) + throws IOException + { + return decode( + new ImageReader.InputStreamImageReader(is, parsers, byteArrayPool), + requestedWidth, + requestedHeight, + options, + callbacks); + } + + @VisibleForTesting + void decode(byte[] bytes, int requestedWidth, int requestedHeight, Options options) + throws IOException + { + decode( + new ImageReader.ByteArrayReader(bytes, parsers, byteArrayPool), + requestedWidth, + requestedHeight, + options, + EMPTY_CALLBACKS); + } + + @VisibleForTesting + void decode(File file, int requestedWidth, int requestedHeight, Options options) + throws IOException + { + decode( + new ImageReader.FileReader(file, parsers, byteArrayPool), + requestedWidth, + requestedHeight, + options, + EMPTY_CALLBACKS); + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + public Resource decode( + ParcelFileDescriptor parcelFileDescriptor, int outWidth, int outHeight, Options options) + throws IOException + { + return decode( + new ImageReader.ParcelFileDescriptorImageReader( + parcelFileDescriptor, parsers, byteArrayPool), + outWidth, + outHeight, + options, + EMPTY_CALLBACKS); + } + + private Resource decode( + ImageReader imageReader, + int requestedWidth, + int requestedHeight, + Options options, + DecodeCallbacks callbacks) + throws IOException + { + byte[] bytesForOptions = byteArrayPool.get(ArrayPool.STANDARD_BUFFER_SIZE_BYTES, byte[].class); + BitmapFactory.Options bitmapFactoryOptions = getDefaultOptions(); + bitmapFactoryOptions.inTempStorage = bytesForOptions; + + DecodeFormat decodeFormat = options.get(DECODE_FORMAT); + PreferredColorSpace preferredColorSpace = options.get(PREFERRED_COLOR_SPACE); + DownsampleStrategy downsampleStrategy = options.get(DownsampleStrategy.OPTION); + boolean fixBitmapToRequestedDimensions = options.get(FIX_BITMAP_SIZE_TO_REQUESTED_DIMENSIONS); + boolean isHardwareConfigAllowed = + options.get(ALLOW_HARDWARE_CONFIG) != null && options.get(ALLOW_HARDWARE_CONFIG); + + try { + Bitmap result = + decodeFromWrappedStreams( + imageReader, + bitmapFactoryOptions, + downsampleStrategy, + decodeFormat, + preferredColorSpace, + isHardwareConfigAllowed, + requestedWidth, + requestedHeight, + fixBitmapToRequestedDimensions, + callbacks); + return BitmapResource.obtain(result, bitmapPool); + } finally { + releaseOptions(bitmapFactoryOptions); + byteArrayPool.put(bytesForOptions); + } + } + + private Bitmap decodeFromWrappedStreams( + ImageReader imageReader, + BitmapFactory.Options options, + DownsampleStrategy downsampleStrategy, + DecodeFormat decodeFormat, + PreferredColorSpace preferredColorSpace, + boolean isHardwareConfigAllowed, + int requestedWidth, + int requestedHeight, + boolean fixBitmapToRequestedDimensions, + DecodeCallbacks callbacks) + throws IOException + { + long startTime = LogTime.getLogTime(); + + int[] sourceDimensions = getDimensions(imageReader, options, callbacks, bitmapPool); + int sourceWidth = sourceDimensions[0]; + int sourceHeight = sourceDimensions[1]; + String sourceMimeType = options.outMimeType; + + // If we failed to obtain the image dimensions, we may end up with an incorrectly sized Bitmap, + // so we want to use a mutable Bitmap type. One way this can happen is if the image header is so + // large (10mb+) that our attempt to use inJustDecodeBounds fails and we're forced to decode the + // full size image. + if (sourceWidth == -1 || sourceHeight == -1) { + isHardwareConfigAllowed = false; + } + + int orientation = imageReader.getImageOrientation(); + int degreesToRotate = TransformationUtils.getExifOrientationDegrees(orientation); + boolean isExifOrientationRequired = TransformationUtils.isExifOrientationRequired(orientation); + + int targetWidth = + requestedWidth == Target.SIZE_ORIGINAL + ? (isRotationRequired(degreesToRotate) ? sourceHeight : sourceWidth) + : requestedWidth; + int targetHeight = + requestedHeight == Target.SIZE_ORIGINAL + ? (isRotationRequired(degreesToRotate) ? sourceWidth : sourceHeight) + : requestedHeight; + + ImageType imageType = imageReader.getImageType(); + + calculateScaling( + imageType, + imageReader, + callbacks, + bitmapPool, + downsampleStrategy, + degreesToRotate, + sourceWidth, + sourceHeight, + targetWidth, + targetHeight, + options); + calculateConfig( + imageReader, + decodeFormat, + isHardwareConfigAllowed, + isExifOrientationRequired, + options, + targetWidth, + targetHeight); + + boolean isKitKatOrGreater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + // Prior to KitKat, the inBitmap size must exactly match the size of the bitmap we're decoding. + if ((options.inSampleSize == 1 || isKitKatOrGreater) && shouldUsePool(imageType)) { + int expectedWidth; + int expectedHeight; + if (sourceWidth >= 0 + && sourceHeight >= 0 + && fixBitmapToRequestedDimensions + && isKitKatOrGreater) + { + expectedWidth = targetWidth; + expectedHeight = targetHeight; + } else { + float densityMultiplier = + isScaling(options) ? (float) options.inTargetDensity / options.inDensity : 1f; + int sampleSize = options.inSampleSize; + int downsampledWidth = (int) Math.ceil(sourceWidth / (float) sampleSize); + int downsampledHeight = (int) Math.ceil(sourceHeight / (float) sampleSize); + expectedWidth = Math.round(downsampledWidth * densityMultiplier); + expectedHeight = Math.round(downsampledHeight * densityMultiplier); + + if (enableVerboseLogging) { + Log.v( + TAG, + "Calculated target [" + + expectedWidth + + "x" + + expectedHeight + + "] for source" + + " [" + + sourceWidth + + "x" + + sourceHeight + + "]" + + ", sampleSize: " + + sampleSize + + ", targetDensity: " + + options.inTargetDensity + + ", density: " + + options.inDensity + + ", density multiplier: " + + densityMultiplier); + } + } + // If this isn't an image, or BitmapFactory was unable to parse the size, width and height + // will be -1 here. + if (expectedWidth > 0 && expectedHeight > 0) { + setInBitmap(options, bitmapPool, expectedWidth, expectedHeight); + } + } + + if (preferredColorSpace != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + boolean isP3Eligible = + preferredColorSpace == PreferredColorSpace.DISPLAY_P3 + && options.outColorSpace != null + && options.outColorSpace.isWideGamut(); + options.inPreferredColorSpace = + ColorSpace.get(isP3Eligible ? ColorSpace.Named.DISPLAY_P3 : ColorSpace.Named.SRGB); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + options.inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB); + } + } + + Bitmap downsampled = decodeStream(imageReader, options, callbacks, bitmapPool); + callbacks.onDecodeComplete(bitmapPool, downsampled); + + if (enableVerboseLogging) { + logDecode( + sourceWidth, + sourceHeight, + sourceMimeType, + options, + downsampled, + requestedWidth, + requestedHeight, + startTime); + } + + Bitmap rotated = null; + if (downsampled != null) { + // If we scaled, the Bitmap density will be our inTargetDensity. Here we correct it back to + // the expected density dpi. + downsampled.setDensity(displayMetrics.densityDpi); + + rotated = TransformationUtils.rotateImageExif(bitmapPool, downsampled, orientation); + if (!downsampled.equals(rotated)) { + bitmapPool.put(downsampled); + } + } + + return rotated; + } + + private static void calculateScaling( + ImageType imageType, + ImageReader imageReader, + DecodeCallbacks decodeCallbacks, + BitmapPool bitmapPool, + DownsampleStrategy downsampleStrategy, + int degreesToRotate, + int sourceWidth, + int sourceHeight, + int targetWidth, + int targetHeight, + BitmapFactory.Options options) + throws IOException + { + // We can't downsample source content if we can't determine its dimensions. + if (sourceWidth <= 0 || sourceHeight <= 0) { + if (enableVerboseLogging) { + Log.d( + TAG, + "Unable to determine dimensions for: " + + imageType + + " with target [" + + targetWidth + + "x" + + targetHeight + + "]"); + } + return; + } + + int orientedSourceWidth = sourceWidth; + int orientedSourceHeight = sourceHeight; + // If we're rotating the image +-90 degrees, we need to downsample accordingly so the image + // width is decreased to near our target's height and the image height is decreased to near + // our target width. + //noinspection SuspiciousNameCombination + if (isRotationRequired(degreesToRotate)) { + orientedSourceWidth = sourceHeight; + orientedSourceHeight = sourceWidth; + } + + final float exactScaleFactor = + downsampleStrategy.getScaleFactor( + orientedSourceWidth, orientedSourceHeight, targetWidth, targetHeight); + + if (exactScaleFactor <= 0f) { + throw new IllegalArgumentException( + "Cannot scale with factor: " + + exactScaleFactor + + " from: " + + downsampleStrategy + + ", source: [" + + sourceWidth + + "x" + + sourceHeight + + "]" + + ", target: [" + + targetWidth + + "x" + + targetHeight + + "]"); + } + + SampleSizeRounding rounding = + downsampleStrategy.getSampleSizeRounding( + orientedSourceWidth, orientedSourceHeight, targetWidth, targetHeight); + if (rounding == null) { + throw new IllegalArgumentException("Cannot round with null rounding"); + } + + int outWidth = round(exactScaleFactor * orientedSourceWidth); + int outHeight = round(exactScaleFactor * orientedSourceHeight); + + int widthScaleFactor = orientedSourceWidth / outWidth; + int heightScaleFactor = orientedSourceHeight / outHeight; + + // TODO: This isn't really right for both CenterOutside and CenterInside. Consider allowing + // DownsampleStrategy to pick, or trying to do something more sophisticated like picking the + // scale factor that leads to an exact match. + int scaleFactor = + rounding == SampleSizeRounding.MEMORY + ? Math.max(widthScaleFactor, heightScaleFactor) + : Math.min(widthScaleFactor, heightScaleFactor); + + int powerOfTwoSampleSize; + // BitmapFactory does not support downsampling wbmp files on platforms <= M. See b/27305903. + if (Build.VERSION.SDK_INT <= 23 + && NO_DOWNSAMPLE_PRE_N_MIME_TYPES.contains(options.outMimeType)) + { + powerOfTwoSampleSize = 1; + } else { + powerOfTwoSampleSize = Math.max(1, Integer.highestOneBit(scaleFactor)); + if (rounding == SampleSizeRounding.MEMORY + && powerOfTwoSampleSize < (1.f / exactScaleFactor)) + { + powerOfTwoSampleSize = powerOfTwoSampleSize << 1; + } + } + + // Here we mimic framework logic for determining how inSampleSize division is rounded on various + // versions of Android. The logic here has been tested on emulators for Android versions 15-26. + // PNG - Always uses floor + // JPEG - Always uses ceiling + // Webp - Prior to N, always uses floor. At and after N, always uses round. + options.inSampleSize = powerOfTwoSampleSize; + int powerOfTwoWidth; + int powerOfTwoHeight; + if (imageType == ImageType.JPEG) { + // libjpegturbo can downsample up to a sample size of 8. libjpegturbo uses ceiling to round. + // After libjpegturbo's native rounding, skia does a secondary scale using floor + // (integer division). Here we replicate that logic. + int nativeScaling = Math.min(powerOfTwoSampleSize, 8); + powerOfTwoWidth = (int) Math.ceil(orientedSourceWidth / (float) nativeScaling); + powerOfTwoHeight = (int) Math.ceil(orientedSourceHeight / (float) nativeScaling); + int secondaryScaling = powerOfTwoSampleSize / 8; + if (secondaryScaling > 0) { + powerOfTwoWidth = powerOfTwoWidth / secondaryScaling; + powerOfTwoHeight = powerOfTwoHeight / secondaryScaling; + } + } else if (imageType == ImageType.PNG || imageType == ImageType.PNG_A) { + powerOfTwoWidth = (int) Math.floor(orientedSourceWidth / (float) powerOfTwoSampleSize); + powerOfTwoHeight = (int) Math.floor(orientedSourceHeight / (float) powerOfTwoSampleSize); + } else if (imageType.isWebp()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + powerOfTwoWidth = Math.round(orientedSourceWidth / (float) powerOfTwoSampleSize); + powerOfTwoHeight = Math.round(orientedSourceHeight / (float) powerOfTwoSampleSize); + } else { + powerOfTwoWidth = (int) Math.floor(orientedSourceWidth / (float) powerOfTwoSampleSize); + powerOfTwoHeight = (int) Math.floor(orientedSourceHeight / (float) powerOfTwoSampleSize); + } + } else if (orientedSourceWidth % powerOfTwoSampleSize != 0 + || orientedSourceHeight % powerOfTwoSampleSize != 0) + { + // If we're not confident the image is in one of our types, fall back to checking the + // dimensions again. inJustDecodeBounds decodes do obey inSampleSize. + int[] dimensions = getDimensions(imageReader, options, decodeCallbacks, bitmapPool); + // Power of two downsampling in BitmapFactory uses a variety of random factors to determine + // rounding that we can't reliably replicate for all image formats. Use ceiling here to make + // sure that we at least provide a Bitmap that's large enough to fit the content we're going + // to load. + powerOfTwoWidth = dimensions[0]; + powerOfTwoHeight = dimensions[1]; + } else { + powerOfTwoWidth = orientedSourceWidth / powerOfTwoSampleSize; + powerOfTwoHeight = orientedSourceHeight / powerOfTwoSampleSize; + } + + double adjustedScaleFactor = + downsampleStrategy.getScaleFactor( + powerOfTwoWidth, powerOfTwoHeight, targetWidth, targetHeight); + + // Density scaling is only supported if inBitmap is null prior to KitKat. Avoid setting + // densities here so we calculate the final Bitmap size correctly. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + options.inTargetDensity = adjustTargetDensityForError(adjustedScaleFactor); + options.inDensity = getDensityMultiplier(adjustedScaleFactor); + } + if (isScaling(options)) { + options.inScaled = true; + } else { + options.inDensity = options.inTargetDensity = 0; + } + + if (enableVerboseLogging) { + Log.v( + TAG, + "Calculate scaling" + + ", source: [" + + sourceWidth + + "x" + + sourceHeight + + "]" + + ", degreesToRotate: " + + degreesToRotate + + ", target: [" + + targetWidth + + "x" + + targetHeight + + "]" + + ", power of two scaled: [" + + powerOfTwoWidth + + "x" + + powerOfTwoHeight + + "]" + + ", exact scale factor: " + + exactScaleFactor + + ", power of 2 sample size: " + + powerOfTwoSampleSize + + ", adjusted scale factor: " + + adjustedScaleFactor + + ", target density: " + + options.inTargetDensity + + ", density: " + + options.inDensity); + } + } + + /** + * BitmapFactory calculates the density scale factor as a float. This introduces some non-trivial + * error. This method attempts to account for that error by adjusting the inTargetDensity so that + * the final scale factor is as close to our target as possible. + */ + private static int adjustTargetDensityForError(double adjustedScaleFactor) { + int densityMultiplier = getDensityMultiplier(adjustedScaleFactor); + int targetDensity = round(densityMultiplier * adjustedScaleFactor); + float scaleFactorWithError = targetDensity / (float) densityMultiplier; + double difference = adjustedScaleFactor / scaleFactorWithError; + return round(difference * targetDensity); + } + + private static int getDensityMultiplier(double adjustedScaleFactor) { + return (int) + Math.round( + Integer.MAX_VALUE + * (adjustedScaleFactor <= 1D ? adjustedScaleFactor : 1 / adjustedScaleFactor)); + } + + // This is weird, but it matches the logic in a bunch of Android views/framework classes for + // rounding. + private static int round(double value) { + return (int) (value + 0.5d); + } + + private boolean shouldUsePool(ImageType imageType) { + // On KitKat+, any bitmap (of a given config) can be used to decode any other bitmap + // (with the same config). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return true; + } + + // We cannot reuse bitmaps when decoding images that are not PNG or JPG prior to KitKat. + // See: https://groups.google.com/forum/#!msg/android-developers/Mp0MFVFi1Fo/e8ZQ9FGdWdEJ + return TYPES_THAT_USE_POOL_PRE_KITKAT.contains(imageType); + } + + @SuppressWarnings("deprecation") + private void calculateConfig( + ImageReader imageReader, + DecodeFormat format, + boolean isHardwareConfigAllowed, + boolean isExifOrientationRequired, + BitmapFactory.Options optionsWithScaling, + int targetWidth, + int targetHeight) + { + + if (hardwareConfigState.setHardwareConfigIfAllowed( + targetWidth, + targetHeight, + optionsWithScaling, + isHardwareConfigAllowed, + isExifOrientationRequired)) + { + return; + } + + // Changing configs can cause skewing on 4.1, see issue #128. + if (format == DecodeFormat.PREFER_ARGB_8888 + || Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN) + { + optionsWithScaling.inPreferredConfig = Bitmap.Config.ARGB_8888; + return; + } + + boolean hasAlpha = false; + try { + hasAlpha = imageReader.getImageType().hasAlpha(); + } catch (IOException e) { + if (enableVerboseLogging) { + Log.d( + TAG, + "Cannot determine whether the image has alpha or not from header" + + ", format " + + format, + e); + } + } + + optionsWithScaling.inPreferredConfig = + hasAlpha ? Bitmap.Config.ARGB_8888 : Bitmap.Config.RGB_565; + if (optionsWithScaling.inPreferredConfig == Config.RGB_565) { + optionsWithScaling.inDither = true; + } + } + + /** + * A method for getting the dimensions of an image from the given InputStream. + * + * @param imageReader The {@link ImageReader} representing the image. + * @param options The options to pass to {@link BitmapFactory#decodeStream(java.io.InputStream, + * android.graphics.Rect, android.graphics.BitmapFactory.Options)}. + * @return an array containing the dimensions of the image in the form {width, height}. + */ + private static int[] getDimensions( + ImageReader imageReader, + BitmapFactory.Options options, + DecodeCallbacks decodeCallbacks, + BitmapPool bitmapPool) + throws IOException + { + options.inJustDecodeBounds = true; + decodeStream(imageReader, options, decodeCallbacks, bitmapPool); + options.inJustDecodeBounds = false; + return new int[] { options.outWidth, options.outHeight }; + } + + private static Bitmap decodeStream( + ImageReader imageReader, + BitmapFactory.Options options, + DecodeCallbacks callbacks, + BitmapPool bitmapPool) + throws IOException + { + if (!options.inJustDecodeBounds) { + // Once we've read the image header, we no longer need to allow the buffer to expand in + // size. To avoid unnecessary allocations reading image data, we fix the mark limit so that it + // is no larger than our current buffer size here. We need to do so immediately before + // decoding the full image to avoid having our mark limit overridden by other calls to + // mark and reset. See issue #225. + callbacks.onObtainBounds(); + imageReader.stopGrowingBuffers(); + } + + // BitmapFactory.Options out* variables are reset by most calls to decodeStream, successful or + // otherwise, so capture here in case we log below. + int sourceWidth = options.outWidth; + int sourceHeight = options.outHeight; + String outMimeType = options.outMimeType; + final Bitmap result; + TransformationUtils.getBitmapDrawableLock().lock(); + try { + result = imageReader.decodeBitmap(options); + } catch (IllegalArgumentException e) { + IOException bitmapAssertionException = + newIoExceptionForInBitmapAssertion(e, sourceWidth, sourceHeight, outMimeType, options); + if (enableVerboseLogging) { + Log.d( + TAG, + "Failed to decode with inBitmap, trying again without Bitmap re-use", + bitmapAssertionException); + } + if (options.inBitmap != null) { + try { + bitmapPool.put(options.inBitmap); + options.inBitmap = null; + return decodeStream(imageReader, options, callbacks, bitmapPool); + } catch (IOException resetException) { + throw bitmapAssertionException; + } + } + throw bitmapAssertionException; + } finally { + TransformationUtils.getBitmapDrawableLock().unlock(); + } + + return result; + } + + private static boolean isScaling(BitmapFactory.Options options) { + return options.inTargetDensity > 0 + && options.inDensity > 0 + && options.inTargetDensity != options.inDensity; + } + + private static void logDecode( + int sourceWidth, + int sourceHeight, + String outMimeType, + BitmapFactory.Options options, + Bitmap result, + int requestedWidth, + int requestedHeight, + long startTime) + { + Log.v( + TAG, + "Decoded " + + getBitmapString(result) + + " from [" + + sourceWidth + + "x" + + sourceHeight + + "] " + + outMimeType + + " with inBitmap " + + getInBitmapString(options) + + " for [" + + requestedWidth + + "x" + + requestedHeight + + "]" + + ", sample size: " + + options.inSampleSize + + ", density: " + + options.inDensity + + ", target density: " + + options.inTargetDensity + + ", thread: " + + Thread.currentThread().getName() + + ", duration: " + + LogTime.getElapsedMillis(startTime)); + } + + private static String getInBitmapString(BitmapFactory.Options options) { + return getBitmapString(options.inBitmap); + } + + @Nullable + @TargetApi(Build.VERSION_CODES.KITKAT) + private static String getBitmapString(Bitmap bitmap) { + if (bitmap == null) { + return null; + } + + String sizeString = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT + ? " (" + bitmap.getAllocationByteCount() + ")" + : ""; + return "[" + + bitmap.getWidth() + + "x" + + bitmap.getHeight() + + "] " + + bitmap.getConfig() + + sizeString; + } + + // BitmapFactory throws an IllegalArgumentException if any error occurs attempting to decode a + // file when inBitmap is non-null, including those caused by partial or corrupt data. We still log + // the error because the IllegalArgumentException is supposed to catch errors reusing Bitmaps, so + // want some useful log output. In most cases this can be safely treated as a normal IOException. + private static IOException newIoExceptionForInBitmapAssertion( + IllegalArgumentException e, + int outWidth, + int outHeight, + String outMimeType, + BitmapFactory.Options options) + { + return new IOException( + "Exception decoding bitmap" + + ", outWidth: " + + outWidth + + ", outHeight: " + + outHeight + + ", outMimeType: " + + outMimeType + + ", inBitmap: " + + getInBitmapString(options), + e); + } + + @SuppressWarnings("PMD.CollapsibleIfStatements") + @TargetApi(Build.VERSION_CODES.O) + private static void setInBitmap( + BitmapFactory.Options options, BitmapPool bitmapPool, int width, int height) + { + @Nullable Bitmap.Config expectedConfig = null; + // Avoid short circuiting, it appears to break on some devices. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (options.inPreferredConfig == Config.HARDWARE) { + return; + } + // On API 26 outConfig may be null for some images even if the image is valid, can be decoded + // and outWidth/outHeight/outColorSpace are populated (see b/71513049). + expectedConfig = options.outConfig; + } + + if (expectedConfig == null) { + // We're going to guess that BitmapFactory will return us the config we're requesting. This + // isn't always the case, even though our guesses tend to be conservative and prefer configs + // of larger sizes so that the Bitmap will fit our image anyway. If we're wrong here and the + // config we choose is too small, our initial decode will fail, but we will retry with no + // inBitmap which will succeed so if we're wrong here, we're less efficient but still correct. + expectedConfig = options.inPreferredConfig; + } + // BitmapFactory will clear out the Bitmap before writing to it, so getDirty is safe. + options.inBitmap = bitmapPool.getDirty(width, height, expectedConfig); + } + + private static synchronized BitmapFactory.Options getDefaultOptions() { + BitmapFactory.Options decodeBitmapOptions; + synchronized (OPTIONS_QUEUE) { + decodeBitmapOptions = OPTIONS_QUEUE.poll(); + } + if (decodeBitmapOptions == null) { + decodeBitmapOptions = new BitmapFactory.Options(); + resetOptions(decodeBitmapOptions); + } + + return decodeBitmapOptions; + } + + private static void releaseOptions(BitmapFactory.Options decodeBitmapOptions) { + resetOptions(decodeBitmapOptions); + synchronized (OPTIONS_QUEUE) { + OPTIONS_QUEUE.offer(decodeBitmapOptions); + } + } + + @SuppressWarnings("deprecation") + private static void resetOptions(BitmapFactory.Options decodeBitmapOptions) { + decodeBitmapOptions.inTempStorage = null; + decodeBitmapOptions.inDither = false; + decodeBitmapOptions.inScaled = false; + decodeBitmapOptions.inSampleSize = 1; + decodeBitmapOptions.inPreferredConfig = null; + decodeBitmapOptions.inJustDecodeBounds = false; + decodeBitmapOptions.inDensity = 0; + decodeBitmapOptions.inTargetDensity = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + decodeBitmapOptions.inPreferredColorSpace = null; + decodeBitmapOptions.outColorSpace = null; + decodeBitmapOptions.outConfig = null; + } + decodeBitmapOptions.outWidth = 0; + decodeBitmapOptions.outHeight = 0; + decodeBitmapOptions.outMimeType = null; + decodeBitmapOptions.inBitmap = null; + decodeBitmapOptions.inMutable = true; + } + + /** + * Callbacks for key points during decodes. + */ + public interface DecodeCallbacks { + void onObtainBounds(); + + void onDecodeComplete(BitmapPool bitmapPool, Bitmap downsampled) throws IOException; + } + + private static boolean isRotationRequired(int degreesToRotate) { + return degreesToRotate == 90 || degreesToRotate == 270; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/HardwareConfigState.java b/app/src/main/java/org/thoughtcrime/securesms/glide/HardwareConfigState.java new file mode 100644 index 0000000000..35d98e82f6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/HardwareConfigState.java @@ -0,0 +1,338 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.glide; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Build; + +import androidx.annotation.GuardedBy; +import androidx.annotation.VisibleForTesting; + +import com.bumptech.glide.util.Util; + +import org.signal.core.util.logging.Log; + +import java.io.File; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * State and constants for interacting with {@link android.graphics.Bitmap.Config#HARDWARE} on Android O+. + */ +public final class HardwareConfigState { + private static final String TAG = "HardwareConfig"; + private static final boolean enableVerboseLogging = false; + + /** + * Force the state to wait until a call to allow hardware Bitmaps to be used when they'd otherwise + * be eligible to work around a framework issue pre Q that can cause a native crash when + * allocating a hardware Bitmap in this specific circumstance. See b/126573603#comment12 for + * details. + */ + public static final boolean BLOCK_HARDWARE_BITMAPS_WHEN_GL_CONTEXT_MIGHT_NOT_BE_INITIALIZED = + Build.VERSION.SDK_INT < Build.VERSION_CODES.Q; + + /** + * Support for the hardware bitmap config was added in Android O. + */ + public static final boolean HARDWARE_BITMAPS_SUPPORTED = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + + /** + * The minimum size in pixels a {@link Bitmap} must be in both dimensions to be created with the + * {@link Bitmap.Config#HARDWARE} configuration. + * + *

This is a quick check that lets us skip wasting FDs (see {@link #FD_SIZE_LIST}) on small + * {@link Bitmap}s with relatively low memory costs. + * + * @see #FD_SIZE_LIST + */ + @VisibleForTesting static final int MIN_HARDWARE_DIMENSION_O = 128; + + private static final int MIN_HARDWARE_DIMENSION_P = 0; + + /** + * Allows us to check to make sure we're not exceeding the FD limit for a process with hardware + * {@link Bitmap}s. + * + *

{@link Bitmap.Config#HARDWARE} {@link Bitmap}s require two FDs (depending on the driver). + * Processes have an FD limit of 1024 (at least on O). With sufficiently small {@link Bitmap}s + * and/or a sufficiently large {@link com.bumptech.glide.load.engine.cache.MemoryCache}, we can + * end up with enough {@link Bitmap}s in memory that we blow through the FD limit, which causes + * graphics errors, Binder errors, and a variety of crashes. + * + *

Calling list.size() should be relatively efficient (hopefully < 1ms on average) because + * /proc is an in-memory FS. + */ + private static final File FD_SIZE_LIST = new File("/proc/self/fd"); + + /** + * Each FD check takes 1-2ms, so to avoid overhead, only check every N decodes. 50 is more or less + * arbitrary. + */ + private static final int MINIMUM_DECODES_BETWEEN_FD_CHECKS = 50; + + /** + * 700 with an error of 50 Bitmaps in between at two FDs each lets us use up to 800 FDs for + * hardware Bitmaps. + * + *

Prior to P, the limit per process was 1024 FDs. In P, the limit was updated to 32k FDs per + * process. + * + *

Access to this variable will be removed in a future version without deprecation. + */ + private static final int MAXIMUM_FDS_FOR_HARDWARE_CONFIGS_O = 700; + // 20k. + private static final int MAXIMUM_FDS_FOR_HARDWARE_CONFIGS_P = 20000; + + /** + * This constant will be removed in a future version without deprecation, avoid using it. + */ + public static final int NO_MAX_FD_COUNT = -1; + + private static volatile HardwareConfigState instance; + + @SuppressWarnings("FieldMayBeFinal") + private static volatile int manualOverrideMaxFdCount = NO_MAX_FD_COUNT; + + private final boolean isHardwareConfigAllowedByDeviceModel; + private final int sdkBasedMaxFdCount; + private final int minHardwareDimension; + + @GuardedBy("this") + private int decodesSinceLastFdCheck; + + @GuardedBy("this") + private boolean isFdSizeBelowHardwareLimit = true; + + /** + * Only mutated on the main thread. Read by any number of background threads concurrently. + * + *

Defaults to {@code false} because we need to wait for the GL context to be initialized and + * it defaults to not initialized (https://b.corp.google.com/issues/126573603#comment12). + */ + private final AtomicBoolean isHardwareConfigAllowedByAppState = new AtomicBoolean(false); + + public static HardwareConfigState getInstance() { + if (instance == null) { + synchronized (HardwareConfigState.class) { + if (instance == null) { + instance = new HardwareConfigState(); + } + } + } + return instance; + } + + @VisibleForTesting HardwareConfigState() { + isHardwareConfigAllowedByDeviceModel = isHardwareConfigAllowedByDeviceModel(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + sdkBasedMaxFdCount = MAXIMUM_FDS_FOR_HARDWARE_CONFIGS_P; + minHardwareDimension = MIN_HARDWARE_DIMENSION_P; + } else { + sdkBasedMaxFdCount = MAXIMUM_FDS_FOR_HARDWARE_CONFIGS_O; + minHardwareDimension = MIN_HARDWARE_DIMENSION_O; + } + } + + public boolean areHardwareBitmapsBlocked() { + Util.assertMainThread(); + return !isHardwareConfigAllowedByAppState.get(); + } + + public void blockHardwareBitmaps() { + Util.assertMainThread(); + isHardwareConfigAllowedByAppState.set(false); + } + + public void unblockHardwareBitmaps() { + Util.assertMainThread(); + isHardwareConfigAllowedByAppState.set(true); + } + + public boolean isHardwareConfigAllowed( + int targetWidth, + int targetHeight, + boolean isHardwareConfigAllowed, + boolean isExifOrientationRequired) + { + if (!isHardwareConfigAllowed) { + if (enableVerboseLogging) { + Log.v(TAG, "Hardware config disallowed by caller"); + } + return false; + } + if (!isHardwareConfigAllowedByDeviceModel) { + if (enableVerboseLogging) { + Log.v(TAG, "Hardware config disallowed by device model"); + } + return false; + } + if (!HARDWARE_BITMAPS_SUPPORTED) { + if (enableVerboseLogging) { + Log.v(TAG, "Hardware config disallowed by sdk"); + } + return false; + } + if (areHardwareBitmapsBlockedByAppState()) { + if (enableVerboseLogging) { + Log.v(TAG, "Hardware config disallowed by app state"); + } + return false; + } + if (isExifOrientationRequired) { + if (enableVerboseLogging) { + Log.v(TAG, "Hardware config disallowed because exif orientation is required"); + } + return false; + } + if (targetWidth < minHardwareDimension) { + if (enableVerboseLogging) { + Log.v(TAG, "Hardware config disallowed because width is too small"); + } + return false; + } + if (targetHeight < minHardwareDimension) { + if (enableVerboseLogging) { + Log.v(TAG, "Hardware config disallowed because height is too small"); + } + return false; + } + // Make sure to call isFdSizeBelowHardwareLimit last because it has side affects. + if (!isFdSizeBelowHardwareLimit()) { + if (enableVerboseLogging) { + Log.v(TAG, "Hardware config disallowed because there are insufficient FDs"); + } + return false; + } + + return true; + } + + private boolean areHardwareBitmapsBlockedByAppState() { + return BLOCK_HARDWARE_BITMAPS_WHEN_GL_CONTEXT_MIGHT_NOT_BE_INITIALIZED + && !isHardwareConfigAllowedByAppState.get(); + } + + @TargetApi(Build.VERSION_CODES.O) + boolean setHardwareConfigIfAllowed( + int targetWidth, + int targetHeight, + BitmapFactory.Options optionsWithScaling, + boolean isHardwareConfigAllowed, + boolean isExifOrientationRequired) + { + boolean result = + isHardwareConfigAllowed( + targetWidth, targetHeight, isHardwareConfigAllowed, isExifOrientationRequired); + if (result) { + optionsWithScaling.inPreferredConfig = Bitmap.Config.HARDWARE; + optionsWithScaling.inMutable = false; + } + return result; + } + + private static boolean isHardwareConfigAllowedByDeviceModel() { + return !isHardwareConfigDisallowedByB112551574() && !isHardwareConfigDisallowedByB147430447(); + } + + private static boolean isHardwareConfigDisallowedByB147430447() { + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.O_MR1) { + return false; + } + // This method will only be called once, so simple iteration is reasonable. + return Arrays.asList( + "LG-M250", + "LG-M320", + "LG-Q710AL", + "LG-Q710PL", + "LGM-K121K", + "LGM-K121L", + "LGM-K121S", + "LGM-X320K", + "LGM-X320L", + "LGM-X320S", + "LGM-X401L", + "LGM-X401S", + "LM-Q610.FG", + "LM-Q610.FGN", + "LM-Q617.FG", + "LM-Q617.FGN", + "LM-Q710.FG", + "LM-Q710.FGN", + "LM-X220PM", + "LM-X220QMA", + "LM-X410PM") + .contains(Build.MODEL); + } + + private static boolean isHardwareConfigDisallowedByB112551574() { + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.O) { + return false; + } + // This method will only be called once, so simple iteration is reasonable. + for (String prefixOrModelName : + // This is sadly a list of prefixes, not models. We no longer have the data that shows us + // all the explicit models, so we have to live with the prefixes. + Arrays.asList( + // Samsung + "SC-04J", + "SM-N935", + "SM-J720", + "SM-G570F", + "SM-G570M", + "SM-G960", + "SM-G965", + "SM-G935", + "SM-G930", + "SM-A520", + "SM-A720F", + // Moto + "moto e5", + "moto e5 play", + "moto e5 plus", + "moto e5 cruise", + "moto g(6) forge", + "moto g(6) play")) { + if (Build.MODEL.startsWith(prefixOrModelName)) { + return true; + } + } + return false; + } + + private int getMaxFdCount() { + return manualOverrideMaxFdCount != NO_MAX_FD_COUNT + ? manualOverrideMaxFdCount + : sdkBasedMaxFdCount; + } + + @SuppressLint("LogTagInlined") + private synchronized boolean isFdSizeBelowHardwareLimit() { + if (++decodesSinceLastFdCheck >= MINIMUM_DECODES_BETWEEN_FD_CHECKS) { + decodesSinceLastFdCheck = 0; + int currentFds = Objects.requireNonNull(FD_SIZE_LIST.list()).length; + long maxFdCount = getMaxFdCount(); + isFdSizeBelowHardwareLimit = currentFds < maxFdCount; + + if (!isFdSizeBelowHardwareLimit && enableVerboseLogging) { + Log.w( + Downsampler.TAG, + "Excluding HARDWARE bitmap config because we're over the file descriptor limit" + + ", file descriptors " + + currentFds + + ", limit " + + maxFdCount); + } + } + + return isFdSizeBelowHardwareLimit; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/ImageReader.java b/app/src/main/java/org/thoughtcrime/securesms/glide/ImageReader.java new file mode 100644 index 0000000000..30cbfa2724 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/ImageReader.java @@ -0,0 +1,262 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.glide; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapFactory.Options; +import android.os.Build; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.bumptech.glide.load.ImageHeaderParser; +import com.bumptech.glide.load.ImageHeaderParser.ImageType; +import com.bumptech.glide.load.ImageHeaderParserUtils; +import com.bumptech.glide.load.data.DataRewinder; +import com.bumptech.glide.load.data.InputStreamRewinder; +import com.bumptech.glide.load.data.ParcelFileDescriptorRewinder; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; +import com.bumptech.glide.load.resource.bitmap.RecyclableBufferedInputStream; +import com.bumptech.glide.util.ByteBufferUtil; +import com.bumptech.glide.util.Preconditions; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.List; + +/** + * This is a helper class for {@link Downsampler} that abstracts out image operations from the input + * type wrapped into a {@link DataRewinder}. + */ +interface ImageReader { + @Nullable + Bitmap decodeBitmap(BitmapFactory.Options options) throws IOException; + + ImageHeaderParser.ImageType getImageType() throws IOException; + + int getImageOrientation() throws IOException; + + void stopGrowingBuffers(); + + final class ByteArrayReader implements ImageReader { + + private final byte[] bytes; + private final List parsers; + private final ArrayPool byteArrayPool; + + ByteArrayReader(byte[] bytes, List parsers, ArrayPool byteArrayPool) { + this.bytes = bytes; + this.parsers = parsers; + this.byteArrayPool = byteArrayPool; + } + + @Nullable + @Override + public Bitmap decodeBitmap(Options options) { + return BitmapFactory.decodeByteArray(bytes, /* offset= */ 0, bytes.length, options); + } + + @Override + public ImageType getImageType() throws IOException { + return ImageHeaderParserUtils.getType(parsers, ByteBuffer.wrap(bytes)); + } + + @Override + public int getImageOrientation() throws IOException { + return ImageHeaderParserUtils.getOrientation(parsers, ByteBuffer.wrap(bytes), byteArrayPool); + } + + @Override + public void stopGrowingBuffers() {} + } + + final class FileReader implements ImageReader { + + private final File file; + private final List parsers; + private final ArrayPool byteArrayPool; + + FileReader(File file, List parsers, ArrayPool byteArrayPool) { + this.file = file; + this.parsers = parsers; + this.byteArrayPool = byteArrayPool; + } + + @Nullable + @Override + public Bitmap decodeBitmap(Options options) throws FileNotFoundException { + InputStream is = null; + try { + is = new RecyclableBufferedInputStream(new FileInputStream(file), byteArrayPool); + return BitmapFactory.decodeStream(is, /* outPadding= */ null, options); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + // Ignored. + } + } + } + } + + @Override + public ImageType getImageType() throws IOException { + InputStream is = null; + try { + is = new RecyclableBufferedInputStream(new FileInputStream(file), byteArrayPool); + return ImageHeaderParserUtils.getType(parsers, is, byteArrayPool); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + // Ignored. + } + } + } + } + + @Override + public int getImageOrientation() throws IOException { + InputStream is = null; + try { + is = new RecyclableBufferedInputStream(new FileInputStream(file), byteArrayPool); + return ImageHeaderParserUtils.getOrientation(parsers, is, byteArrayPool); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + // Ignored. + } + } + } + } + + @Override + public void stopGrowingBuffers() {} + } + + final class ByteBufferReader implements ImageReader { + + private final ByteBuffer buffer; + private final List parsers; + private final ArrayPool byteArrayPool; + + ByteBufferReader(ByteBuffer buffer, List parsers, ArrayPool byteArrayPool) { + this.buffer = buffer; + this.parsers = parsers; + this.byteArrayPool = byteArrayPool; + } + + @Nullable + @Override + public Bitmap decodeBitmap(Options options) { + return BitmapFactory.decodeStream(stream(), /* outPadding= */ null, options); + } + + @Override + public ImageType getImageType() throws IOException { + return ImageHeaderParserUtils.getType(parsers, ByteBufferUtil.rewind(buffer)); + } + + @Override + public int getImageOrientation() throws IOException { + return ImageHeaderParserUtils.getOrientation( + parsers, ByteBufferUtil.rewind(buffer), byteArrayPool); + } + + @Override + public void stopGrowingBuffers() {} + + private InputStream stream() { + return ByteBufferUtil.toStream(ByteBufferUtil.rewind(buffer)); + } + } + + final class InputStreamImageReader implements ImageReader { + private final InputStreamRewinder dataRewinder; + private final ArrayPool byteArrayPool; + private final List parsers; + + InputStreamImageReader( + InputStream is, List parsers, ArrayPool byteArrayPool) + { + this.byteArrayPool = Preconditions.checkNotNull(byteArrayPool); + this.parsers = Preconditions.checkNotNull(parsers); + + dataRewinder = new InputStreamRewinder(is, byteArrayPool); + } + + @Nullable + @Override + public Bitmap decodeBitmap(BitmapFactory.Options options) throws IOException { + return BitmapFactory.decodeStream(dataRewinder.rewindAndGet(), null, options); + } + + @Override + public ImageHeaderParser.ImageType getImageType() throws IOException { + return ImageHeaderParserUtils.getType(parsers, dataRewinder.rewindAndGet(), byteArrayPool); + } + + @Override + public int getImageOrientation() throws IOException { + return ImageHeaderParserUtils.getOrientation( + parsers, dataRewinder.rewindAndGet(), byteArrayPool); + } + + @Override + public void stopGrowingBuffers() { + dataRewinder.fixMarkLimits(); + } + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) final class ParcelFileDescriptorImageReader implements ImageReader { + private final ArrayPool byteArrayPool; + private final List parsers; + private final ParcelFileDescriptorRewinder dataRewinder; + + ParcelFileDescriptorImageReader( + ParcelFileDescriptor parcelFileDescriptor, + List parsers, + ArrayPool byteArrayPool) + { + this.byteArrayPool = Preconditions.checkNotNull(byteArrayPool); + this.parsers = Preconditions.checkNotNull(parsers); + + dataRewinder = new ParcelFileDescriptorRewinder(parcelFileDescriptor); + } + + @Nullable + @Override + public Bitmap decodeBitmap(BitmapFactory.Options options) throws IOException { + return BitmapFactory.decodeFileDescriptor( + dataRewinder.rewindAndGet().getFileDescriptor(), null, options); + } + + @Override + public ImageHeaderParser.ImageType getImageType() throws IOException { + return ImageHeaderParserUtils.getType(parsers, dataRewinder, byteArrayPool); + } + + @Override + public int getImageOrientation() throws IOException { + return ImageHeaderParserUtils.getOrientation(parsers, dataRewinder, byteArrayPool); + } + + @Override + public void stopGrowingBuffers() { + // Nothing to do here. + } + } +}