From 0575a6cce6ae9ed1faadf7e783949cc9818baf9c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 21 Jan 2025 18:01:04 +0100 Subject: [PATCH] #1391 region decoding fallback to jpeg export --- CHANGELOG.md | 1 + .../channel/calls/fetchers/RegionFetcher.kt | 54 +++++++++++++------ .../calls/fetchers/ThumbnailFetcher.kt | 30 +++-------- .../channel/streams/ImageByteStreamHandler.kt | 31 +++-------- .../aves/decoder/AvesAppGlideModule.kt | 29 ++++++++++ .../aves/model/provider/ImageProvider.kt | 30 ++--------- .../thibault/aves/utils/StorageUtils.kt | 9 ++-- 7 files changed, 92 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca7cfca80..12b860b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. ### Fixed - editing TIFF metadata increasing file size +- region decoding for some RAW files ## [v1.12.2] - 2025-01-13 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt index b8f3c5740..c6c9d0220 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt @@ -6,14 +6,14 @@ import android.graphics.BitmapFactory import android.graphics.BitmapRegionDecoder import android.graphics.Rect import android.net.Uri +import android.util.Log import com.bumptech.glide.Glide -import com.bumptech.glide.load.DecodeFormat -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.request.RequestOptions +import deckers.thibault.aves.decoder.AvesAppGlideModule import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.utils.BitmapRegionDecoderCompat import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE import deckers.thibault.aves.utils.BitmapUtils.getBytes +import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MathUtils import deckers.thibault.aves.utils.MemoryUtils import deckers.thibault.aves.utils.MimeTypes @@ -30,12 +30,7 @@ class RegionFetcher internal constructor( ) { private var lastDecoderRef: LastDecoderRef? = null - private val pageTempUris = HashMap, Uri>() - - private val multiTrackGlideOptions = RequestOptions() - .format(DecodeFormat.PREFER_ARGB_8888) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) + private val exportUris = HashMap, Uri>() suspend fun fetch( uri: Uri, @@ -45,25 +40,27 @@ class RegionFetcher internal constructor( regionRect: Rect, imageWidth: Int, imageHeight: Int, + requestKey: Pair = Pair(uri, pageId), result: MethodChannel.Result, ) { if (pageId != null && MultiPageImage.isSupported(mimeType)) { - val id = Pair(uri, pageId) + // use JPEG export for requested page fetch( - uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, mimeType, pageId) }, + uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) }, mimeType = MimeTypes.JPEG, pageId = null, sampleSize = sampleSize, regionRect = regionRect, imageWidth = imageWidth, imageHeight = imageHeight, + requestKey = requestKey, result = result, ) return } var currentDecoderRef = lastDecoderRef - if (currentDecoderRef != null && currentDecoderRef.uri != uri) { + if (currentDecoderRef != null && currentDecoderRef.requestKey != requestKey) { currentDecoderRef = null } @@ -76,7 +73,7 @@ class RegionFetcher internal constructor( result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null) return } - currentDecoderRef = LastDecoderRef(uri, newDecoder) + currentDecoderRef = LastDecoderRef(requestKey, newDecoder) } val decoder = currentDecoderRef.decoder lastDecoderRef = currentDecoderRef @@ -119,16 +116,35 @@ class RegionFetcher internal constructor( result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null) } } catch (e: Exception) { + if (mimeType != MimeTypes.JPEG) { + // retry with JPEG export on failure, + // as some formats are not fully supported by `BitmapRegionDecoder` + fetch( + uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) }, + mimeType = MimeTypes.JPEG, + pageId = null, + sampleSize = sampleSize, + regionRect = regionRect, + imageWidth = imageWidth, + imageHeight = imageHeight, + requestKey = requestKey, + result = result, + ) + return + } + result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message) } } - private fun createJpegForPage(sourceUri: Uri, mimeType: String, pageId: Int): Uri { + private fun createTemporaryJpegExport(uri: Uri, mimeType: String, pageId: Int?): Uri { + Log.d(LOG_TAG, "create JPEG export for uri=$uri mimeType=$mimeType pageId=$pageId") val target = Glide.with(context) .asBitmap() - .apply(multiTrackGlideOptions) - .load(MultiPageImage(context, sourceUri, mimeType, pageId)) + .apply(AvesAppGlideModule.uncachedFullImageOptions) + .load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId)) .submit() + try { val bitmap = target.get() val tempFile = StorageUtils.createTempFile(context).apply { @@ -143,7 +159,11 @@ class RegionFetcher internal constructor( } private data class LastDecoderRef( - val uri: Uri, + val requestKey: Pair, val decoder: BitmapRegionDecoder, ) + + companion object { + private val LOG_TAG = LogUtils.createTag() + } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index 7b5cdc2d1..3d57f38fc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -12,10 +12,8 @@ import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.signature.ObjectKey +import deckers.thibault.aves.decoder.AvesAppGlideModule import deckers.thibault.aves.decoder.MultiPageImage -import deckers.thibault.aves.decoder.SvgImage -import deckers.thibault.aves.decoder.TiffImage -import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.MimeTypes @@ -122,28 +120,16 @@ class ThumbnailFetcher internal constructor( .format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565) .signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId")) .override(width, height) - - val target = if (isVideo(mimeType)) { + if (isVideo(mimeType)) { options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE) - Glide.with(context) - .asBitmap() - .apply(options) - .load(VideoThumbnail(context, uri)) - .submit(width, height) - } else { - val model: Any = when { - svgFetch -> SvgImage(context, uri) - tiffFetch -> TiffImage(context, uri, pageId) - multiPageFetch -> MultiPageImage(context, uri, mimeType, pageId) - else -> StorageUtils.getGlideSafeUri(context, uri, mimeType) - } - Glide.with(context) - .asBitmap() - .apply(options) - .load(model) - .submit(width, height) } + val target = Glide.with(context) + .asBitmap() + .apply(options) + .load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId)) + .submit(width, height) + return try { var bitmap = target.get() if (needRotationAfterGlide(mimeType, pageId)) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index 2cb61ad99..a0b033c3c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -6,12 +6,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import com.bumptech.glide.Glide -import com.bumptech.glide.load.DecodeFormat -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.request.RequestOptions -import deckers.thibault.aves.decoder.MultiPageImage -import deckers.thibault.aves.decoder.TiffImage -import deckers.thibault.aves.decoder.VideoThumbnail +import deckers.thibault.aves.decoder.AvesAppGlideModule import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils @@ -130,18 +125,10 @@ class ImageByteStreamHandler(private val context: Context, private val arguments rotationDegrees: Int, isFlipped: Boolean, ) { - val model: Any = if (pageId != null && MultiPageImage.isSupported(mimeType)) { - MultiPageImage(context, uri, mimeType, pageId) - } else if (mimeType == MimeTypes.TIFF) { - TiffImage(context, uri, pageId) - } else { - StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes) - } - val target = Glide.with(context) .asBitmap() - .apply(glideOptions) - .load(model) + .apply(AvesAppGlideModule.uncachedFullImageOptions) + .load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId, sizeBytes)) .submit() try { var bitmap = withContext(Dispatchers.IO) { target.get() } @@ -159,7 +146,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null) } } catch (e: Exception) { - error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e)) + error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri", toErrorDetails(e)) } finally { Glide.with(context).clear(target) } @@ -168,8 +155,8 @@ class ImageByteStreamHandler(private val context: Context, private val arguments private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) { val target = Glide.with(context) .asBitmap() - .apply(glideOptions) - .load(VideoThumbnail(context, uri)) + .apply(AvesAppGlideModule.uncachedFullImageOptions) + .load(AvesAppGlideModule.getModel(context, uri, mimeType, null, sizeBytes)) .submit() try { val bitmap = withContext(Dispatchers.IO) { target.get() } @@ -218,11 +205,5 @@ class ImageByteStreamHandler(private val context: Context, private val arguments const val CHANNEL = "deckers.thibault/aves/media_byte_stream" private const val BUFFER_SIZE = 2 shl 17 // 256kB - - // request a fresh image with the highest quality format - private val glideOptions = RequestOptions() - .format(DecodeFormat.PREFER_ARGB_8888) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/AvesAppGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/AvesAppGlideModule.kt index 0b699c80a..52336f25c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/AvesAppGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/AvesAppGlideModule.kt @@ -1,14 +1,21 @@ package deckers.thibault.aves.decoder import android.content.Context +import android.net.Uri import android.util.Log import com.bumptech.glide.Glide import com.bumptech.glide.GlideBuilder import com.bumptech.glide.Registry import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.ImageHeaderParser +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser import com.bumptech.glide.module.AppGlideModule +import com.bumptech.glide.request.RequestOptions +import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.MimeTypes.isVideo +import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.compatRemoveIf @GlideModule @@ -25,4 +32,26 @@ class AvesAppGlideModule : AppGlideModule() { } override fun isManifestParsingEnabled(): Boolean = false + + companion object { + // request a fresh image with the highest quality format + val uncachedFullImageOptions = RequestOptions() + .format(DecodeFormat.PREFER_ARGB_8888) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + + fun getModel(context: Context, uri: Uri, mimeType: String, pageId: Int?, sizeBytes: Long? = null): Any { + return if (pageId != null && MultiPageImage.isSupported(mimeType)) { + MultiPageImage(context, uri, mimeType, pageId) + } else if (mimeType == MimeTypes.TIFF) { + TiffImage(context, uri, pageId) + } else if (mimeType == MimeTypes.SVG) { + SvgImage(context, uri) + } else if (isVideo(mimeType)) { + VideoThumbnail(context, uri) + } else { + StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes) + } + } + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 9734cd497..8b2d530e5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -11,16 +11,10 @@ import android.net.Uri import android.os.Binder import android.os.Build import android.util.Log -import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface import com.bumptech.glide.Glide -import com.bumptech.glide.load.DecodeFormat -import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.FutureTarget -import com.bumptech.glide.request.RequestOptions import com.commonsware.cwac.document.DocumentFileCompat -import deckers.thibault.aves.decoder.MultiPageImage -import deckers.thibault.aves.decoder.SvgImage -import deckers.thibault.aves.decoder.TiffImage +import deckers.thibault.aves.decoder.AvesAppGlideModule import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF @@ -68,6 +62,7 @@ import java.nio.channels.Channels import java.util.Date import java.util.TimeZone import kotlin.math.absoluteValue +import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface abstract class ImageProvider { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) { @@ -317,27 +312,12 @@ abstract class ImageProvider { } } - val model: Any = if (pageId != null && MultiPageImage.isSupported(sourceMimeType)) { - MultiPageImage(activity, sourceUri, sourceMimeType, pageId) - } else if (sourceMimeType == MimeTypes.TIFF) { - TiffImage(activity, sourceUri, pageId) - } else if (sourceMimeType == MimeTypes.SVG) { - SvgImage(activity, sourceUri) - } else { - StorageUtils.getGlideSafeUri(activity, sourceUri, sourceMimeType, sourceEntry.sizeBytes) - } - - // request a fresh image with the highest quality format - val glideOptions = RequestOptions() - .format(DecodeFormat.PREFER_ARGB_8888) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) - target = Glide.with(activity.applicationContext) .asBitmap() - .apply(glideOptions) - .load(model) + .apply(AvesAppGlideModule.uncachedFullImageOptions) + .load(AvesAppGlideModule.getModel(activity, sourceUri, sourceMimeType, pageId, sourceEntry.sizeBytes)) .submit(targetWidthPx, targetHeightPx) + var bitmap = withContext(Dispatchers.IO) { target.get() } if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) { bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 93f64ccef..136d3827d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -81,7 +81,8 @@ object StorageUtils { return null } val trashDir = File(externalFilesDir, "trash") - if (!trashDir.exists() && !trashDir.mkdirs()) { + trashDir.mkdirs() + if (!trashDir.exists()) { Log.e(LOG_TAG, "failed to create directories at path=$trashDir") return null } @@ -499,7 +500,8 @@ object StorageUtils { parentFile } else { val directory = File(cleanDirPath) - if (!directory.exists() && !directory.mkdirs()) { + directory.mkdirs() + if (!directory.exists()) { Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath") return null } @@ -712,7 +714,8 @@ object StorageUtils { fun createTempFile(context: Context, extension: String? = null): File { val directory = getTempDirectory(context) - if (!directory.exists() && !directory.mkdirs()) { + directory.mkdirs() + if (!directory.exists()) { throw IOException("failed to create directories at path=$directory") } val tempFile = File.createTempFile("aves", extension, directory)