Fix large image file loading failures.

Replaces `DecryptableStreamUriLoader` with `DecryptableUriStreamLoader`, which addresses `InvalidMarkException` errors that were occurring when loading large image files with Glide. This new model loader provides a more robust approach via multiple fallback mechanisms to try to recover gracefully from errors related to displaying large images.
This commit is contained in:
jeffrey-signal
2025-08-08 10:28:08 -04:00
committed by Greyson Parrelli
parent a549fff6fa
commit 784a64c353
10 changed files with 378 additions and 98 deletions

View File

@@ -14,6 +14,7 @@ import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.util.DisplayMetrics;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
@@ -39,6 +40,7 @@ import com.bumptech.glide.util.Preconditions;
import com.bumptech.glide.util.Util;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.mms.InputStreamFactory;
import java.io.File;
import java.io.IOException;
@@ -184,22 +186,17 @@ public final class Downsampler {
* 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)
* @see #decode(InputStreamFactory, int, int, Options, DecodeCallbacks)
*/
public Resource<Bitmap> decode(InputStream is, int outWidth, int outHeight, Options options)
throws IOException
{
return decode(is, outWidth, outHeight, options, EMPTY_CALLBACKS);
public Resource<Bitmap> decode(@NonNull InputStreamFactory inputStreamFactory, int outWidth, int outHeight, Options options) throws IOException {
return decode(inputStreamFactory, 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}.
* Identical to {@link #decode(InputStreamFactory, int, int, Options)}, except that it accepts a {@link
* ByteBuffer} in place of an {@link InputStreamFactory}.
*/
public Resource<Bitmap> decode(
ByteBuffer buffer, int requestedWidth, int requestedHeight, Options options)
throws IOException
{
public Resource<Bitmap> decode(ByteBuffer buffer, int requestedWidth, int requestedHeight, Options options) throws IOException {
return decode(
new ImageReader.ByteBufferReader(buffer, parsers, byteArrayPool),
requestedWidth,
@@ -218,7 +215,7 @@ public final class Downsampler {
* 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 inputStreamFactory An {@link InputStreamFactory} 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
@@ -229,7 +226,7 @@ public final class Downsampler {
* not null.
*/
public Resource<Bitmap> decode(
InputStream is,
@NonNull InputStreamFactory inputStreamFactory,
int requestedWidth,
int requestedHeight,
Options options,
@@ -237,7 +234,7 @@ public final class Downsampler {
throws IOException
{
return decode(
new ImageReader.InputStreamImageReader(is, parsers, byteArrayPool),
new ImageReader.InputStreamImageReader(inputStreamFactory, parsers, byteArrayPool),
requestedWidth,
requestedHeight,
options,

View File

@@ -0,0 +1,150 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.glide
import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.data.ParcelFileDescriptorRewinder
import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool
import org.thoughtcrime.securesms.mms.InputStreamFactory
import org.thoughtcrime.securesms.util.BitmapUtil
import java.io.IOException
import java.io.InputStream
import java.nio.ByteBuffer
typealias GlideImageHeaderParserUtils = com.bumptech.glide.load.ImageHeaderParserUtils
object ImageHeaderParserUtils {
/**
* @see com.bumptech.glide.load.ImageHeaderParserUtils.getType
*/
@JvmStatic
@Throws(IOException::class)
fun getType(
parsers: List<ImageHeaderParser>,
inputStream: InputStream,
byteArrayPool: ArrayPool
): ImageHeaderParser.ImageType {
return GlideImageHeaderParserUtils.getType(parsers, inputStream, byteArrayPool)
}
/**
* @see com.bumptech.glide.load.ImageHeaderParserUtils.getType
*/
@JvmStatic
@Throws(IOException::class)
fun getType(
parsers: List<ImageHeaderParser>,
buffer: ByteBuffer
): ImageHeaderParser.ImageType {
return GlideImageHeaderParserUtils.getType(parsers, buffer)
}
/**
* @see com.bumptech.glide.load.ImageHeaderParserUtils.getType
*/
@JvmStatic
@Throws(IOException::class)
fun getType(
parsers: List<ImageHeaderParser>,
parcelFileDescriptorRewinder: ParcelFileDescriptorRewinder,
byteArrayPool: ArrayPool
): ImageHeaderParser.ImageType {
return GlideImageHeaderParserUtils.getType(parsers, parcelFileDescriptorRewinder, byteArrayPool)
}
/**
* @see com.bumptech.glide.load.ImageHeaderParserUtils.getOrientation
*/
@JvmStatic
@Throws(IOException::class)
fun getOrientationWithFallbacks(
parsers: List<ImageHeaderParser>,
buffer: ByteBuffer,
arrayPool: ArrayPool
): Int {
return GlideImageHeaderParserUtils.getOrientation(parsers, buffer, arrayPool)
}
/**
* @see com.bumptech.glide.load.ImageHeaderParserUtils.getOrientation
*/
@JvmStatic
@Throws(IOException::class)
fun getOrientation(
parsers: List<ImageHeaderParser>,
parcelFileDescriptorRewinder: ParcelFileDescriptorRewinder,
byteArrayPool: ArrayPool
): Int {
return GlideImageHeaderParserUtils.getOrientation(parsers, parcelFileDescriptorRewinder, byteArrayPool)
}
@JvmStatic
@Throws(IOException::class)
fun getOrientation(
parsers: List<ImageHeaderParser>,
inputStream: InputStream,
byteArrayPool: ArrayPool
): Int {
return GlideImageHeaderParserUtils.getOrientation(parsers, inputStream, byteArrayPool)
}
/**
* @see com.bumptech.glide.load.ImageHeaderParserUtils.getOrientation
*/
@JvmStatic
@Throws(IOException::class)
fun getOrientationWithFallbacks(
parsers: List<ImageHeaderParser>,
inputStreamFactory: InputStreamFactory,
byteArrayPool: ArrayPool
): Int {
val orientationFromParsers = getOrientationFromParsers(
parsers = parsers,
inputStream = inputStreamFactory.createRecyclable(byteArrayPool),
byteArrayPool = byteArrayPool
)
if (orientationFromParsers != ImageHeaderParser.UNKNOWN_ORIENTATION) return orientationFromParsers
val orientationFromExif = getOrientationFromExif(inputStream = inputStreamFactory.createRecyclable(byteArrayPool))
if (orientationFromExif != ImageHeaderParser.UNKNOWN_ORIENTATION) return orientationFromExif
return ImageHeaderParser.UNKNOWN_ORIENTATION
}
private fun getOrientationFromParsers(
parsers: List<ImageHeaderParser>,
inputStream: InputStream?,
byteArrayPool: ArrayPool
): Int {
if (inputStream == null) {
return ImageHeaderParser.UNKNOWN_ORIENTATION
}
return getOrientation(
parsers = parsers,
readOrientation = { parser -> parser.getOrientation(inputStream, byteArrayPool) }
)
}
private fun getOrientationFromExif(inputStream: InputStream): Int {
return BitmapUtil.getExifOrientation(ExifInterface(inputStream))
}
private fun getOrientation(
parsers: List<ImageHeaderParser>,
readOrientation: (ImageHeaderParser) -> Int
): Int {
parsers.forEach { parser ->
val orientation = readOrientation(parser)
if (orientation != ImageHeaderParser.UNKNOWN_ORIENTATION) {
return orientation
}
}
return ImageHeaderParser.UNKNOWN_ORIENTATION
}
}

View File

@@ -11,12 +11,12 @@ import android.graphics.BitmapFactory.Options;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
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;
@@ -25,6 +25,8 @@ import com.bumptech.glide.load.resource.bitmap.RecyclableBufferedInputStream;
import com.bumptech.glide.util.ByteBufferUtil;
import com.bumptech.glide.util.Preconditions;
import org.thoughtcrime.securesms.mms.InputStreamFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
@@ -72,7 +74,7 @@ interface ImageReader {
@Override
public int getImageOrientation() throws IOException {
return ImageHeaderParserUtils.getOrientation(parsers, ByteBuffer.wrap(bytes), byteArrayPool);
return ImageHeaderParserUtils.getOrientationWithFallbacks(parsers, ByteBuffer.wrap(bytes), byteArrayPool);
}
@Override
@@ -128,19 +130,7 @@ interface ImageReader {
@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.
}
}
}
return ImageHeaderParserUtils.getOrientationWithFallbacks(parsers, InputStreamFactory.build(file), byteArrayPool);
}
@Override
@@ -172,8 +162,7 @@ interface ImageReader {
@Override
public int getImageOrientation() throws IOException {
return ImageHeaderParserUtils.getOrientation(
parsers, ByteBufferUtil.rewind(buffer), byteArrayPool);
return ImageHeaderParserUtils.getOrientationWithFallbacks(parsers, ByteBufferUtil.rewind(buffer), byteArrayPool);
}
@Override
@@ -188,31 +177,42 @@ interface ImageReader {
private final InputStreamRewinder dataRewinder;
private final ArrayPool byteArrayPool;
private final List<ImageHeaderParser> parsers;
private final InputStreamFactory inputStreamFactory;
InputStreamImageReader(
InputStream is, List<ImageHeaderParser> parsers, ArrayPool byteArrayPool)
{
InputStreamImageReader(@NonNull InputStreamFactory inputStreamFactory, List<ImageHeaderParser> parsers, ArrayPool byteArrayPool) {
this.byteArrayPool = Preconditions.checkNotNull(byteArrayPool);
this.parsers = Preconditions.checkNotNull(parsers);
dataRewinder = new InputStreamRewinder(is, byteArrayPool);
this.inputStreamFactory = inputStreamFactory;
this.dataRewinder = new InputStreamRewinder(inputStreamFactory.create(), byteArrayPool);
}
@Nullable
@Override
public Bitmap decodeBitmap(BitmapFactory.Options options) throws IOException {
return BitmapFactory.decodeStream(dataRewinder.rewindAndGet(), null, options);
public Bitmap decodeBitmap(@NonNull BitmapFactory.Options options) {
try {
return BitmapFactory.decodeStream(dataRewinder.rewindAndGet(), null, options);
} catch (IOException e) {
return BitmapFactory.decodeStream(inputStreamFactory.createRecyclable(byteArrayPool), null, options);
}
}
@Override
public ImageHeaderParser.ImageType getImageType() throws IOException {
return ImageHeaderParserUtils.getType(parsers, dataRewinder.rewindAndGet(), byteArrayPool);
try {
return ImageHeaderParserUtils.getType(parsers, dataRewinder.rewindAndGet(), byteArrayPool);
} catch (IOException e) {
return ImageHeaderParserUtils.getType(parsers, inputStreamFactory.createRecyclable(byteArrayPool), byteArrayPool);
}
}
@Override
public int getImageOrientation() throws IOException {
return ImageHeaderParserUtils.getOrientation(
parsers, dataRewinder.rewindAndGet(), byteArrayPool);
try {
return ImageHeaderParserUtils.getOrientation(parsers, dataRewinder.rewindAndGet(), byteArrayPool);
} catch (IOException e) {
return ImageHeaderParserUtils.getOrientationWithFallbacks(parsers, inputStreamFactory, byteArrayPool);
}
}
@Override

View File

@@ -25,9 +25,9 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher {
private static final String TAG = Log.tag(DecryptableStreamLocalUriFetcher.class);
private static final int DIMENSION_LIMIT = 12_000;
private static final long TOTAL_PIXEL_SIZE_LIMIT = 200_000_000L; // 200 megapixels
private Context context;
private final Context context;
DecryptableStreamLocalUriFetcher(Context context, Uri uri) {
super(context.getContentResolver(), uri);
@@ -76,8 +76,9 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher {
private boolean isSafeSize(InputStream stream) {
try {
Pair<Integer, Integer> size = BitmapUtil.getDimensions(stream);
return size.first < DIMENSION_LIMIT && size.second < DIMENSION_LIMIT;
Pair<Integer, Integer> dimensions = BitmapUtil.getDimensions(stream);
long totalPixels = (long) dimensions.first * dimensions.second;
return totalPixels < TOTAL_PIXEL_SIZE_LIMIT;
} catch (BitmapDecodingException e) {
return false;
}

View File

@@ -1,53 +0,0 @@
package org.thoughtcrime.securesms.mms;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import java.io.InputStream;
public class DecryptableStreamUriLoader implements ModelLoader<DecryptableUri, InputStream> {
private final Context context;
private DecryptableStreamUriLoader(Context context) {
this.context = context;
}
@Nullable
@Override
public LoadData<InputStream> buildLoadData(@NonNull DecryptableUri decryptableUri, int width, int height, @NonNull Options options) {
return new LoadData<>(decryptableUri, new DecryptableStreamLocalUriFetcher(context, decryptableUri.getUri()));
}
@Override
public boolean handles(@NonNull DecryptableUri decryptableUri) {
return true;
}
static class Factory implements ModelLoaderFactory<DecryptableUri, InputStream> {
private final Context context;
Factory(Context context) {
this.context = context.getApplicationContext();
}
@Override
public @NonNull ModelLoader<DecryptableUri, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
return new DecryptableStreamUriLoader(context);
}
@Override
public void teardown() {
// Do nothing.
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mms
import android.content.Context
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.data.DataFetcher
/**
* A Glide [DataFetcher] that retrieves an [InputStreamFactory] for a [DecryptableUri].
*/
class DecryptableUriStreamFetcher(
private val context: Context,
private val decryptableUri: DecryptableUri
) : DataFetcher<InputStreamFactory> {
override fun getDataClass(): Class<InputStreamFactory> = InputStreamFactory::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStreamFactory>) {
try {
callback.onDataReady(InputStreamFactory.build(context, decryptableUri.uri))
} catch (e: Exception) {
callback.onLoadFailed(e)
}
}
override fun cancel() = Unit
override fun cleanup() = Unit
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mms
import android.content.Context
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.signature.ObjectKey
/**
* A Glide [ModelLoader] that handles conversion from [DecryptableUri] to [InputStreamFactory].
*/
class DecryptableUriStreamLoader(
private val context: Context
) : ModelLoader<DecryptableUri, InputStreamFactory> {
override fun handles(model: DecryptableUri): Boolean = true
override fun buildLoadData(
model: DecryptableUri,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<InputStreamFactory> {
val sourceKey = ObjectKey(model)
val dataFetcher = DecryptableUriStreamFetcher(context, model)
return ModelLoader.LoadData(sourceKey, dataFetcher)
}
class Factory(
private val context: Context
) : ModelLoaderFactory<DecryptableUri, InputStreamFactory> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<DecryptableUri, InputStreamFactory> {
return DecryptableUriStreamLoader(context)
}
override fun teardown() = Unit
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mms
import android.content.Context
import android.net.Uri
import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool
import com.bumptech.glide.load.resource.bitmap.RecyclableBufferedInputStream
import org.signal.core.util.logging.Log
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
interface InputStreamFactory {
companion object {
@JvmStatic
fun build(context: Context, uri: Uri): InputStreamFactory = UriInputStreamFactory(context, uri)
@JvmStatic
fun build(file: File): InputStreamFactory = FileInputStreamFactory(file)
}
fun create(): InputStream
fun createRecyclable(byteArrayPool: ArrayPool): InputStream = RecyclableBufferedInputStream(create(), byteArrayPool)
}
/**
* A factory that creates a new [InputStream] for the given [Uri] each time [create] is called.
*/
class UriInputStreamFactory(
private val context: Context,
private val uri: Uri
) : InputStreamFactory {
companion object {
private val TAG = Log.tag(UriInputStreamFactory::class)
}
override fun create(): InputStream {
return try {
DecryptableStreamLocalUriFetcher(context, uri).loadResource(uri, context.contentResolver)
} catch (e: Exception) {
Log.w(TAG, "Error creating input stream for URI.", e)
throw e
}
}
}
/**
* A factory that creates a new [InputStream] for the given [File] each time [create] is called.
*/
class FileInputStreamFactory(
private val file: File
) : InputStreamFactory {
companion object {
private val TAG = Log.tag(FileInputStreamFactory::class)
}
override fun create(): InputStream {
return try {
FileInputStream(file)
} catch (e: Exception) {
Log.w(TAG, "Error creating input stream for File.", e)
throw e
}
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mms
import android.content.Context
import android.graphics.Bitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.ResourceDecoder
import com.bumptech.glide.load.engine.Resource
import org.thoughtcrime.securesms.glide.Downsampler
/**
* A Glide [ResourceDecoder] that decodes [Bitmap]s from a [InputStreamFactory] instances.
*/
class InputStreamFactoryBitmapDecoder(
private val downsampler: Downsampler
) : ResourceDecoder<InputStreamFactory, Bitmap> {
constructor(
context: Context,
glide: Glide,
registry: Registry
) : this(
downsampler = Downsampler(registry.imageHeaderParsers, context.resources.displayMetrics, glide.bitmapPool, glide.arrayPool)
)
override fun handles(source: InputStreamFactory, options: Options): Boolean = true
override fun decode(source: InputStreamFactory, width: Int, height: Int, options: Options): Resource<Bitmap?>? {
return downsampler.decode(source, width, height, options)
}
}

View File

@@ -93,7 +93,8 @@ public class SignalGlideComponents implements RegisterGlideComponents {
registry.append(StoryTextPostModel.class, StoryTextPostModel.class, UnitModelLoader.Factory.getInstance());
registry.append(ConversationShortcutPhoto.class, Bitmap.class, new ConversationShortcutPhoto.Loader.Factory(context));
registry.append(ContactPhoto.class, InputStream.class, new ContactPhotoLoader.Factory(context));
registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context));
registry.append(DecryptableUri.class, InputStreamFactory.class, new DecryptableUriStreamLoader.Factory(context));
registry.append(InputStreamFactory.class, Bitmap.class, new InputStreamFactoryBitmapDecoder(context, glide, registry));
registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory());
registry.append(StickerRemoteUri.class, InputStream.class, new StickerRemoteUriLoader.Factory());
registry.append(BlurHash.class, BlurHash.class, new BlurHashModelLoader.Factory());