From cf6a60882f0aca2b19f719248d151f8e9cec1acf Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:22:34 +0000 Subject: [PATCH] Fix IndexOutOfBounds crash when sending media with an undecodable preview (#7050) * android, desktop: skip media with no decodable preview to fix IndexOutOfBounds on send processPickedMedia appended to MediaPreview.content unconditionally for videos (and size-ok animated images) but only appended to images when a preview bitmap existed. getBitmapFromVideo returns a null preview for undecodable/corrupted videos without throwing, desyncing the two lists; sendMessageAsync then indexes images[index] past its end and crashes. Pair both appends behind a non-null bitmap so the lists stay equal-length and index-aligned, and skip only the bad item so the rest of the picked batch still sends. A video skipped this way shows showVideoDecodingException(), guarded by hasAlertsShown() so it neither stacks across multiple bad items nor duplicates the alert already shown on getBitmapFromVideo's exception path. Image decode failures are already surfaced earlier by getBitmapFromUri. * docs: add plan justifying media preview alignment fix --- .../simplex/common/views/chat/ComposeView.kt | 24 ++++- ...-06-04-fix-corrupted-video-upload-error.md | 100 ++++++++++++++++++ 2 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 plans/2026-06-04-fix-corrupted-video-upload-error.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 3ae7f9d55b..21290b99b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -330,7 +330,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: val imagesPreview = ArrayList() uris.forEach { uri -> var bitmap: ImageBitmap? - when { + val uploadContent: UploadContent? = when { isImage(uri) -> { // Image val drawable = getDrawableFromUri(uri) @@ -340,16 +340,19 @@ suspend fun MutableState.processPickedMedia(uris: List, text: // It's a gif or webp val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { - content.add(UploadContent.AnimatedImage(uri)) + UploadContent.AnimatedImage(uri) } else { bitmap = null AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) ) + null } } else if (bitmap != null) { - content.add(UploadContent.SimpleImage(uri)) + UploadContent.SimpleImage(uri) + } else { + null } } else -> { @@ -357,11 +360,22 @@ suspend fun MutableState.processPickedMedia(uris: List, text: val res = getBitmapFromVideo(uri, withAlertOnException = true) bitmap = res.preview val durationMs = res.duration - content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) + UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0) } } - if (bitmap != null) { + // content and imagesPreview must stay index-aligned and equal-length: both consumers + // (ComposeImageView and sendMessageAsync) cross-index one list by the other's index. + // Only pair them when a preview bitmap exists; otherwise skip the media entirely. + if (bitmap != null && uploadContent != null) { + content.add(uploadContent) imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) + } else if (uploadContent is UploadContent.Video && !AlertManager.shared.hasAlertsShown()) { + // A corrupted/undecodable video can yield a null preview frame without throwing, so + // getBitmapFromVideo shows no alert. Skip it (other picked media still send) and tell + // the user instead of dropping it silently. hasAlertsShown guards against stacking the + // alert across multiple bad items and against duplicating the one already shown on the + // exception path. Image decode failures are already surfaced by getBitmapFromUri above. + showVideoDecodingException() } } if (imagesPreview.isNotEmpty()) { diff --git a/plans/2026-06-04-fix-corrupted-video-upload-error.md b/plans/2026-06-04-fix-corrupted-video-upload-error.md new file mode 100644 index 0000000000..e88e781a5d --- /dev/null +++ b/plans/2026-06-04-fix-corrupted-video-upload-error.md @@ -0,0 +1,100 @@ +# Fix: IndexOutOfBoundsException when uploading media with an undecodable preview + +## Symptom + +``` +java.lang.IndexOutOfBoundsException: Index 6 out of bounds for length 6 + at java.util.ArrayList.get(ArrayList.java:434) + at chat.simplex.common.views.chat.ComposeViewKt.ComposeView$sendMessageAsync(ComposeView.kt:827) + ... +``` + +The crash fires when sending a batch of picked media (e.g. 7 items) in which at least +one item produces no preview bitmap — most commonly a corrupted or unusual video whose +first frame cannot be extracted. + +## Root cause + +`ComposePreview.MediaPreview` carries two parallel lists that are assumed to be +**equal-length and index-aligned**: + +```kotlin +class MediaPreview(val images: List, val content: List) +``` + +Both consumers cross-index one list by the other's index, so the invariant is load-bearing: + +- `ComposeImageView` (preview row) iterates `media.images` and reads `media.content[index]`. +- `ComposeView.sendMessageAsync` iterates `preview.content` and reads `preview.images[index]` + (the `MCImage` / `MCVideo` preview string). This is the crash site. + +`processPickedMedia` built the two lists out of step: + +- `imagesPreview` was appended **only when `bitmap != null`**. +- `content` was appended **unconditionally** for videos, and for animated images that + passed the size check — regardless of whether a preview bitmap was produced. + +`getBitmapFromVideo` returns `PreviewAndDuration(null, …)` whenever Android's +`MediaMetadataRetriever` cannot extract a frame, **even when no exception is thrown** +(`Utils.android.kt:351`). So a single undecodable video appends to `content` but not to +`images`, leaving `content.size == images.size + 1`. Iterating `content` then indexes +`images[lastIndex+1]` → `Index N out of bounds for length N`. + +This is a pre-existing bug in the shared media picker; it is unrelated to any in-flight +feature work and reproduces on both Android and Desktop (both use the `commonMain` +`processPickedMedia`). + +## Fix + +Keep `content` and `imagesPreview` strictly paired at the source. The `when` now yields an +`UploadContent?` instead of mutating `content` inside its branches, and both lists are +appended together, gated on a non-null preview bitmap: + +```kotlin +if (bitmap != null && uploadContent != null) { + content.add(uploadContent) + imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) +} +``` + +Each iteration now adds exactly zero or one entry to **both** lists, so the +equal-length / index-aligned invariant holds by construction. + +### Behavior change + +Media that yields no decodable preview frame is now **skipped** rather than enqueued. +Previously such a video crashed the send; now only the bad item is dropped and the rest of +the picked batch sends normally (the loop evaluates each URI independently). + +The skip is **not silent**. A skipped video shows `showVideoDecodingException()`, gated on +`AlertManager.hasAlertsShown()` so the alert neither stacks across several bad items in one +batch nor duplicates the one `getBitmapFromVideo` already shows on its exception path. The +genuinely silent gap this closes is the video path that returns a null frame **without** +throwing (Android `getFrameAtTime` returns null; Desktop snapshot times out) — that path +previously produced no alert and then crashed on send. + +Image decode failures need no new alert here: `getBitmapFromUri` is already called for every +image (animated or not) with `withAlertOnException = !hasAlertsShown()`, so a null image +bitmap is surfaced before this point. Only the video null-frame case lacked any notice. + +## Why this approach + +- **Fixes the invariant at its origin** rather than papering over it at the two read + sites. Guarding `images[index]` in `sendMessageAsync` would stop the crash but leave the + preview row (`ComposeImageView`) silently mismatched and the actual media set ambiguous. +- **Minimal, surgical diff** confined to `processPickedMedia`; no API/type changes, no new + placeholder assets, no touch to the read sites. +- **Cross-platform by construction**: the change lives in `commonMain`, so Android and + Desktop are both covered. iOS has a separate Swift compose implementation and is out of + scope for this fix. + +## Other `MediaPreview` construction sites (verified aligned) + +- `cs.preview` → single-element `listOf(mc.image)` / `listOf(content)` (edit path): aligned. +- `constructFailedMessage` takes `last()` of each list: aligned if the input was aligned. + +## Test notes + +Manual repro: pick a multi-item batch including a corrupted/zero-frame video and send. +- Before: `IndexOutOfBoundsException` on send. +- After: the undecodable item is dropped; remaining media sends normally.