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;