multiplatform: improve image processing (#7082)

* Bound attachment image decoding

* desktop: downsample attachment images like android; keep user picks uncapped

---------

Co-authored-by: Paul Bottinelli <paul.bottinelli@trailofbits.com>
This commit is contained in:
shandPaul Bottinelli authored and GitHub committed 2026-07-10 09:02:27 +01:00
1 parent 41379907f2
commit 56040adf28
3 files changed
+110 -5

No files matched your search

@@ -214,13 +214,14 @@ private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, req
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeByteArray(data, 0, data.size)
BitmapFactory.decodeByteArray(data, 0, data.size, this)
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
inJustDecodeBounds = false
BitmapFactory.decodeByteArray(data, 0, data.size)
BitmapFactory.decodeByteArray(data, 0, data.size, this)
?: throw IOException("Unable to decode image")
}
}
@@ -13,8 +13,10 @@ import chat.simplex.common.model.readCryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState
import kotlinx.coroutines.delay
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.net.URI
import java.util.*
import javax.imageio.ImageIO
@@ -24,6 +26,12 @@ import kotlin.io.encoding.ExperimentalEncodingApi
private val bStyle = SpanStyle(fontWeight = FontWeight.Bold)
private val iStyle = SpanStyle(fontStyle = FontStyle.Italic)
private val uStyle = SpanStyle(textDecoration = TextDecoration.Underline)
// Full-screen view downsamples to fit within this on each side
private const val MAX_IMAGE_DIMENSION = 4320
// Chat render (getLoadedImage) downsamples to this, matching Android's getLoadedImage target
private const val MAX_THUMBNAIL_DIMENSION = 1000
// Source images larger than this on either side are rejected (bounds the decoder's per-scanline buffer)
private const val MAX_SOURCE_IMAGE_DIMENSION = 16384
private fun fontStyle(color: String) =
SpanStyle(color = Color(color.replace("#", "ff").toLongOrNull(16) ?: Color.White.toArgb().toLong()))
@@ -146,8 +154,8 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
return if (filePath != null) {
loadedImageCache[filePath] ?: try {
val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes()
val bitmap = getBitmapFromByteArray(data, false)
if (bitmap != null) (bitmap to data).also { loadedImageCache[filePath] = it } else null
val bitmap = decodeBoundedImage(data, MAX_THUMBNAIL_DIMENSION)
(bitmap to data).also { loadedImageCache[filePath] = it }
} catch (e: Exception) {
Log.e(TAG, "Unable to read crypto file: " + e.stackTraceToString())
null
@@ -165,6 +173,8 @@ actual fun getFileSize(uri: URI): Long? = uri.toFile().length()
actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? =
try {
// No dimension cap here: this path decodes user-picked local files (image picker, compose, save),
// not untrusted received attachments. The cap is applied in getBitmapFromByteArray (auto-rendered content).
uri.inputStream().use {
ImageIO.read(it).toComposeImageBitmap()
}
@@ -177,7 +187,7 @@ actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitma
actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? =
try {
ImageIO.read(ByteArrayInputStream(data)).toComposeImageBitmap()
decodeBoundedImage(data, MAX_IMAGE_DIMENSION)
} catch (e: Exception) {
Log.e(TAG, "Error while encoding bitmap from byte array: ${e.stackTraceToString()}")
if (withAlertOnException) showImageDecodingException()
@@ -185,6 +195,47 @@ actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean
null
}
private fun decodeBoundedImage(data: ByteArray, maxDimension: Int): ImageBitmap =
decodeBoundedBufferedImage(data, maxDimension).toComposeImageBitmap()
// Decodes downloaded/auto-rendered image bytes with bounded memory: rejects absurd source dimensions,
// then downsamples (like Android's inSampleSize) so even large legitimate images decode to a bounded raster.
internal fun decodeBoundedBufferedImage(data: ByteArray, maxDimension: Int): BufferedImage {
val stream = ImageIO.createImageInputStream(ByteArrayInputStream(data))
?: throw IOException("Unsupported image format")
stream.use {
val readers = ImageIO.getImageReaders(it)
if (!readers.hasNext()) throw IOException("Unsupported image format")
val reader = readers.next()
try {
reader.input = it
val width = reader.getWidth(0)
val height = reader.getHeight(0)
if (!sourceDimensionsWithinLimits(width, height)) throw IOException("Image dimensions exceed limit")
val sampleSize = imageSampleSize(width, height, maxDimension)
val param = reader.defaultReadParam.apply {
if (sampleSize > 1) setSourceSubsampling(sampleSize, sampleSize, 0, 0)
}
return reader.read(0, param) ?: throw IOException("Unable to decode image")
} finally {
reader.dispose()
}
}
}
internal fun sourceDimensionsWithinLimits(width: Int, height: Int): Boolean =
width in 1..MAX_SOURCE_IMAGE_DIMENSION && height in 1..MAX_SOURCE_IMAGE_DIMENSION
// Smallest power-of-two subsampling factor so both dimensions fit within maxDimension (mirrors Android inSampleSize)
internal fun imageSampleSize(width: Int, height: Int, maxDimension: Int): Int {
var sampleSize = 1
while (width / sampleSize > maxDimension || height / sampleSize > maxDimension) {
sampleSize *= 2
}
return sampleSize
}
// LALAL implement to support animated drawable
actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? = null
@@ -0,0 +1,53 @@
package chat.simplex.app
import chat.simplex.common.views.helpers.decodeBoundedBufferedImage
import chat.simplex.common.views.helpers.imageSampleSize
import chat.simplex.common.views.helpers.sourceDimensionsWithinLimits
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.IOException
import javax.imageio.ImageIO
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ImageDecodeBoundsTest {
@Test
fun testSampleSizeKeepsImagesWithinTargetDimension() {
assertEquals(1, imageSampleSize(4320, 4320, 4320)) // at the target -> no subsampling
assertEquals(2, imageSampleSize(4321, 100, 4320)) // one side just over -> halved
assertEquals(2, imageSampleSize(8640, 8640, 4320))
assertEquals(4, imageSampleSize(16384, 16384, 4320))
}
@Test
fun testSourceDimensionsRejectAbsurdSizes() {
assertTrue(sourceDimensionsWithinLimits(16384, 16384)) // generous upper bound, downsampled later
assertFalse(sourceDimensionsWithinLimits(16385, 1))
assertFalse(sourceDimensionsWithinLimits(0, 1))
}
@Test
fun testDecodeRejectsImageExceedingSourceLimit() {
// Rejected at the dimension check before any full-size allocation
assertFailsWith<IOException> { decodeBoundedBufferedImage(encodePng(16385, 1), 4320) }
}
@Test
fun testDecodeDownsamplesLargeDimensionImageInsteadOfRejecting() {
// Image-bomb shaped: large declared width (within source cap), tiny encoded size.
// Must decode (not reject) AND be downsampled - proves subsampling is honored, so memory stays bounded.
val image = decodeBoundedBufferedImage(encodePng(16384, 8), 4320)
assertTrue(image.width <= 4320, "expected downsampled width, got ${image.width}")
}
private fun encodePng(width: Int, height: Int): ByteArray {
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
return ByteArrayOutputStream().use { out ->
ImageIO.write(image, "png", out)
out.toByteArray()
}
}
}