# Brief: Performance review report for the multiplatform Kotlin clients
## Goal
Produce one human-readable Markdown report that documents the performance
problems found in the SimpleX Chat Android/Desktop Kotlin code
(`apps/multiplatform/`) and states a concrete fix for each. The report is the
deliverable. It changes no application code.
## Request and prior feedback
The request has two parts:
1. Review the Kotlin client code and determine whether performance issues exist.
2. Write the result as a structured, human-readable report: each finding is a
section in the exact form `## Issue N:
`, followed by a prose
description, then a `### Fix` subsection.
Prior feedback: an earlier attempt deleted the findings and produced content
that did not follow the `## Issue N` / `### Fix` structure. This revision
restores every grounded finding and fixes the structure. Each issue below is
verified against the checked-out worktree with a real `file:line`.
## Deliverable
A single Markdown file, `apps/multiplatform/PERFORMANCE_REPORT.md`, containing:
- A short **Brief** (scope: `apps/multiplatform/` Kotlin; method: static read of
the checked-out worktree; how severity is used).
- A **Table of contents** linking to every issue, ordered by severity.
- One `## Issue N: ` section per finding, each with a prose description
and a `### Fix` subsection. This structure is mandatory.
- A closing **Verified-good patterns / not defects** section recording the
places already doing the right thing, so the report is balanced.
## Audience and voice
SimpleX maintainers who read Kotlin and Compose. Do not explain language basics.
Every claim points at a concrete `file:line` under `apps/multiplatform/`.
Plain and formal engineering prose. No hedging, no marketing adjectives, no
emoji, no AI tells. Each issue states: what the code does, the `file:line`
evidence (short excerpt where it clarifies), why it costs time or memory (name
the complexity or "runs on the UI thread"), when it triggers (scroll, incoming
message batch, recomposition, startup, tap), and a severity label.
Severity labels:
- **High**: blocks the UI thread, or scales O(n^2) on a common path.
- **Medium**: per-event or per-recomposition waste on a hot path.
- **Low**: bounded, startup-only, or already partly mitigated.
## Corrections folded in from verification
Three claims from the earlier review were inaccurate and are corrected here so
the report does not repeat them:
- `Base64AsyncImage` is at `platform/Images.kt:36` and **is used** (one caller,
`views/helpers/ChatInfoImage.kt:104`). It is not "unused". Frame it as an
existing off-thread decode pattern that the media item views do not reuse.
- `CIImageView.kt:45`, `CIVideoView.kt:42`, and `ImageFullScreenView.kt:157`
**do** wrap `base64ToBitmap` in `remember`, so they do not re-decode on every
recomposition. The real cost is the first synchronous decode on the composing
thread. Do not claim "no remember" for these three.
- `getChatItemIndexOrNull` at `ChatItemView.kt:887` is inside an `onClick`
handler (tap-time), not render-time. The render-time linear scan is
`EventItemView` (`ChatItemView.kt:640-643`). Do not claim 887 is per-frame.
## Issues to cover (grounded and verified)
Order in the report by severity, then group related items. Each entry below
maps to one `## Issue N` / `### Fix` section.
### High severity
- **Full image decoded on the composition thread in the fullscreen gallery.**
`ImageGalleryProvider.getMedia` calls `runBlocking { getLoadedImage(item.file) }`
(`views/chat/ChatView.kt:3599`), and `ImageFullScreenView.kt:97` calls
`provider.getMedia(index)` directly in the composable `Content` body (also in a
`LaunchedEffect` at `:49`). This decrypts and decodes a full-resolution image
synchronously, blocking the UI thread on open and page change.
Fix: move the load off the main thread — decode in `produceState` /
`LaunchedEffect` on `Dispatchers.IO`, mirroring `Base64AsyncImage`
(`platform/Images.kt:36`); never call the blocking provider from composition.
- **O(n) list copy per chat-item insert, O(n^2) per incoming batch.**
`addToChatItems` rebuilds a fresh `SnapshotStateList` and `addAll`s the whole
list on every insert (`model/ChatModel.kt:500-506`); `addChatItem` first scans
with `none { it.id == cItem.id }` (`:584`) then calls that O(n) copy.
`CR.NewChatItems` runs `r.chatItems.forEach { addChatItem(...) }`
(`model/SimpleXAPI.kt:2899-2900`), so a batch of m items costs O(m*n).
Triggered on every incoming message and batch.
Fix: mutate the existing `SnapshotStateList` in place (`add`/`add(index, …)`)
instead of reallocating; maintain an id->index map for the dedupe, mirroring
`groupMembersIndexes` (`ChatModel.kt:139`) and `mapItemsToIds` in
`ChatItemsLoader.kt`.
- **O(n) `indexOfFirst` per upsert/update, O(m*n) per status/reaction batch.**
`upsertChatItem` (`ChatModel.kt:639`) and `updateChatItem` (`:662`) locate the
item with `indexOfFirst { it.id == cItem.id }`; a status/reaction batch runs
`r.chatItems.forEach { upsertChatItem(...) }` (`SimpleXAPI.kt:2930-2935`).
Fix: back `chatItems` with an id->index map so upsert/update are O(1) lookups.
### Medium severity
- **Linear chat lookup per incoming item.** `getChatIndex`, `getChat`, and
`hasChat` are linear scans (`ChatModel.kt:377-379`) called on the add/upsert
paths (e.g. `:533`, `:615`, `:677`).
Fix: maintain an id->index map for `chats`, invalidated on reorder/replace.
- **Chat list re-filters and re-copies the whole list every recomposition.**
`ChatList` calls `filteredChats(..., allChats.value.toList(), ...)` inline in
composition (`views/chatlist/ChatListView.kt:943`; definition `:1443-1471`),
copying then filtering all chats each pass.
Fix: memoize with `remember`/`derivedStateOf` keyed on the inputs
(search text, active filter, chats) so it recomputes only when they change.
- **Per-row `remember` keyed on the freshly allocated filtered list.**
`remember(chat.id, chats)` (`ChatListView.kt:1002`) is keyed on `chats`, the
new filtered list, so every visible row's `derivedStateOf` is invalidated each
pass.
Fix: key the row `remember` on stable values only (`chat.id` and the neighbour
id), not the list identity.
- **Whole-list reassignment invalidates all readers.** `replaceAll`
(`ChatModel.kt:3526-3528`) and `reorderChat` (`:406-413`, two full allocations
per reorder) discard and rebuild the `SnapshotStateList`.
Fix: mutate in place (`move`/`add`/`removeAt`) so only affected rows recompose.
- **`upsertGroupMember` maps the whole `chatItems` list per member update.**
It builds a full `map` copy, does an O(n) structural `!=` comparison, then
`replaceAll` rebuilds the list (`ChatModel.kt:941-955`), on every member update.
Fix: update only the affected items in place; skip the full map + compare +
rebuild.
- **Unstable `selectChatItem` lambda makes `ChatItemView` non-skippable.**
A new closure is allocated inline per item at `views/chat/ChatView.kt:1950`.
Fix: hoist/`remember` the lambda so the parameter is stable and Compose can
skip unchanged items.
- **`EventItemView` reverses and scans `chatItems` in composition.**
`chatsCtx.chatItems.value.asReversed()` then `mergedGroupEventText(...)` scans
it and computes text unmemoized (`ChatItemView.kt:640-643`, `625-638`).
Fix: memoize the merged event text with `remember` keyed on the relevant ids.
- **Chat-list rows read model-wide state in the row body.**
`ChatListNavLinkView` reads `chatModel.chatRunning.value` and
`chatModel.deletedChats.value` in every row (`ChatListNavLinkView.kt:42`), so a
single change recomposes all rows.
Fix: derive a per-chat boolean outside the row, or scope the read so only the
affected row recomposes.
- **Toolbar rebuilds a list of `@Composable` lambdas each recomposition.**
`ChatListToolbar` allocates `arrayListOf<@Composable RowScope.() -> Unit>()`
and re-adds lambdas each pass (`ChatListView.kt:484-624`).
Fix: extract stable composables or `remember` the button set.
- **Chat-list previews decode base64 in the composable body with no `remember`.**
`ChatPreviewView.kt:320` and `:357` call `base64ToBitmap(...)` inline, so the
preview re-decodes on every recomposition while scrolling.
Fix: wrap in `remember(image)` or use `Base64AsyncImage`.
- **Android `base64ToBitmap` has no cache, double-decodes, no `inSampleSize`.**
`platform/Images.android.kt:26-42` runs `decodeByteArray` twice (bounds then
full) at full resolution and caches nothing, unlike the desktop
`base64BitmapCache` (`Images.desktop.kt:24-49`).
Fix: add a bounded cache like desktop and set `inSampleSize` for the target size.
- **Android `getLoadedImage` has no cache and re-reads the file each call.**
`views/helpers/Utils.android.kt:172-194` calls `readBytes()` and decodes on
every call (`clearImageCaches()` at `:169` is a no-op), unlike the desktop
`loadedImageCache` (`Utils.desktop.kt:128-146`).
Fix: add a bounded cache mirroring desktop and wire `clearImageCaches()` to it.
### Low severity
- **Media preview decode is synchronous on the composing thread (first pass).**
`CIImageView.kt:45`, `CIVideoView.kt:42`, and `ImageFullScreenView.kt:157`
wrap `base64ToBitmap` in `remember` (so no re-decode on recomposition), but the
first decode runs on the composing thread. `Base64AsyncImage`
(`platform/Images.kt:36`, used once at `ChatInfoImage.kt:104`) already decodes
off-thread on `Dispatchers.IO`.
Fix: reuse the off-thread decode pattern for these media previews.
- **`getChatItemIndexOrNull` is a linear scan.** `ChatModel.kt:1122-1125`
(`indexOfFirst`). The per-recomposition caller is `EventItemView`
(`ChatItemView.kt:640-643`); the call at `ChatItemView.kt:887` is a tap-time
delete handler, not render-time.
Fix: resolve index via the id->index map introduced for `chatItems`.
- **Pagination dedupe scans `splits` per item.** The loader runs
`splits.value.indexOf(it.id)` and `splits.contains(...)` inside per-item passes
and `removeAll { newIds.contains(it.id) }`
(`views/chat/ChatItemsLoader.kt:204, 246, 284, 318, 336, 354-356, 384, 410`),
giving O(items*splits) where `splits` is a `List`. Note `newIds` is already a
Set, so credit that.
Fix: index `splits` by id (Set/Map) for O(1) membership within these loops.
- **Full undecoded file bytes retained per visible media item, Coil
`Size.ORIGINAL`.** `CIImageView.kt` (image data path) and
`CIImageView.android.kt` request original size, holding full bytes per item.
Fix: request a bounded target size rather than original for list/preview use.
- **Desktop `resizeImageToStrSize` recomputes `hasAlpha()` each iteration.**
The resize loop calls `compressImageStr` per iteration
(`platform/Images.desktop.kt:57-68`), and `compressImageStr` calls
`bitmap.hasAlpha()` (`:105-106`), a full pixel scan (`:143-155`), every time.
Fix: compute `usePng`/`hasAlpha` once before the loop and pass it in, as
`resizeImageToDataSize` already does.
- **`Thread.sleep(10)` busy-wait on the UI thread in `ModalManager`.**
`runAtomically` spins with `Thread.sleep(10)` until a CAS succeeds
(`views/helpers/ModalView.kt:241-247`).
Fix: replace the spin-wait with a coroutine `Mutex` or a suspend guard.
- **`runBlocking { progressJob?.cancelAndJoin() }` in audio recorder `stop()`.**
`platform/RecAndPlay.android.kt:82-84` and `RecAndPlay.desktop.kt:80` block the
caller; the player path in the same files uses non-blocking `cancel()`.
Fix: use non-blocking `cancel()` (or a suspend `stop()`), matching the player.
- **`Thread.sleep(50)` file-existence polling at desktop startup.**
`SingleInstance.kt:50-53` polls with `Thread.sleep(50)` for up to 1s.
Fix: acceptable as startup/background, but a `WatchService` or shorter bounded
wait removes the poll. Mark Low.
## Verified-good patterns (must appear in the closing section)
- `groupMembersIndexes` id->index map (`ChatModel.kt:139`) — the pattern the
other collections should adopt.
- `Base64AsyncImage` off-thread decode (`platform/Images.kt:36`).
- Desktop `base64BitmapCache` (`Images.desktop.kt:24-49`) and `loadedImageCache`
(`Utils.desktop.kt:128-146`).
- `newIds` Set-based membership in the pagination loader (`ChatItemsLoader.kt`).
- `PopChatCollector.throttlePopChat` throttling the chat-pop hot path
(`ChatModel.kt`).
- Stable `LazyColumn` keys in `ChatList` (`ChatListView.kt:1001`).
## Success criteria
- `apps/multiplatform/PERFORMANCE_REPORT.md` exists and follows the structure:
Brief, TOC, one `## Issue N: ` / description / `### Fix` per finding,
then the verified-good section.
- Every issue above is covered, each with a real `file:line`, a stated cost, a
trigger, a severity, and a concrete fix grounded in this repo.
- Issues are ordered by severity and grouped by theme in the TOC.
- The three corrections above are honoured; no corrected claim is restated in its
wrong form.
- Prose is plain and free of AI tells; no application code is modified.
## Edge cases and constraints
- Do not assert unverified claims. Findings that could not be confirmed at the
call site (WebRTC signaling decode in `CallView.*`, the `rememberSaveable`
Saver decode in `ComposeView.kt`) are excluded, not stated as defects.
- Distinguish Android-only, desktop-only, and shared issues; several image/cache
problems exist on Android but are already solved on desktop.
- Keep the report descriptive: it recommends fixes but edits no code, and does
not overstate — note where a hot path is already throttled or optimized.
- Length follows coverage; do not pad. One tight section per issue.