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
This commit is contained in:
Narasimha-sc
2026-06-17 19:22:34 +01:00
committed by GitHub
parent a4cfa2ae97
commit cf6a60882f
2 changed files with 119 additions and 5 deletions
@@ -330,7 +330,7 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
val imagesPreview = ArrayList<String>()
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<ComposeState>.processPickedMedia(uris: List<URI>, 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<ComposeState>.processPickedMedia(uris: List<URI>, 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()) {
@@ -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<String>, val content: List<UploadContent>)
```
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.