mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-25 17:01:45 +00:00
desktop: fix low-quality image previews from over-downsampling (#7267)
getLoadedImage subsampled with an OR cap on the larger side, so tall images (e.g. phone screenshots) were reduced far below the display size and rendered blurry. Mirror Android's calculateInSampleSize (keep the smaller side at the target) so previews stay sharp, and add a decoded- pixel ceiling so extreme aspect ratios can't blow up decode memory.
This commit is contained in:
+17
-5
@@ -26,12 +26,15 @@ 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
|
||||
// Full-screen view target: the smaller side is kept at or above this (the larger side may stay bigger), matching Android
|
||||
private const val MAX_IMAGE_DIMENSION = 4320
|
||||
// Chat render (getLoadedImage) downsamples to this, matching Android's getLoadedImage target
|
||||
// Chat render (getLoadedImage) target, 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
|
||||
// Hard ceiling on the decoded raster in pixels, independent of aspect ratio, so an extreme-aspect image within the
|
||||
// source cap can't blow up memory. Sized to the full-screen target's area (~18.7 MP -> ~75 MB at 4 bytes/px).
|
||||
private const val MAX_DECODED_PIXELS = MAX_IMAGE_DIMENSION * MAX_IMAGE_DIMENSION
|
||||
private fun fontStyle(color: String) =
|
||||
SpanStyle(color = Color(color.replace("#", "ff").toLongOrNull(16) ?: Color.White.toArgb().toLong()))
|
||||
|
||||
@@ -157,7 +160,7 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
|
||||
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())
|
||||
Log.e(TAG, "Unable to load image: " + e.stackTraceToString())
|
||||
null
|
||||
}
|
||||
} else {
|
||||
@@ -227,10 +230,19 @@ internal fun decodeBoundedBufferedImage(data: ByteArray, maxDimension: Int): Buf
|
||||
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)
|
||||
// Power-of-two subsampling factor for bounded decoding. First keeps the smaller side at or above maxDimension
|
||||
// (mirroring Android's calculateInSampleSize) so portrait images stay sharp instead of being over-subsampled by a
|
||||
// larger-side cap; then bounds the total decoded pixels so an extreme aspect ratio can't exceed the memory ceiling.
|
||||
internal fun imageSampleSize(width: Int, height: Int, maxDimension: Int): Int {
|
||||
var sampleSize = 1
|
||||
while (width / sampleSize > maxDimension || height / sampleSize > maxDimension) {
|
||||
if (height > maxDimension || width > maxDimension) {
|
||||
val halfHeight = height / 2
|
||||
val halfWidth = width / 2
|
||||
while (halfHeight / sampleSize >= maxDimension && halfWidth / sampleSize >= maxDimension) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
}
|
||||
while ((width / sampleSize) * (height / sampleSize) > MAX_DECODED_PIXELS) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
return sampleSize
|
||||
|
||||
+34
-8
@@ -15,11 +15,28 @@ 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
|
||||
fun testSampleSizeKeepsSmallerSideAtTarget() {
|
||||
// At or below the target on both sides -> no subsampling
|
||||
assertEquals(1, imageSampleSize(4320, 4320, 4320))
|
||||
// Portrait phone screenshot at the chat-render target: the narrow side is already below the target, so it is kept
|
||||
// at full resolution instead of being over-subsampled (this was the reported blurry-preview regression)
|
||||
assertEquals(1, imageSampleSize(1179, 2556, 1000))
|
||||
// Only the larger side is over the target while the smaller side is tiny -> kept at full resolution
|
||||
assertEquals(1, imageSampleSize(4321, 100, 4320))
|
||||
// Both sides well above the target -> halved until the smaller side approaches the target
|
||||
assertEquals(2, imageSampleSize(8640, 8640, 4320))
|
||||
assertEquals(4, imageSampleSize(16384, 16384, 4320))
|
||||
assertEquals(4, imageSampleSize(4000, 4000, 1000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSampleSizeCapsDecodedPixelsForExtremeAspectRatios() {
|
||||
// Smaller-side semantics alone would keep these at full resolution (sampleSize 1, since the narrow side is below
|
||||
// the target), decoding ~131 MB (thumbnail) / ~566 MB (full-screen) rasters. The decoded-pixel ceiling forces
|
||||
// extra subsampling so memory stays bounded, even though the narrow side then drops below the target.
|
||||
assertEquals(2, imageSampleSize(16384, 1999, 1000)) // 16384x1999 (~32.7 MP) -> 8192x999 (~8 MP)
|
||||
assertEquals(4, imageSampleSize(16384, 8639, 4320)) // 16384x8639 (~141 MP) -> 4096x2159 (~8.8 MP)
|
||||
// A large near-square image is bounded to the ceiling rather than left at the smaller-side target
|
||||
assertEquals(4, imageSampleSize(16384, 16384, 4320)) // 8192x8192 (~67 MP) would exceed the ceiling -> 4096x4096
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -36,11 +53,20 @@ class ImageDecodeBoundsTest {
|
||||
}
|
||||
|
||||
@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.
|
||||
fun testDecodeAcceptsElongatedImageWithinSourceLimit() {
|
||||
// Long, thin image at the source-dimension boundary: the raster is small, so it is decoded at full size
|
||||
// (like Android) rather than over-subsampled to fit the larger side under the target
|
||||
val image = decodeBoundedBufferedImage(encodePng(16384, 8), 4320)
|
||||
assertTrue(image.width <= 4320, "expected downsampled width, got ${image.width}")
|
||||
assertEquals(16384, image.width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeDownsamplesLargeImageInsteadOfRejecting() {
|
||||
// Large image with both sides above the target: must decode (not reject) AND be subsampled - proves subsampling
|
||||
// is honored so the decoded raster stays bounded, while keeping the smaller side at or above the target.
|
||||
val image = decodeBoundedBufferedImage(encodePng(2400, 2400), 1000)
|
||||
assertTrue(image.width < 2400, "expected downsampled width, got ${image.width}")
|
||||
assertTrue(image.width >= 1000, "expected smaller side kept at or above target, got ${image.width}")
|
||||
}
|
||||
|
||||
private fun encodePng(width: Int, height: Int): ByteArray {
|
||||
|
||||
Reference in New Issue
Block a user