Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/develop' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
weblate committed Mar 2, 2025
2 parents 476139d + b224709 commit cfba01a
Show file tree
Hide file tree
Showing 12 changed files with 191 additions and 128 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import android.util.Log
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getDecodedBytes
import deckers.thibault.aves.utils.BitmapUtils.getEncodedBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MemoryUtils
Expand Down Expand Up @@ -81,11 +81,13 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
return
}

val decoded = arguments["decoded"] as Boolean
val mimeType = arguments["mimeType"] as String?
val uri = (arguments["uri"] as String?)?.toUri()
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean
val isAnimated = arguments["isAnimated"] as Boolean
val pageId = arguments["pageId"] as Int?

if (mimeType == null || uri == null) {
Expand All @@ -94,19 +96,31 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
return
}

if (isVideo(mimeType)) {
streamVideoByGlide(uri, mimeType, sizeBytes)
} else if (!canDecodeWithFlutter(mimeType, pageId, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, pageId, mimeType, sizeBytes, rotationDegrees, isFlipped)
} else {
if (canDecodeWithFlutter(mimeType, isAnimated) && !decoded) {
// to be decoded by Flutter
streamImageAsIs(uri, mimeType, sizeBytes)
streamOriginalEncodedBytes(uri, mimeType, sizeBytes)
} else if (isVideo(mimeType)) {
streamVideoByGlide(
uri = uri,
mimeType = mimeType,
sizeBytes = sizeBytes,
decoded = decoded,
)
} else {
streamImageByGlide(
uri = uri,
pageId = pageId,
mimeType = mimeType,
sizeBytes = sizeBytes,
rotationDegrees = rotationDegrees,
isFlipped = isFlipped,
decoded = decoded,
)
}
endOfStream()
}

private fun streamImageAsIs(uri: Uri, mimeType: String, sizeBytes: Long?) {
private fun streamOriginalEncodedBytes(uri: Uri, mimeType: String, sizeBytes: Long?) {
if (!MemoryUtils.canAllocate(sizeBytes)) {
error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
return
Expand All @@ -126,6 +140,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
sizeBytes: Long?,
rotationDegrees: Int,
isFlipped: Boolean,
decoded: Boolean,
) {
val target = Glide.with(context)
.asBitmap()
Expand All @@ -139,11 +154,12 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
}
if (bitmap != null) {
val recycle = false
val canHaveAlpha = MimeTypes.canHaveAlpha(mimeType)
var bytes = bitmap.getEncodedBytes(canHaveAlpha, recycle = recycle)
if (bytes != null && bytes.isEmpty()) {
bytes = BitmapUtils.tryPixelFormatConversion(bitmap)?.getEncodedBytes(canHaveAlpha, recycle = recycle)
val bytes = if (decoded) {
bitmap.getDecodedBytes(recycle)
} else {
bitmap.getEncodedBytes(canHaveAlpha = MimeTypes.canHaveAlpha(mimeType), recycle = recycle)
}

if (MemoryUtils.canAllocate(sizeBytes)) {
success(bytes)
} else {
Expand All @@ -159,7 +175,7 @@ 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?, decoded: Boolean) {
val target = Glide.with(context)
.asBitmap()
.apply(AvesAppGlideModule.uncachedFullImageOptions)
Expand All @@ -168,7 +184,13 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
try {
val bitmap = withContext(Dispatchers.IO) { target.get() }
if (bitmap != null) {
val bytes = bitmap.getEncodedBytes(canHaveAlpha = false, recycle = false)
val recycle = false
val bytes = if (decoded) {
bitmap.getDecodedBytes(recycle)
} else {
bitmap.getEncodedBytes(canHaveAlpha = false, recycle = false)
}

if (MemoryUtils.canAllocate(sizeBytes)) {
success(bytes)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import androidx.core.graphics.scale

@GlideModule
class TiffGlideModule : LibraryGlideModule() {
Expand Down Expand Up @@ -96,7 +97,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
dstWidth = width
dstHeight = (width / aspectRatio).toInt()
}
callback.onDataReady(Bitmap.createScaledBitmap(bitmap, dstWidth, dstHeight, true))
callback.onDataReady(bitmap.scale(dstWidth, dstHeight))
} else {
callback.onDataReady(bitmap)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package deckers.thibault.aves.decoder

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
Expand All @@ -20,53 +21,61 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getEncodedBytes
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.io.IOException
import kotlin.math.ceil
import kotlin.math.roundToInt

@GlideModule
class VideoThumbnailGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(VideoThumbnail::class.java, InputStream::class.java, VideoThumbnailLoader.Factory())
registry.append(VideoThumbnail::class.java, Bitmap::class.java, VideoThumbnailLoader.Factory())
}
}

class VideoThumbnail(val context: Context, val uri: Uri)

internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, Bitmap> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
}

override fun handles(model: VideoThumbnail): Boolean = true

internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
internal class Factory : ModelLoaderFactory<VideoThumbnail, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, Bitmap> = VideoThumbnailLoader()

override fun teardown() {}
}
}

internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
ioScope.launch {
val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever == null) {
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
} else {
try {
var bytes = retriever.embeddedPicture
if (bytes == null) {
var bitmap: Bitmap? = null

retriever.embeddedPicture?.let { bytes ->
try {
bitmap = BitmapFactory.decodeStream(ByteArrayInputStream(bytes))
} catch (e: IOException) {
// ignore
}
}

if (bitmap == null) {
// there is no consistent strategy across devices to match
// the thumbnails returned by the content resolver / Media Store
// so we derive one in an arbitrary way
Expand Down Expand Up @@ -111,7 +120,7 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
}

// the returned frame is already rotated according to the video metadata
val frame = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
bitmap = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
val pixelCount = dstWidth * dstHeight
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
Expand All @@ -134,13 +143,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
retriever.getFrameAtTime(timeMicros, option)
}
}
bytes = frame?.getEncodedBytes(canHaveAlpha = false, recycle = false)
}

if (bytes != null) {
callback.onDataReady(ByteArrayInputStream(bytes))
if (bitmap == null) {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame for uri=${model.uri}"))
} else {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
callback.onDataReady(bitmap)
}
} catch (e: Exception) {
callback.onLoadFailed(e)
Expand Down Expand Up @@ -175,7 +183,7 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
// cannot cancel
override fun cancel() {}

override fun getDataClass(): Class<InputStream> = InputStream::class.java
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java

override fun getDataSource(): DataSource = DataSource.LOCAL
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,39 +139,6 @@ object BitmapUtils {
return null
}

// On some devices, RGBA_1010102 config can be displayed directly from the hardware buffer,
// but the native image decoder cannot convert RGBA_1010102 to another config like ARGB_8888,
// so we manually check the config and convert the pixels as a fallback mechanism.
fun tryPixelFormatConversion(bitmap: Bitmap): Bitmap? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && bitmap.config == Bitmap.Config.RGBA_1010102) {
val byteCount = bitmap.byteCount
if (MemoryUtils.canAllocate(byteCount)) {
val bytes = ByteBuffer.allocate(byteCount).apply {
bitmap.copyPixelsToBuffer(this)
rewind()
}.array()
val srcColorSpace = bitmap.colorSpace
if (srcColorSpace != null) {
val dstColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
val connector = ColorSpace.connect(srcColorSpace, dstColorSpace)
rgba1010102toArgb8888(bytes, connector)

val hasAlpha = false
return createBitmap(
bitmap.width,
bitmap.height,
Bitmap.Config.ARGB_8888,
hasAlpha = hasAlpha,
colorSpace = dstColorSpace,
).apply {
copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
}
}
}
}
return null
}

@RequiresApi(Build.VERSION_CODES.O)
private fun argb8888toArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
// unpacking from ARGB_8888 and packing to ARGB_8888
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@ object MimeTypes {
else -> false
}

// as of Flutter v3.16.4, with additional custom handling for SVG
fun canDecodeWithFlutter(mimeType: String, pageId: Int?, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
// as of Flutter v3.16.4, with additional custom handling for SVG in Dart,
// while handling still PNG and JPEG on Android for color space and config conversion
fun canDecodeWithFlutter(mimeType: String, isAnimated: Boolean) = when (mimeType) {
GIF, WEBP, BMP, WBMP, ICO, SVG -> true
JPEG -> (pageId ?: 0) == 0
PNG -> (rotationDegrees ?: 0) == 0 && !(isFlipped ?: false)
JPEG, PNG -> isAnimated
else -> false
}

Expand Down
Loading

0 comments on commit cfba01a

Please sign in to comment.