ios, android, desktop: load JPEG, PNG, GIF and WebP image files (#7956)

This commit is contained in:
Evgeny @ SimpleX Chat authored and GitHub committed 2026-10-31 07:14:01 +00:00
1 parent 1fd7ed8327
commit 5230eadbab
7 files changed
+94 -7

No files matched your search

@@ -112,7 +112,7 @@ private func imagesWithinBudget() -> Modifier {
guard parts.count == 2,
parts[0].hasSuffix(";base64"),
let data = Data(base64Encoded: String(parts[1])),
data.starts(with: [0xFF, 0xD8, 0xFF]) || data.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
ImageFileType.jpeg.matches(data) || ImageFileType.png.matches(data),
let size = UIImage(data: data)?.size,
size.width <= MAX_MARKDOWN_IMAGE_DIMENSION,
size.height <= MAX_MARKDOWN_IMAGE_DIMENSION,
+31
View File
@@ -24,6 +24,7 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? {
let filePath = getAppFilePath(fileSource.filePath)
do {
let data = try getFileData(filePath, fileSource.cryptoArgs)
guard isImageFile(data) else { return nil }
let img = UIImage(data: data)
do {
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
@@ -38,6 +39,36 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? {
return nil
}
public enum ImageFileType: CaseIterable {
case jpeg
case png
case gif
case webp
var signatures: [[UInt8?]] {
switch self {
case .jpeg: [[0xFF, 0xD8, 0xFF]]
case .png: [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]]
case .gif: [ascii("GIF87a"), ascii("GIF89a")]
case .webp: [ascii("RIFF") + [nil, nil, nil, nil] + ascii("WEBP")]
}
}
public func matches(_ data: Data) -> Bool {
signatures.contains { signature in
data.count >= signature.count && zip(signature, data).allSatisfy { b, d in b == nil || b == d }
}
}
}
public func isImageFile(_ data: Data) -> Bool {
ImageFileType.allCases.contains { $0.matches(data) }
}
private func ascii(_ s: String) -> [UInt8?] {
s.utf8.map { $0 }
}
public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
if let cfArgs = cfArgs {
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
@@ -183,6 +183,7 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
} else {
File(getAppFilePath(file.fileName)).readBytes()
}
if (!isImageFile(data)) return null
decodeSampledBitmapFromByteArray(data, 1000, 1000).asImageBitmap() to data
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
@@ -29,6 +29,21 @@ expect fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap
expect fun isImage(uri: URI): Boolean
expect fun isAnimImage(uri: URI, drawable: Any?): Boolean
enum class ImageFileType(val signatures: List<List<Int?>>) {
JPEG(listOf(listOf(0xFF, 0xD8, 0xFF))),
PNG(listOf(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))),
GIF(listOf(ascii("GIF87a"), ascii("GIF89a"))),
WEBP(listOf(ascii("RIFF") + List(4) { null } + ascii("WEBP")));
fun matches(data: ByteArray): Boolean = signatures.any { signature ->
data.size >= signature.size && signature.withIndex().all { (i, b) -> b == null || b == data[i].toInt() and 0xFF }
}
}
fun isImageFile(data: ByteArray): Boolean = ImageFileType.entries.any { it.matches(data) }
private fun ascii(s: String): List<Int?> = s.map { it.code }
expect fun loadImageBitmap(inputStream: InputStream): ImageBitmap
expect fun resizeImageFileUri(uri: URI, maxSize: Int): String
@@ -54,10 +54,7 @@ import java.nio.charset.CodingErrorAction
import java.util.Base64
private const val DATA_IMAGE = "data:image/"
private val IMAGE_SIGNATURES = listOf(
listOf(0xFF, 0xD8, 0xFF),
listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
)
private val IMAGE_TYPES = listOf(ImageFileType.JPEG, ImageFileType.PNG)
private const val MAX_MARKDOWN_VIEW_SIZE: Long = 1_048_576
private const val MAX_MARKDOWN_LINE_LENGTH = 15_000
private const val MAX_MARKDOWN_DEPTH = 32
@@ -295,6 +292,5 @@ private class DataImageTransformer : ImageTransformer {
private fun hasImageSignature(base64: String): Boolean {
val header = runCatching { Base64.getDecoder().decode(base64.take(12)) }.getOrNull() ?: return false
val bytes = header.map { it.toInt() and 0xFF }
return IMAGE_SIGNATURES.any { bytes.take(it.size) == it }
return IMAGE_TYPES.any { it.matches(header) }
}
@@ -142,6 +142,7 @@ 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()
if (!isImageFile(data)) return null
val bitmap = decodeBoundedImage(data, MAX_THUMBNAIL_DIMENSION)
(bitmap to data).also { loadedImageCache[filePath] = it }
} catch (e: Exception) {
@@ -0,0 +1,43 @@
package chat.simplex.app
import chat.simplex.common.platform.ImageFileType
import chat.simplex.common.platform.isImageFile
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import javax.imageio.ImageIO
import kotlin.test.Test
import kotlin.test.assertTrue
class ImageFileTypeTest {
@Test
fun testJpegImagesLoad() {
assertTrue(ImageFileType.JPEG.matches(encoded("jpg")))
assertTrue(isImageFile(encoded("jpg")))
}
@Test
fun testPngImagesLoad() {
assertTrue(ImageFileType.PNG.matches(encoded("png")))
assertTrue(isImageFile(encoded("png")))
}
@Test
fun testGifImagesLoad() {
assertTrue(ImageFileType.GIF.matches(encoded("gif")))
assertTrue(isImageFile(encoded("gif")))
}
@Test
fun testWebpImagesLoad() {
assertTrue(ImageFileType.WEBP.matches(webpHeader))
assertTrue(isImageFile(webpHeader))
}
private fun encoded(format: String): ByteArray {
val out = ByteArrayOutputStream()
ImageIO.write(BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB), format, out)
return out.toByteArray()
}
private val webpHeader = listOf(0x52, 0x49, 0x46, 0x46, 0x36, 0xC8, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58).map { it.toByte() }.toByteArray()
}