diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ab21a917..7599e4d92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file.
- opening motion photo embedded video when video track is not the first one
- some SVG rendering issues
- decoding of SVG containing references to namespaces in !ATTLIST
+- fallback decoding of images packed in RGBA_1010102 config
## [v1.12.3] - 2025-02-06
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 c6c9d0220..0b1728a8f 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
@@ -11,6 +11,7 @@ import com.bumptech.glide.Glide
import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
+import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
@@ -111,7 +112,14 @@ class RegionFetcher internal constructor(
}
val bitmap = decoder.decodeRegion(effectiveRect, options)
if (bitmap != null) {
- result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = true))
+ val canHaveAlpha = MimeTypes.canHaveAlpha(mimeType)
+ val recycle = false
+ var bytes = bitmap.getBytes(canHaveAlpha, recycle = recycle)
+ if (bytes != null && bytes.isEmpty()) {
+ bytes = BitmapUtils.tryPixelFormatConversion(bitmap)?.getBytes(canHaveAlpha, recycle = recycle)
+ }
+ bitmap.recycle()
+ result.success(bytes)
} else {
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
}
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 0af9fc5ed..a72a427af 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
@@ -7,6 +7,7 @@ import android.os.Build
import android.provider.MediaStore
import android.util.Size
import androidx.annotation.RequiresApi
+import androidx.core.net.toUri
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
@@ -14,6 +15,7 @@ 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.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes
@@ -24,7 +26,6 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodChannel
-import androidx.core.net.toUri
class ThumbnailFetcher internal constructor(
private val context: Context,
@@ -78,7 +79,13 @@ class ThumbnailFetcher internal constructor(
}
if (bitmap != null) {
- result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false, quality = quality))
+ val canHaveAlpha = MimeTypes.canHaveAlpha(mimeType)
+ val recycle = false
+ var bytes = bitmap.getBytes(canHaveAlpha, quality, recycle)
+ if (bytes != null && bytes.isEmpty()) {
+ bytes = BitmapUtils.tryPixelFormatConversion(bitmap)?.getBytes(canHaveAlpha, quality, recycle)
+ }
+ result.success(bytes)
} else {
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {
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 33ec694b1..ccb9b0626 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
@@ -8,6 +8,7 @@ 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.getBytes
import deckers.thibault.aves.utils.LogUtils
@@ -137,7 +138,12 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
if (bitmap != null) {
- val bytes = bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)
+ val recycle = false
+ val canHaveAlpha = MimeTypes.canHaveAlpha(mimeType)
+ var bytes = bitmap.getBytes(canHaveAlpha, recycle = recycle)
+ if (bytes != null && bytes.isEmpty()) {
+ bytes = BitmapUtils.tryPixelFormatConversion(bitmap)?.getBytes(canHaveAlpha, recycle = recycle)
+ }
if (MemoryUtils.canAllocate(sizeBytes)) {
success(bytes)
} else {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt
index 95999d60e..1a3acfce9 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt
@@ -2,13 +2,18 @@ package deckers.thibault.aves.utils
import android.content.Context
import android.graphics.Bitmap
+import android.graphics.ColorSpace
+import android.os.Build
import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.graphics.createBitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import deckers.thibault.aves.metadata.Metadata.getExifCode
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
object BitmapUtils {
private val LOG_TAG = LogUtils.createTag()
@@ -21,6 +26,7 @@ object BitmapUtils {
private val mutex = Mutex()
const val ARGB_8888_BYTE_SIZE = 4
+ private const val RGBA_1010102_BYTE_SIZE = 4
suspend fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
val stream: ByteArrayOutputStream
@@ -62,6 +68,76 @@ 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
+ }
+
+ // convert bytes, without reallocation:
+ // - from config RGBA_1010102 to ARGB_8888,
+ // - from original color space to sRGB.
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun rgba1010102toArgb8888(bytes: ByteArray, connector: ColorSpace.Connector) {
+ val max10Bits = 0x3ff.toFloat()
+ val dstAlpha = 0xff.toByte()
+
+ val byteCount = bytes.size
+ for (i in 0.. [AABBBBBB BBBBGGGG GGGGGGRR RRRRRRRR]
+// val iA = ((i3 and 0xc0) shr 6)
+ val iB = ((i3 and 0x3f) shl 4) or ((i2 and 0xf0) shr 4)
+ val iG = ((i2 and 0x0f) shl 6) or ((i1 and 0xfc) shr 2)
+ val iR = ((i1 and 0x03) shl 8) or ((i0 and 0xff) shr 0)
+
+ // components as floats in sRGB
+ val srgbFloats = connector.transform(iR / max10Bits, iG / max10Bits, iB / max10Bits)
+ val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
+ val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
+ val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
+
+ // packing to ARGB_8888
+ // stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
+ bytes[i + 3] = dstAlpha
+ bytes[i + 2] = srgbB.toByte()
+ bytes[i + 1] = srgbG.toByte()
+ bytes[i] = srgbR.toByte()
+ }
+ }
+
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
if (rotationDegrees == 0 && !isFlipped) return bitmap
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BmpWriter.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BmpWriter.kt
index 8e097c77b..bfd9947af 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BmpWriter.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BmpWriter.kt
@@ -90,12 +90,7 @@ object BmpWriter {
var column = 0
while (column < biWidth) {
- /*
- alpha: (value shr 24 and 0xFF).toByte()
- red: (value shr 16 and 0xFF).toByte()
- green: (value shr 8 and 0xFF).toByte()
- blue: (value and 0xFF).toByte()
- */
+ // non-premultiplied ARGB values in the sRGB color space
value = pixels[column]
// blue: [0], green: [1], red: [2]
rgb[0] = (value and 0xFF).toByte()
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ByteUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ByteUtils.kt
index f45236ba4..a0e1e834f 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ByteUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ByteUtils.kt
@@ -8,6 +8,8 @@ fun ByteBuffer.toByteArray(): ByteArray {
return bytes
}
+fun Int.toHex(): String = "0x${byteArrayOf(shr(8).toByte(), toByte()).toHex()}"
+
fun ByteArray.toHex(): String = joinToString(separator = "") { it.toHex() }
fun Byte.toHex(): String = "%02x".format(this)
\ No newline at end of file
diff --git a/lib/model/entry/extensions/props.dart b/lib/model/entry/extensions/props.dart
index 88d8b5b1a..c466ebc4c 100644
--- a/lib/model/entry/extensions/props.dart
+++ b/lib/model/entry/extensions/props.dart
@@ -34,7 +34,7 @@ extension ExtraAvesEntryProps on AvesEntry {
// size
- bool get useTiles => (width > 4096 || height > 4096) && !isAnimated;
+ bool get useTiles => !isAnimated;
bool get isSized => width > 0 && height > 0;