diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 22005bbfc4..32b1d5cff0 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -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? 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 diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageDecodeBoundsTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageDecodeBoundsTest.kt index 4c4b8c3ab6..3a3c1e4de8 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageDecodeBoundsTest.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageDecodeBoundsTest.kt @@ -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 {