diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 3e514be..09995b9 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -312,7 +312,8 @@ internal fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: Messenger LaunchedEffect(ref.id) { val bytes = model.attachmentBytes(ref) if (bytes == null) { failed = true; return@LaunchedEffect } - bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + // Bounded: these bytes came from a peer. See ImagePrep.decodeBounded. + bitmap = ImagePrep.decodeBounded(bytes) if (bitmap == null) failed = true } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt index e8327e8..4c90bf4 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt @@ -84,6 +84,30 @@ object ImagePrep { } /** Decode with `inSampleSize`, so the full-size bitmap never exists. */ + /** + * Decode bytes that came from somebody else, bounded. + * + * 🔴 **The render paths were calling `decodeByteArray` with no options at + * all.** Sending was careful — bounds first, then a sampled decode — but + * the two places that draw an image RECEIVED from a peer decoded at full + * size. Inbound attachments are capped at 12 MiB, and 12 MiB of cleverly + * chosen JPEG is a few hundred megabytes of ARGB_8888 once expanded. A + * contact could send one image and crash the app every time the thread or + * the story was opened — a conversation wedged from the outside, with no + * way for the victim to get past it. + * + * ⚠️ Bounds are read first so an enormous picture is never fully decoded + * just to discover how big it is. That is the whole point; a size check on + * the byte count alone does not bound the decoded bitmap. + */ + fun decodeBounded(source: ByteArray, maxSide: Int = STANDARD_MAX_SIDE): Bitmap? { + if (source.isEmpty()) return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(source, 0, source.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + return decodeScaled(source, bounds, maxSide) + } + private fun decodeScaled(source: ByteArray, bounds: BitmapFactory.Options, maxSide: Int): Bitmap? { var sample = 1 while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSide * 2) sample *= 2 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 5c44a33..82725eb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -223,7 +223,8 @@ fun StoryComposerDialog( if (draft.duration > 0) StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap() else - android.graphics.BitmapFactory.decodeByteArray(draft.bytes, 0, draft.bytes.size) + // Own draft, but a 50MP photo is just as much bitmap. + ImagePrep.decodeBounded(draft.bytes) ?.asImageBitmap() }.getOrNull() } @@ -439,7 +440,8 @@ private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerView LaunchedEffect(ref.id) { val bytes = model.attachmentBytes(ref) if (bytes == null) { failed.value = true; return@LaunchedEffect } - bitmap.value = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + // Bounded: a received story's bytes are a peer's choice. + bitmap.value = ImagePrep.decodeBounded(bytes) if (bitmap.value == null) failed.value = true } val loaded = bitmap.value