From f36f32f6eb5dd5e29dd754368d9c2a4cdf6ebefc Mon Sep 17 00:00:00 2001 From: IacobIonut01 Date: Sun, 4 Aug 2024 18:00:01 +0300 Subject: [PATCH] Update subsampling library, Update coil and add avif support Also fixes Using double tap to zoom #399 Signed-off-by: IacobIonut01 --- app/build.gradle.kts | 2 +- .../dot/gallery/core/coil/ThumbnailDecoder.kt | 7 +- .../components/media/ZoomablePagerImage.kt | 4 +- .../presentation/util/JxlDecoder.kt | 6 +- .../presentation/util/newImageLoader.kt | 7 +- .../awxkee/avifcoil/decoder/Heif3Decoder.kt | 145 ++++++++++++++++ .../zoomable/coil3/CoilI3mageSource.kt | 161 ++++++++++-------- .../zoomable/coil3/canBeSubSampled.kt | 37 ++-- .../telephoto/zoomable/coil3/imageFormats.kt | 55 ++++++ gradle/libs.versions.toml | 8 +- 10 files changed, 325 insertions(+), 107 deletions(-) create mode 100644 app/src/main/kotlin/com/github/awxkee/avifcoil/decoder/Heif3Decoder.kt create mode 100644 app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/imageFormats.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0dcd612c4..fbe899ddb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,7 +20,7 @@ android { applicationId = "com.dot.gallery" minSdk = 30 targetSdk = 34 - versionCode = 30011 + versionCode = 30013 versionName = "3.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt index 390ed913b..59dffa222 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt @@ -10,7 +10,7 @@ import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.toDrawable import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi -import coil3.asCoilImage +import coil3.asImage import coil3.decode.ContentMetadata import coil3.decode.DecodeResult import coil3.decode.DecodeUtils @@ -19,6 +19,7 @@ import coil3.decode.ImageSource import coil3.fetch.SourceFetchResult import coil3.request.Options import coil3.request.bitmapConfig +import coil3.size.Precision import coil3.size.Size import coil3.size.pxOrElse import coil3.svg.internal.MIME_TYPE_SVG @@ -46,7 +47,7 @@ class ThumbnailDecoder( return DecodeResult( - image = normalizedBitmap.toDrawable(options.context.resources).asCoilImage(), + image = normalizedBitmap.toDrawable(options.context.resources).asImage(), isSampled = true, ) } @@ -91,7 +92,7 @@ class ThumbnailDecoder( } private fun isSizeValid(bitmap: Bitmap, options: Options, size: Size): Boolean { - if (options.allowInexactSize) return true + if (options.precision == Precision.INEXACT) return true val multiplier = DecodeUtils.computeSizeMultiplier( srcWidth = bitmap.width, srcHeight = bitmap.height, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt index e844109e4..f5d3f3c16 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt @@ -29,6 +29,7 @@ import com.dot.gallery.core.Settings import com.dot.gallery.core.presentation.components.util.LocalBatteryStatus import com.dot.gallery.core.presentation.components.util.ProvideBatteryStatus import com.dot.gallery.feature_node.domain.model.Media +import me.saket.telephoto.zoomable.DoubleClickToZoomListener import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomableImage import me.saket.telephoto.zoomable.ZoomableImageSource @@ -104,7 +105,8 @@ fun ZoomablePagerImage( .build() ), contentScale = ContentScale.Fit, - contentDescription = media.label + contentDescription = media.label, + onDoubleClick = DoubleClickToZoomListener.cycle(3f) ) } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/JxlDecoder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/JxlDecoder.kt index 3496fd64c..e4509daa7 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/JxlDecoder.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/JxlDecoder.kt @@ -30,7 +30,7 @@ package com.dot.gallery.feature_node.presentation.util import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi -import coil3.asCoilImage +import coil3.asImage import coil3.decode.DecodeResult import coil3.decode.Decoder import coil3.fetch.SourceFetchResult @@ -63,7 +63,7 @@ class JxlDecoder( preferredColorConfig = PreferredColorConfig.DEFAULT ) return@runInterruptible DecodeResult( - image = originalImage.asCoilImage(), + image = originalImage.asImage(), isSampled = false ) } @@ -85,7 +85,7 @@ class JxlDecoder( JxlResizeFilter.BILINEAR, ) DecodeResult( - image = originalImage.asCoilImage(), + image = originalImage.asImage(), isSampled = true ) } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt index 487e15dda..76ddd1212 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt @@ -1,6 +1,7 @@ package com.dot.gallery.feature_node.presentation.util import android.app.ActivityManager +import android.graphics.Bitmap import androidx.core.content.getSystemService import coil3.ImageLoader import coil3.PlatformContext @@ -9,10 +10,11 @@ import coil3.disk.directory import coil3.gif.AnimatedImageDecoder import coil3.memory.MemoryCache import coil3.request.allowRgb565 +import coil3.request.bitmapConfig import coil3.request.crossfade import coil3.svg.SvgDecoder -import coil3.util.DebugLogger import com.dot.gallery.core.coil.ThumbnailDecoder +import com.github.awxkee.avifcoil.decoder.HeifDecoder3 fun newImageLoader( context: PlatformContext @@ -21,6 +23,7 @@ fun newImageLoader( val memoryPercent = if (activityManager.isLowRamDevice) 0.25 else 0.75 return ImageLoader.Builder(context) .components { + add(HeifDecoder3.Factory(context)) // SVGs add(SvgDecoder.Factory(false)) add(JxlDecoder.Factory()) @@ -43,6 +46,6 @@ fun newImageLoader( // Show a short crossfade when loading images asynchronously. .crossfade(100) .allowRgb565(true) - .logger(DebugLogger()) + .bitmapConfig(Bitmap.Config.HARDWARE) .build() } diff --git a/app/src/main/kotlin/com/github/awxkee/avifcoil/decoder/Heif3Decoder.kt b/app/src/main/kotlin/com/github/awxkee/avifcoil/decoder/Heif3Decoder.kt new file mode 100644 index 000000000..99a16cb77 --- /dev/null +++ b/app/src/main/kotlin/com/github/awxkee/avifcoil/decoder/Heif3Decoder.kt @@ -0,0 +1,145 @@ +/* + * MIT License + * + * Copyright (c) 2023 Radzivon Bartoshyk + * avif-coder [https://github.com/awxkee/avif-coder] + * + * Created by Radzivon Bartoshyk on 23/09/2023 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ +// Same HeifDecoder but for coil3 + +package com.github.awxkee.avifcoil.decoder + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.asImage +import coil3.decode.DecodeResult +import coil3.decode.Decoder +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import coil3.request.allowRgb565 +import coil3.request.bitmapConfig +import coil3.size.Scale +import coil3.size.Size +import coil3.size.pxOrElse +import com.radzivon.bartoshyk.avif.coder.HeifCoder +import com.radzivon.bartoshyk.avif.coder.PreferredColorConfig +import com.radzivon.bartoshyk.avif.coder.ScaleMode +import kotlinx.coroutines.runInterruptible +import okio.ByteString.Companion.encodeUtf8 + +class HeifDecoder3( + context: Context?, + private val source: SourceFetchResult, + private val options: Options, +) : Decoder { + + private val coder = HeifCoder(context) + + @OptIn(ExperimentalCoilApi::class) + override suspend fun decode(): DecodeResult = runInterruptible { + // ColorSpace is preferred to be ignored due to lib is trying to handle all color profile by itself + val sourceData = source.source.source().readByteArray() + + var mPreferredColorConfig: PreferredColorConfig = when (options.bitmapConfig) { + Bitmap.Config.ALPHA_8 -> PreferredColorConfig.RGBA_8888 + Bitmap.Config.RGB_565 -> if (options.allowRgb565) PreferredColorConfig.RGB_565 else PreferredColorConfig.DEFAULT + Bitmap.Config.ARGB_8888 -> PreferredColorConfig.RGBA_8888 + else -> PreferredColorConfig.DEFAULT + } + if (options.bitmapConfig == Bitmap.Config.RGBA_F16) { + mPreferredColorConfig = PreferredColorConfig.RGBA_F16 + } else if (options.bitmapConfig == Bitmap.Config.HARDWARE) { + mPreferredColorConfig = PreferredColorConfig.HARDWARE + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && options.bitmapConfig == Bitmap.Config.RGBA_1010102) { + mPreferredColorConfig = PreferredColorConfig.RGBA_1010102 + } + + if (options.size == Size.ORIGINAL) { + val originalImage = + coder.decode( + sourceData, + preferredColorConfig = mPreferredColorConfig + ) + return@runInterruptible DecodeResult( + BitmapDrawable( + options.context.resources, + originalImage + ).asImage(), false + ) + } + + val dstWidth = options.size.width.pxOrElse { 0 } + val dstHeight = options.size.height.pxOrElse { 0 } + val scaleMode = when (options.scale) { + Scale.FILL -> ScaleMode.FILL + Scale.FIT -> ScaleMode.FIT + } + + val originalImage = + coder.decodeSampled( + sourceData, + dstWidth, + dstHeight, + preferredColorConfig = mPreferredColorConfig, + scaleMode, + ) + return@runInterruptible DecodeResult( + image = BitmapDrawable( + options.context.resources, + originalImage + ).asImage(), isSampled = true + ) + } + + /** + * @param context is preferred to be set when displaying an HDR content to apply Vulkan shaders + */ + class Factory(private val context: Context? = null) : Decoder.Factory { + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader + ): Decoder? { + return if (AVAILABLE_BRANDS.any { + result.source.source().rangeEquals(4, it) + }) HeifDecoder3(context, result, options) else null + } + + companion object { + private val MIF = "ftypmif1".encodeUtf8() + private val MSF = "ftypmsf1".encodeUtf8() + private val HEIC = "ftypheic".encodeUtf8() + private val HEIX = "ftypheix".encodeUtf8() + private val HEVC = "ftyphevc".encodeUtf8() + private val HEVX = "ftyphevx".encodeUtf8() + private val AVIF = "ftypavif".encodeUtf8() + private val AVIS = "ftypavis".encodeUtf8() + + private val AVAILABLE_BRANDS = listOf(MIF, MSF, HEIC, HEIX, HEVC, HEVX, AVIF, AVIS) + } + } +} diff --git a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/CoilI3mageSource.kt b/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/CoilI3mageSource.kt index c8853aa49..430981e61 100644 --- a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/CoilI3mageSource.kt +++ b/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/CoilI3mageSource.kt @@ -2,7 +2,6 @@ package me.saket.telephoto.zoomable.coil3 -import android.content.ContentResolver import android.content.res.Resources import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable @@ -13,36 +12,44 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext import coil3.Image import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi +import coil3.asDrawable import coil3.compose.LocalPlatformContext import coil3.decode.DataSource +import coil3.gif.AnimatedImageDecoder import coil3.imageLoader import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.ImageResult +import coil3.request.Options import coil3.request.SuccessResult import coil3.request.transitionFactory import coil3.size.Dimension +import coil3.size.Precision import coil3.size.SizeResolver +import coil3.svg.SvgDecoder import coil3.transition.CrossfadeTransition +import com.dot.gallery.feature_node.presentation.util.JxlDecoder +import com.github.awxkee.avifcoil.decoder.HeifDecoder3 import com.google.accompanist.drawablepainter.DrawablePainter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import me.saket.telephoto.subsamplingimage.ImageBitmapOptions import me.saket.telephoto.subsamplingimage.SubSamplingImageSource -import me.saket.telephoto.subsamplingimage.asAssetPathOrNull import me.saket.telephoto.zoomable.ZoomableImageSource import me.saket.telephoto.zoomable.ZoomableImageSource.ResolveResult -import me.saket.telephoto.zoomable.coil3.Resolver.ImageSourceFactory +import me.saket.telephoto.zoomable.coil3.Resolver.ImageSourceCreationResult.EligibleForSubSampling +import me.saket.telephoto.zoomable.coil3.Resolver.ImageSourceCreationResult.ImageDeletedOnlyFromDiskCache import me.saket.telephoto.zoomable.internal.RememberWorker import me.saket.telephoto.zoomable.internal.copy -import okio.Path.Companion.toPath +import java.io.File import kotlin.math.roundToInt import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -97,6 +104,16 @@ internal class Resolver( @OptIn(ExperimentalCoilApi::class) override suspend fun work() { + val imageLoader = imageLoader + .newBuilder() + .components { + add(HeifDecoder3.Factory(request.context)) + // SVGs + add(SvgDecoder.Factory(false)) + add(JxlDecoder.Factory()) + // GIFs + add(AnimatedImageDecoder.Factory()) + }.build() val result = imageLoader.execute( request.newBuilder() .size(request.defined.sizeResolver ?: sizeResolver) @@ -120,10 +137,24 @@ internal class Resolver( ) } ) + // Increase memory cache hit rate because the image will anyway fit the canvas + // size at draw time. + .precision( + when (request.defined.precision) { + Precision.EXACT -> request.precision + else -> Precision.INEXACT + } + ) .build() ) - val imageSource = result.toSubSamplingImageSource() + val imageSource = when (val it = result.toSubSamplingImageSource()) { + null -> null + is EligibleForSubSampling -> it.source + is ImageDeletedOnlyFromDiskCache -> { + return + } + } resolved = resolved.copy( crossfadeDuration = result.crossfadeDuration(), delegate = if (result is SuccessResult && imageSource != null) { @@ -139,48 +170,67 @@ internal class Resolver( ) } - private suspend fun ImageResult.toSubSamplingImageSource(): SubSamplingImageSource? { + @OptIn(ExperimentalCoilApi::class) + private fun ImageResult.asDrawable() = image?.asDrawable(request.context.resources) + + @OptIn(ExperimentalCoilApi::class) + private fun ImageResult.asBitmap() = (image?.asDrawable(request.context.resources) as? BitmapDrawable)?.bitmap + + @OptIn(ExperimentalCoilApi::class) + private fun Image.asPainter(resources: Resources) = asDrawable(resources).asPainter() + + private sealed interface ImageSourceCreationResult { + data class EligibleForSubSampling(val source: SubSamplingImageSource) : ImageSourceCreationResult + + /** Image was deleted from the disk cache, but is still present in the memory cache. */ + data object ImageDeletedOnlyFromDiskCache : ImageSourceCreationResult + } + + @OptIn(ExperimentalCoilApi::class) + private suspend fun ImageResult.toSubSamplingImageSource(): ImageSourceCreationResult? { val result = this - val sourceFactory = if (result is SuccessResult && result.asDrawable() is BitmapDrawable) { + val source = if (result is SuccessResult && result.asDrawable() is BitmapDrawable) { + val preview = result.asBitmap()?.asImageBitmap() when { // Prefer reading of images directly from files whenever possible because // it is significantly faster than reading from their input streams. result.diskCacheKey != null -> { val diskCache = imageLoader.diskCache!! - val snapshot = diskCache.openSnapshot(result.diskCacheKey!!) ?: error("Coil returned a null cache snapshot") - ImageSourceFactory { SubSamplingImageSource.file(snapshot.data, preview = it, onClose = snapshot::close) } + val snapshot = withContext(Dispatchers.IO) { // IO because openSnapshot() can delete files. + diskCache.openSnapshot(result.diskCacheKey!!) + } + if (snapshot == null) { + return when (result.dataSource) { + DataSource.MEMORY_CACHE -> ImageDeletedOnlyFromDiskCache + else -> error("Coil returned an image that is missing from its disk cache") + } + } + SubSamplingImageSource.file(snapshot.data, preview, onClose = snapshot::close) } - result.dataSource.let { it == DataSource.DISK || it == DataSource.MEMORY_CACHE } -> { - val requestData = result.request.data - requestData.asUriOrNull()?.toSourceFactory() - ?: requestData.asResourceIdOrNull()?.toSourceFactory() - ?: return null + // Possible reasons for reaching this code path: + // - Locally stored images such as assets, resource, etc. + // - Remote image that wasn't saved to disk because of a "no-store" HTTP header. + result.request.mapRequestDataToUriOrNull()?.let { uri -> + SubSamplingImageSource.contentUriOrNull(uri, preview) + } } - else -> return null + else -> { + // Image wasn't saved to the disk. Telephoto won't be able to load this image in its full + // quality. It'll attempt to display the bitmap directly as a fallback, but that can + // potentially cause an OutOfMemoryError when the bitmap is drawn. + return null + } } } else { return null } - - val preview = result.asBitmap()?.asImageBitmap() - val source = sourceFactory.create(preview) - return if (source?.canBeSubSampled() == true) source else null + return if (source?.canBeSubSampled() == true) EligibleForSubSampling( + source + ) else null } - @OptIn(ExperimentalCoilApi::class) - private fun ImageResult.asDrawable() = image?.asDrawable(request.context.resources) - - @OptIn(ExperimentalCoilApi::class) - private fun ImageResult.asBitmap() = (image?.asDrawable(request.context.resources) as? BitmapDrawable)?.bitmap - - @OptIn(ExperimentalCoilApi::class) - private fun Image.asPainter(resources: Resources) = asDrawable(resources).asPainter() - - fun interface ImageSourceFactory { - suspend fun create(preview: ImageBitmap?): SubSamplingImageSource? - } private fun ImageResult.crossfadeDuration(): Duration { val transitionFactory = request.transitionFactory @@ -194,52 +244,15 @@ internal class Resolver( } } - private fun Any.asUriOrNull(): Uri? { - return when (this) { - is String -> Uri.parse(this) - is Uri -> this + private fun ImageRequest.mapRequestDataToUriOrNull(): Uri? { + val dummyOptions = Options(request.context) // Good enough for mappers that only use the context. + return when (val mapped = imageLoader.components.map(data, dummyOptions)) { + is Uri -> mapped + is File -> Uri.parse(mapped.path) else -> null } } - private fun Uri.toSourceFactory(): ImageSourceFactory { - // Assets must be evaluated before files because they share the same scheme. - this.asAssetPathOrNull()?.let { assetPath -> - return ImageSourceFactory { SubSamplingImageSource.asset(assetPath.path, preview = it) } - } - - val filePath = when { - // File URIs without a scheme are invalid but have had historic support - // from many image loaders, including Coil. Telephoto is forced to support - // them because it promises to be a drop-in replacement for AsyncImage(). - // https://github.com/saket/telephoto/issues/19 - scheme == null && path?.startsWith('/') == true && pathSegments.isNotEmpty() -> toString().toPath() - scheme == ContentResolver.SCHEME_FILE -> path?.toPath() - else -> null - } - if (filePath != null) { - return ImageSourceFactory { SubSamplingImageSource.file(filePath, preview = it) } - } - - return ImageSourceFactory { SubSamplingImageSource.contentUri(this, preview = it) } - } - - @JvmInline - value class ResourceId(val id: Int) - - private fun Any.asResourceIdOrNull(): ResourceId? { - if (this is Int) { - runCatching { - request.context.resources.getResourceEntryName(this) - return ResourceId(this) - } - } - return null - } - - private fun ResourceId.toSourceFactory() = - ImageSourceFactory { SubSamplingImageSource.resource(id, preview = it) } - private fun Drawable.asPainter(): Painter { return DrawablePainter(mutate()) } diff --git a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/canBeSubSampled.kt b/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/canBeSubSampled.kt index 5b39c8245..15c4d89ca 100644 --- a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/canBeSubSampled.kt +++ b/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/canBeSubSampled.kt @@ -5,6 +5,7 @@ package me.saket.telephoto.zoomable.coil3 import android.annotation.SuppressLint import android.util.TypedValue import coil3.decode.DecodeUtils +import coil3.gif.isGif import coil3.svg.isSvg import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -22,20 +23,15 @@ import okio.use context(Resolver) internal suspend fun SubSamplingImageSource.canBeSubSampled(): Boolean { - val preventSubSampling = when (this) { - is ResourceImageSource -> isVectorDrawable() - is AssetImageSource -> isSvgDecoderPresent() && isSvg() - is UriImageSource -> isSvgDecoderPresent() && isSvg() - is FileImageSource -> isSvgDecoderPresent() && isSvg(FileSystem.SYSTEM.source(path)) - is RawImageSource -> isSvgDecoderPresent() && isSvg(source.invoke()) + return withContext(Dispatchers.IO) { + when (this@canBeSubSampled) { + is ResourceImageSource -> !isVectorDrawable() + is AssetImageSource -> canBeSubSampled() + is UriImageSource -> canBeSubSampled() + is FileImageSource -> canBeSubSampled(FileSystem.SYSTEM.source(path)) + is RawImageSource -> canBeSubSampled(source.invoke()) + } } - return !preventSubSampling -} - -context(Resolver) -private fun isSvgDecoderPresent(): Boolean { - // Only available in this app - return true } context(Resolver) @@ -45,16 +41,17 @@ private fun ResourceImageSource.isVectorDrawable(): Boolean = }.string.endsWith(".xml") context(Resolver) -private suspend fun AssetImageSource.isSvg(): Boolean = - isSvg(peek(request.context).source()) +private fun AssetImageSource.canBeSubSampled(): Boolean = + canBeSubSampled(peek(request.context).source()) context(Resolver) @SuppressLint("Recycle") -private suspend fun UriImageSource.isSvg(): Boolean = - isSvg(peek(request.context).source()) +private fun UriImageSource.canBeSubSampled(): Boolean = + canBeSubSampled(peek(request.context).source()) -private suspend fun isSvg(source: Source?): Boolean { - return withContext(Dispatchers.IO) { - source?.buffer()?.use { DecodeUtils.isSvg(source.buffer()) } == true +private fun canBeSubSampled(source: Source): Boolean { + return source.buffer().use { + // Check for GIFs as well because Android's ImageDecoder can return a Bitmap for single-frame GIFs. + !DecodeUtils.isSvg(it) && !DecodeUtils.isGif(it) } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/imageFormats.kt b/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/imageFormats.kt new file mode 100644 index 000000000..20107c11d --- /dev/null +++ b/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/imageFormats.kt @@ -0,0 +1,55 @@ +package me.saket.telephoto.zoomable.coil3 + +import coil.decode.DecodeUtils +import okio.BufferedSource +import okio.ByteString +import okio.ByteString.Companion.encodeUtf8 + +private val SVG_TAG: ByteString = " 0) { "bytes is empty" } + + val firstByte = bytes[0] + val lastIndex = toIndex - bytes.size + var currentIndex = fromIndex + while (currentIndex < lastIndex) { + currentIndex = indexOf(firstByte, currentIndex, lastIndex) + if (currentIndex == -1L || rangeEquals(currentIndex, bytes)) { + return currentIndex + } + currentIndex++ + } + return -1 +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0a1bfdf4..9b964bdfb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,10 @@ [versions] agp = "8.3.2" +avifCoderCoil = "1.8.0" benchmarkMacroJunit4 = "1.2.4" biometric = "1.2.0-alpha05" appcompat = "1.7.0" -coilVersion = "3.0.0-alpha06" +coilVersion = "3.0.0-alpha08" composeVideo = "1.2.0" coreSplashscreen = "1.0.1" fuzzywuzzy = "1.4.0" @@ -33,8 +34,8 @@ datastore = "1.1.1" securityCrypto = "1.1.0-alpha06" uiautomator = "2.3.0" zoomable = "1.6.1" -zoomableImageCoil = "0.11.2" -composealpha = "1.7.0-beta03" +zoomableImageCoil = "0.12.1" +composealpha = "1.7.0-beta06" [libraries] # AndroidX @@ -56,6 +57,7 @@ androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstall # Coil androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +avif-coder-coil = { module = "com.github.awxkee:avif-coder-coil", version.ref = "avifCoderCoil" } coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coilVersion" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilVersion" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilVersion" }