#1391 region decoding fallback to jpeg export

This commit is contained in:
Thibault Deckers 2025-01-21 18:01:04 +01:00
parent 8e5d971a6f
commit 0575a6cce6
7 changed files with 92 additions and 92 deletions

View file

@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- editing TIFF metadata increasing file size - editing TIFF metadata increasing file size
- region decoding for some RAW files
## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13 ## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13

View file

@ -6,14 +6,14 @@ import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder import android.graphics.BitmapRegionDecoder
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.util.Log
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import deckers.thibault.aves.decoder.AvesAppGlideModule
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MathUtils import deckers.thibault.aves.utils.MathUtils
import deckers.thibault.aves.utils.MemoryUtils import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
@ -30,12 +30,7 @@ class RegionFetcher internal constructor(
) { ) {
private var lastDecoderRef: LastDecoderRef? = null private var lastDecoderRef: LastDecoderRef? = null
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>() private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
private val multiTrackGlideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
suspend fun fetch( suspend fun fetch(
uri: Uri, uri: Uri,
@ -45,25 +40,27 @@ class RegionFetcher internal constructor(
regionRect: Rect, regionRect: Rect,
imageWidth: Int, imageWidth: Int,
imageHeight: Int, imageHeight: Int,
requestKey: Pair<Uri, Int?> = Pair(uri, pageId),
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
if (pageId != null && MultiPageImage.isSupported(mimeType)) { if (pageId != null && MultiPageImage.isSupported(mimeType)) {
val id = Pair(uri, pageId) // use JPEG export for requested page
fetch( fetch(
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, mimeType, pageId) }, uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
mimeType = MimeTypes.JPEG, mimeType = MimeTypes.JPEG,
pageId = null, pageId = null,
sampleSize = sampleSize, sampleSize = sampleSize,
regionRect = regionRect, regionRect = regionRect,
imageWidth = imageWidth, imageWidth = imageWidth,
imageHeight = imageHeight, imageHeight = imageHeight,
requestKey = requestKey,
result = result, result = result,
) )
return return
} }
var currentDecoderRef = lastDecoderRef var currentDecoderRef = lastDecoderRef
if (currentDecoderRef != null && currentDecoderRef.uri != uri) { if (currentDecoderRef != null && currentDecoderRef.requestKey != requestKey) {
currentDecoderRef = null 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) result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
return return
} }
currentDecoderRef = LastDecoderRef(uri, newDecoder) currentDecoderRef = LastDecoderRef(requestKey, newDecoder)
} }
val decoder = currentDecoderRef.decoder val decoder = currentDecoderRef.decoder
lastDecoderRef = currentDecoderRef lastDecoderRef = currentDecoderRef
@ -119,16 +116,35 @@ class RegionFetcher internal constructor(
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null) result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
} }
} catch (e: Exception) { } 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) 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) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(multiTrackGlideOptions) .apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(MultiPageImage(context, sourceUri, mimeType, pageId)) .load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
.submit() .submit()
try { try {
val bitmap = target.get() val bitmap = target.get()
val tempFile = StorageUtils.createTempFile(context).apply { val tempFile = StorageUtils.createTempFile(context).apply {
@ -143,7 +159,11 @@ class RegionFetcher internal constructor(
} }
private data class LastDecoderRef( private data class LastDecoderRef(
val uri: Uri, val requestKey: Pair<Uri, Int?>,
val decoder: BitmapRegionDecoder, val decoder: BitmapRegionDecoder,
) )
companion object {
private val LOG_TAG = LogUtils.createTag<RegionFetcher>()
}
} }

View file

@ -12,10 +12,8 @@ import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.MultiPageImage 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.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
@ -122,27 +120,15 @@ class ThumbnailFetcher internal constructor(
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565) .format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId")) .signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
.override(width, height) .override(width, height)
if (isVideo(mimeType)) {
val target = if (isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE) options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
Glide.with(context) }
val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(VideoThumbnail(context, uri)) .load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
.submit(width, height) .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)
}
return try { return try {
var bitmap = target.get() var bitmap = target.get()

View file

@ -6,12 +6,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import deckers.thibault.aves.decoder.AvesAppGlideModule
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.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -130,18 +125,10 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
rotationDegrees: Int, rotationDegrees: Int,
isFlipped: Boolean, 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) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(model) .load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId, sizeBytes))
.submit() .submit()
try { try {
var bitmap = withContext(Dispatchers.IO) { target.get() } 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) error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
} }
} catch (e: Exception) { } 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 { } finally {
Glide.with(context).clear(target) 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?) { private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) {
val target = Glide.with(context) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(VideoThumbnail(context, uri)) .load(AvesAppGlideModule.getModel(context, uri, mimeType, null, sizeBytes))
.submit() .submit()
try { try {
val bitmap = withContext(Dispatchers.IO) { target.get() } 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" const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
private const val BUFFER_SIZE = 2 shl 17 // 256kB 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)
} }
} }

View file

@ -1,14 +1,21 @@
package deckers.thibault.aves.decoder package deckers.thibault.aves.decoder
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log import android.util.Log
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.Registry import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.ImageHeaderParser 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.load.resource.bitmap.ExifInterfaceImageHeaderParser
import com.bumptech.glide.module.AppGlideModule 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 import deckers.thibault.aves.utils.compatRemoveIf
@GlideModule @GlideModule
@ -25,4 +32,26 @@ class AvesAppGlideModule : AppGlideModule() {
} }
override fun isManifestParsingEnabled(): Boolean = false 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)
}
}
}
} }

View file

@ -11,16 +11,10 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import com.bumptech.glide.Glide 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.FutureTarget
import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
@ -68,6 +62,7 @@ import java.nio.channels.Channels
import java.util.Date import java.util.Date
import java.util.TimeZone import java.util.TimeZone
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
abstract class ImageProvider { abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) { 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) target = Glide.with(activity.applicationContext)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(model) .load(AvesAppGlideModule.getModel(activity, sourceUri, sourceMimeType, pageId, sourceEntry.sizeBytes))
.submit(targetWidthPx, targetHeightPx) .submit(targetWidthPx, targetHeightPx)
var bitmap = withContext(Dispatchers.IO) { target.get() } var bitmap = withContext(Dispatchers.IO) { target.get() }
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) { if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)

View file

@ -81,7 +81,8 @@ object StorageUtils {
return null return null
} }
val trashDir = File(externalFilesDir, "trash") 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") Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
return null return null
} }
@ -499,7 +500,8 @@ object StorageUtils {
parentFile parentFile
} else { } else {
val directory = File(cleanDirPath) 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") Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
return null return null
} }
@ -712,7 +714,8 @@ object StorageUtils {
fun createTempFile(context: Context, extension: String? = null): File { fun createTempFile(context: Context, extension: String? = null): File {
val directory = getTempDirectory(context) val directory = getTempDirectory(context)
if (!directory.exists() && !directory.mkdirs()) { directory.mkdirs()
if (!directory.exists()) {
throw IOException("failed to create directories at path=$directory") throw IOException("failed to create directories at path=$directory")
} }
val tempFile = File.createTempFile("aves", extension, directory) val tempFile = File.createTempFile("aves", extension, directory)