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:
sh
2026-07-17 16:58:40 +01:00
committed by GitHub
parent bd86d6f403
commit 27da94e11d
2 changed files with 51 additions and 13 deletions
@@ -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
@@ -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 {