android, desktop: fix empty area below wide images (beta.3 regression) (#7223)

Wide images size their preview box with a fixed width of DEFAULT_MAX_IMAGE_WIDTH
(500dp). #7125 switched the box from .aspectRatio() to a fixed .height() computed
from that nominal width; on screens narrower than 500dp, .width(500dp) is clamped
to the available width but the fixed height is not, so the top-aligned FillWidth
image is shorter than its box, leaving an empty strip below.

Compute the height from the width actually granted via a small Modifier.layout so
it tracks the clamped width, restoring the self-correcting behaviour .aspectRatio()
had. coerceAtMost(w) keeps the box within its nominal width (and bounds the
unbounded intrinsic-measurement pass), and coerceAtLeast(0) mirrors what
Modifier.width()'s SizeNode does for a negative w on a tiny window - so both
dimensions stay in range and the #7123 Constraints overflow crash cannot recur.
This commit is contained in:
Narasimha-sc
2026-07-18 14:58:25 +01:00
committed by GitHub
parent 590707915c
commit c125836d85
2 changed files with 166 additions and 1 deletions
@@ -12,10 +12,12 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.*
@@ -27,6 +29,7 @@ import chat.simplex.common.views.chat.chatViewScrollState
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlin.math.roundToInt
@Composable
fun CIImageView(
@@ -176,7 +179,13 @@ fun CIImageView(
.then(
if (!smallView) {
val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH
Modifier.width(w).height(w * (previewBitmap.height.toFloat() / previewBitmap.width.toFloat()).coerceAtMost(2.33f))
// Height follows the measured (clamped) width, not nominal w, else wide images get an empty strip below.
Modifier.width(w).layout { measurable, constraints ->
val width = constraints.maxWidth.coerceAtMost(w.roundToPx().coerceAtLeast(0))
val height = (width * (previewBitmap.height.toFloat() / previewBitmap.width.toFloat()).coerceAtMost(2.33f)).roundToInt().coerceAtMost(constraints.maxHeight)
val placeable = measurable.measure(Constraints.fixed(width, height))
layout(width, height) { placeable.place(0, 0) }
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
@@ -0,0 +1,156 @@
# Fix empty area below wide images (v7.0.0-beta.3 regression)
## Problem
Since **v7.0.0-beta.3**, wide/landscape images in the chat render with an
**empty strip below** them. The image is drawn correctly at the top of its box
but the box reserves more vertical space than the image occupies, leaving a gap.
Portrait/tall images are unaffected — hence "some images."
Affects **Android and desktop** (the Compose `apps/multiplatform` UI). iOS is not
affected.
## Cause
The regression is commit `2c2337b07` (#7125) — the only image-sizing change
between beta.2 and beta.3. It replaced the framed image preview Box's
`aspectRatio` modifier with a directly-computed fixed height
(`apps/multiplatform/.../chat/item/CIImageView.kt`):
```kotlin
// beta.2 (interim crash fix)
Modifier.width(w).aspectRatio((previewBitmap.width / previewBitmap.height).coerceIn(1f / 2.33f, 2.33f))
// beta.3 (#7125)
Modifier.width(w).height(w * (previewBitmap.height / previewBitmap.width).coerceAtMost(2.33f))
```
The box width is:
```kotlin
val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH
```
- **Tall/portrait** images use `w = imageViewFullWidth() * 0.75f`, and
`imageViewFullWidth() = min(DEFAULT_MAX_IMAGE_WIDTH, window 100.dp)` — so `w`
is always ≤ the available width. The box never has to shrink. No gap.
- **Wide/landscape** images use the **fixed** `w = DEFAULT_MAX_IMAGE_WIDTH = 500.dp`,
which is *not* reduced by the window width.
On any screen or bubble narrower than ~500dp (all phones, many desktop windows),
`Modifier.width(500.dp)` is **clamped down** to the available width by Compose's
`SizeModifier` (it constrains the requested size into the incoming constraints;
`PriorityLayout` in `FramedItemView.kt` measures the image with `maxWidth` = the
bubble's available width). But `.height(w * ratio)` was computed from the
**un-clamped** `w = 500`, so it does **not** shrink. The inner image
(`ContentScale.FillWidth`, top-aligned via `Alignment.TopEnd`) fills the clamped
width and is therefore shorter than the too-tall box:
```
box height = 500 * ratio (from unclamped w)
image height = clampedWidth * ratio (FillWidth to the real width)
empty strip = (500 clampedWidth) * ratio
```
**Why it's a regression and not present before:** the old `.width(w).aspectRatio(r)`
self-corrected. When the box width was clamped to the available width,
`aspectRatio` recomputed the height proportionally, so the box always matched the
image. Switching to a fixed `.height()` (computed once from the nominal `w`) broke
that coupling.
They could not simply revert to `.aspectRatio()`: it reintroduces the original
#7123 crash. `FramedItemView`'s `Modifier.width(IntrinsicSize.Max)` triggers
`aspectRatio`'s `maxIntrinsicWidth = height × ratio`, which overflows Compose's
18-bit `Constraints` packing for extreme ratios (`4000×1``1165 × 4000` ≫ 262142).
## Fix
Keep `Modifier.width(w)` (this is what preserves the fixed intrinsic **width** `w`
that both avoids the crash and drives `FramedItemView`'s text-width adaptation),
but derive the box **height** from the *actual, already-clamped* width via a small
`Modifier.layout {}` — restoring the self-correcting behaviour without `aspectRatio`
(`CIImageView.kt`):
```kotlin
val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH
Modifier.width(w).layout { measurable, constraints ->
val width = constraints.maxWidth.coerceAtMost(w.roundToPx().coerceAtLeast(0))
val height = (width * (previewBitmap.height.toFloat() / previewBitmap.width.toFloat()).coerceAtMost(2.33f)).roundToInt().coerceAtMost(constraints.maxHeight)
val placeable = measurable.measure(Constraints.fixed(width, height))
layout(width, height) { placeable.place(0, 0) }
}
```
New imports: `androidx.compose.ui.layout.layout`, `androidx.compose.ui.unit.Constraints`,
`kotlin.math.roundToInt`. The change is deliberately surgical: the only semantic difference
from #7125 is that the height multiplies the **measured** `width` instead of the nominal `w`.
`roundToInt` (not truncation) matches the px rounding `.height(Dp)` did in #7125.
### Why `coerceAtMost(w)` and `coerceAtLeast(0)` are load-bearing (crash-safety)
`width = constraints.maxWidth.coerceAtMost(w.roundToPx().coerceAtLeast(0))` bounds the
width at `w` in every case, which is what keeps `Constraints.fixed(width, height)` inside
Compose's 18-bit (262142 px) packing limit and prevents the #7123 overflow from recurring:
- **Real measure pass:** `width(w)` (a fixed-size `SizeNode`) already hands the block a
bounded `maxWidth ≤ w` — the screen-clamped width. `coerceAtMost(w)` is then a no-op and
`width` = that clamped width, exactly what removes the strip.
- **Unbounded intrinsic pass:** a plain `Modifier.layout {}` re-runs its lambda for intrinsic
queries, and `constraints.maxWidth` there is `Constraints.Infinity` (`Int.MAX_VALUE`). A
bare `constraints.maxWidth` would feed `Infinity` into `height = width × ratio`
`Constraints.fixed` throws (the exact earlier-observed regression). `coerceAtMost(w)`
collapses `Infinity` back to `w`, so `height ≤ w × 2.33` — always finite. (In this tree the
outer `width(w)` masks intrinsics so the lambda is never actually invoked unbounded, but the
clamp makes the block correct regardless.)
- **`coerceAtLeast(0)`** mirrors what `Modifier.width()`'s `SizeNode` does internally: when the
desktop window is narrower than the 100dp padding in `imageViewFullWidth()` (or 0 at window
init), `w` is negative, and `Constraints.fixed` requires non-negative — so without the clamp
a portrait image in a tiny window would crash where `.width(w)` silently rendered 0-width.
`width` and `height` are therefore always in `[0, w × 2.33]`.
### Why this is correct at every aspect ratio
- **Fits within `DEFAULT_MAX_IMAGE_WIDTH`** (wide window, or any tall image): the
measured width equals the nominal `w`, so `height = w × heightRatio` — **pixel-identical
to beta.3**. No visible change for the cases that already looked right.
- **Wider than the available width** (wide image on a phone/narrow window):
`width = constraints.maxWidth` is the clamped width, `height = clampedWidth × heightRatio`
— matches the `FillWidth` image exactly. **Empty strip gone.**
- **Tall images** (`h / w > 2.33`): `heightRatio` is capped at `2.33`, so the box is
`width × 2.33·width`; the `FillWidth` image is taller and is cropped by the box
(top-aligned) — same crop cap as before. `coerceAtMost(constraints.maxHeight)` keeps
the `PriorityLayout` height ceiling honoured.
- **No crash:** the intrinsic width is still `w` (from `Modifier.width(w)`, unaffected
by the inner `layout`), so `FramedItemView`'s `IntrinsicSize.Max` never multiplies by a
ratio. The `width = height × ratio` derivation that overflowed `Constraints` is gone.
Both dimensions are always finite: `width(w)` gives the inner `layout` a bounded
`maxWidth`, and `height ≤ width × 2.33 ≤ maxHeight`.
## Scope / non-goals
- Only the `!smallView` framed image Box is changed. The chat-list `smallView`
preview is a fixed square, and all other image/video/link paths size with
`.width(...)` + `ContentScale` (no ratio-derived box), so none of them show the
gap or the crash.
- Same file is `commonMain`, so the one change covers **Android and desktop**.
- Not touched: the bitmap decoders' guards (`Images.android.kt` / `Images.desktop.kt`)
and the still-open "symmetric wide guard" defense-in-depth follow-up noted in
`plans/2026-06-23-wide-image-crash.md` — out of scope for this display fix.
## iOS
iOS already computes the height directly from the laid-out width
(`height = w × heightRatio`, `heightRatio = min(h / w, 2.33)`,
`apps/ios/SimpleXChat/ImageUtils.swift`) and lays out with `CGFloat` frames, so it
never had the clamped-width gap. No iOS change required; this brings Android/desktop
back into line.
## Verification
- Build Android arm64 debug APK (`bash ~/build/android.sh`) and Linux x86_64
AppImage (`bash ~/build/linux.sh`) from branch `nd/fix-image-width`.
- Manual: send/view a landscape image (~3:1 and ~4:3) on a phone / narrow desktop
window — the empty strip below should be gone and the image should look as it did
before beta.3. Confirm a very wide panorama still renders as a natural thin strip
(no letterbox, no crash) and a very tall image is still cropped at 2.33.