mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 19:42:13 +00:00
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:
1 parent
41379907f2
commit
56040adf28
3 files changed
+110
-5
No files matched your search
+3
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
-3
@@ -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
|
||||
|
||||
|
||||
+53
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user