diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/Downsampler.java b/app/src/main/java/org/thoughtcrime/securesms/glide/Downsampler.java index 7cb17e004f..b27c4706c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/Downsampler.java +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/Downsampler.java @@ -1,6 +1,19 @@ /* * Copyright 2025 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only + * + * This file is adapted from the Glide image loading library. + * Original work Copyright 2014 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package org.thoughtcrime.securesms.glide; diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/GlideStreamConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/GlideStreamConfig.kt new file mode 100644 index 0000000000..5cb0bae936 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/GlideStreamConfig.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.glide + +import android.app.ActivityManager +import android.content.Context +import org.signal.core.util.ByteSize +import org.signal.core.util.bytes +import org.signal.core.util.gibiBytes +import org.signal.core.util.mebiBytes +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.glide.GlideStreamConfig.MAX_MARK_LIMIT +import org.thoughtcrime.securesms.glide.GlideStreamConfig.MIN_MARK_LIMIT + +object GlideStreamConfig { + private val MIN_MARK_LIMIT: ByteSize = 5.mebiBytes // Glide default + private val MAX_MARK_LIMIT: ByteSize = 8.mebiBytes + + private val LOW_MEMORY_THRESHOLD: ByteSize = 4.gibiBytes + private val HIGH_MEMORY_THRESHOLD: ByteSize = 12.gibiBytes + + @JvmStatic + val markReadLimitBytes: Int by lazy { calculateScaledMarkLimit(context = AppDependencies.application).inWholeBytes.toInt() } + + /** + * Calculates buffer size, scaling proportionally from [MIN_MARK_LIMIT] to [MAX_MARK_LIMIT] based on how much memory the device has. + */ + private fun calculateScaledMarkLimit(context: Context): ByteSize { + val deviceMemory = getAvailableDeviceMemory(context) + return when { + deviceMemory <= LOW_MEMORY_THRESHOLD -> MIN_MARK_LIMIT + deviceMemory >= HIGH_MEMORY_THRESHOLD -> MAX_MARK_LIMIT + else -> calculateScaledSize(deviceMemory) + } + } + + private fun getAvailableDeviceMemory(context: Context): ByteSize { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo().apply { + activityManager.getMemoryInfo(this) + } + return memoryInfo.totalMem.bytes + } + + private fun calculateScaledSize(deviceMemory: ByteSize): ByteSize { + val ratio: Float = (deviceMemory - LOW_MEMORY_THRESHOLD).percentageOf(HIGH_MEMORY_THRESHOLD - LOW_MEMORY_THRESHOLD) + val offsetBytes = (ratio * (MAX_MARK_LIMIT.inWholeBytes - MIN_MARK_LIMIT.inWholeBytes)).toLong() + return MIN_MARK_LIMIT + ByteSize(offsetBytes) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/HardwareConfigState.java b/app/src/main/java/org/thoughtcrime/securesms/glide/HardwareConfigState.java index 35d98e82f6..a68f878ad7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/HardwareConfigState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/HardwareConfigState.java @@ -1,6 +1,19 @@ /* * Copyright 2025 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only + * + * This file is adapted from the Glide image loading library. + * Original work Copyright 2014 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package org.thoughtcrime.securesms.glide; diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/ImageHeaderParserUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/ImageHeaderParserUtils.kt index a710ae48ae..262e87235c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/ImageHeaderParserUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/ImageHeaderParserUtils.kt @@ -9,6 +9,7 @@ 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 com.bumptech.glide.load.resource.bitmap.RecyclableBufferedInputStream import org.thoughtcrime.securesms.mms.InputStreamFactory import org.thoughtcrime.securesms.util.BitmapUtil import java.io.IOException @@ -25,10 +26,31 @@ object ImageHeaderParserUtils { @Throws(IOException::class) fun getType( parsers: List, - inputStream: InputStream, + inputStream: InputStream?, byteArrayPool: ArrayPool ): ImageHeaderParser.ImageType { - return GlideImageHeaderParserUtils.getType(parsers, inputStream, byteArrayPool) + if (inputStream == null) { + return ImageHeaderParser.ImageType.UNKNOWN + } + + val markableStream = if (!inputStream.markSupported()) { + RecyclableBufferedInputStream(inputStream, byteArrayPool) + } else { + inputStream + } + + markableStream.mark(GlideStreamConfig.markReadLimitBytes) + + return getType( + parsers = parsers, + getTypeAndRewind = { parser -> + try { + parser.getType(markableStream) + } finally { + markableStream.reset() + } + } + ) } /** @@ -56,6 +78,16 @@ object ImageHeaderParserUtils { return GlideImageHeaderParserUtils.getType(parsers, parcelFileDescriptorRewinder, byteArrayPool) } + private fun getType( + parsers: List, + getTypeAndRewind: (ImageHeaderParser) -> ImageHeaderParser.ImageType + ): ImageHeaderParser.ImageType { + return parsers.firstNotNullOfOrNull { parser -> + getTypeAndRewind(parser) + .takeIf { type -> type != ImageHeaderParser.ImageType.UNKNOWN } + } ?: ImageHeaderParser.ImageType.UNKNOWN + } + /** * @see com.bumptech.glide.load.ImageHeaderParserUtils.getOrientation */ @@ -82,14 +114,43 @@ object ImageHeaderParserUtils { return GlideImageHeaderParserUtils.getOrientation(parsers, parcelFileDescriptorRewinder, byteArrayPool) } + /** + * @see com.bumptech.glide.load.ImageHeaderParserUtils.getOrientation + */ @JvmStatic @Throws(IOException::class) fun getOrientation( parsers: List, - inputStream: InputStream, - byteArrayPool: ArrayPool + inputStream: InputStream?, + byteArrayPool: ArrayPool, + allowStreamRewind: Boolean ): Int { - return GlideImageHeaderParserUtils.getOrientation(parsers, inputStream, byteArrayPool) + if (inputStream == null) { + return ImageHeaderParser.UNKNOWN_ORIENTATION + } + + val markableStream = if (allowStreamRewind && !inputStream.markSupported()) { + RecyclableBufferedInputStream(inputStream, byteArrayPool) + } else { + inputStream + } + + if (allowStreamRewind) { + markableStream.mark(GlideStreamConfig.markReadLimitBytes) + } + + return getOrientation( + parsers = parsers, + getOrientationAndRewind = { parser -> + try { + parser.getOrientation(markableStream, byteArrayPool) + } finally { + if (allowStreamRewind) { + markableStream.reset() + } + } + } + ) } /** @@ -102,49 +163,27 @@ object ImageHeaderParserUtils { inputStreamFactory: InputStreamFactory, byteArrayPool: ArrayPool ): Int { - val orientationFromParsers = getOrientationFromParsers( + val orientationFromParsers = getOrientation( parsers = parsers, inputStream = inputStreamFactory.createRecyclable(byteArrayPool), - byteArrayPool = byteArrayPool + byteArrayPool = byteArrayPool, + allowStreamRewind = false ) if (orientationFromParsers != ImageHeaderParser.UNKNOWN_ORIENTATION) return orientationFromParsers - val orientationFromExif = getOrientationFromExif(inputStream = inputStreamFactory.createRecyclable(byteArrayPool)) + val orientationFromExif = BitmapUtil.getExifOrientation(ExifInterface(inputStreamFactory.createRecyclable(byteArrayPool))) if (orientationFromExif != ImageHeaderParser.UNKNOWN_ORIENTATION) return orientationFromExif return ImageHeaderParser.UNKNOWN_ORIENTATION } - private fun getOrientationFromParsers( - parsers: List, - 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, - readOrientation: (ImageHeaderParser) -> Int + getOrientationAndRewind: (ImageHeaderParser) -> Int ): Int { - parsers.forEach { parser -> - val orientation = readOrientation(parser) - if (orientation != ImageHeaderParser.UNKNOWN_ORIENTATION) { - return orientation - } - } - - return ImageHeaderParser.UNKNOWN_ORIENTATION + return parsers.firstNotNullOfOrNull { parser -> + getOrientationAndRewind(parser) + .takeIf { type -> type != ImageHeaderParser.UNKNOWN_ORIENTATION } + } ?: ImageHeaderParser.UNKNOWN_ORIENTATION } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/ImageReader.java b/app/src/main/java/org/thoughtcrime/securesms/glide/ImageReader.java index d9aea0fdba..6863ee3e37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/ImageReader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/ImageReader.java @@ -18,7 +18,6 @@ import androidx.annotation.RequiresApi; import com.bumptech.glide.load.ImageHeaderParser; import com.bumptech.glide.load.ImageHeaderParser.ImageType; 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; @@ -209,7 +208,7 @@ interface ImageReader { @Override public int getImageOrientation() throws IOException { try { - return ImageHeaderParserUtils.getOrientation(parsers, dataRewinder.rewindAndGet(), byteArrayPool); + return ImageHeaderParserUtils.getOrientation(parsers, dataRewinder.rewindAndGet(), byteArrayPool, true); } catch (IOException e) { return ImageHeaderParserUtils.getOrientationWithFallbacks(parsers, inputStreamFactory, byteArrayPool); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/InputStreamRewinder.java b/app/src/main/java/org/thoughtcrime/securesms/glide/InputStreamRewinder.java new file mode 100644 index 0000000000..269c254756 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/InputStreamRewinder.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + * + * This file is adapted from the Glide image loading library. + * Original work Copyright 2014 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thoughtcrime.securesms.glide; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.data.DataRewinder; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; +import com.bumptech.glide.load.resource.bitmap.RecyclableBufferedInputStream; +import com.bumptech.glide.util.Synthetic; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Implementation for {@link InputStream}s that rewinds streams by wrapping them in a buffered stream. This is a copy of + * {@link com.bumptech.glide.load.data.InputStreamRewinder that is modified to use GlideStreamConfig.markReadLimitBytes in place of a hardcoded MARK_READ_LIMIT. + */ +public final class InputStreamRewinder implements DataRewinder { + private final RecyclableBufferedInputStream bufferedStream; + + @Synthetic + public InputStreamRewinder(InputStream is, ArrayPool byteArrayPool) { + // We don't check is.markSupported() here because RecyclableBufferedInputStream allows resetting + // after exceeding GlideStreamConfig.markReadLimitBytes, which other InputStreams don't guarantee. + bufferedStream = new RecyclableBufferedInputStream(is, byteArrayPool); + bufferedStream.mark(GlideStreamConfig.getMarkReadLimitBytes()); + } + + @NonNull + @Override + public InputStream rewindAndGet() throws IOException { + bufferedStream.reset(); + return bufferedStream; + } + + @Override + public void cleanup() { + bufferedStream.release(); + } + + public void fixMarkLimits() { + bufferedStream.fixMarkLimit(); + } + + /** + * Factory for producing {@link InputStreamRewinder}s from {@link InputStream}s. + */ + public static final class Factory implements DataRewinder.Factory { + private final ArrayPool byteArrayPool; + + public Factory(ArrayPool byteArrayPool) { + this.byteArrayPool = byteArrayPool; + } + + @NonNull + @Override + public DataRewinder build(@NonNull InputStream data) { + return new InputStreamRewinder(data, byteArrayPool); + } + + @NonNull + @Override + public Class getDataClass() { + return InputStream.class; + } + } +}