diff --git a/plans/sh-testing-design.md b/plans/sh-testing-design.md
index 18c149dcb3..afed77ce6b 100644
--- a/plans/sh-testing-design.md
+++ b/plans/sh-testing-design.md
@@ -4,138 +4,239 @@
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.
+deliverable. It changes no application code.
-## Origin
+## 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 — styled like the
- project's own docs (table of contents, plain technical prose), with a BRIEF
- at the top and one section per issue in the form `## Issue N:
`,
- followed by a description and a `### Fix` subsection.
+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.
-The review has already been carried out against the checked-out worktree; the
-findings below are grounded in specific files and line numbers. This brief and
-the plan define how those findings become the report.
+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, what was reviewed, method, how severities are used).
-- A **Table of contents** linking to every issue.
-- One `## Issue N: ` section per finding, each with a description and a
- `### Fix`.
-- A closing **Not defects / verified-good patterns** section that records the
- places already doing the right thing (stable `LazyColumn` keys, off-main JSON
- decode, existing caches and index maps), so the report is balanced and does
- not imply the whole codebase is slow.
+- 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
-SimpleX maintainers familiar with the codebase. Assume they read Kotlin and
-Compose; do not explain language basics. Every claim must point at a concrete
-`file:line` in `apps/multiplatform/`.
+## 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.
-## Content and voice
-- Structure exactly as requested: `## Issue N: ...` → description → `### Fix`.
-- Each issue states: what the code does, the file:line evidence (with a short
- excerpt where it clarifies), why it costs time or memory (name the complexity,
- e.g. O(n) per add / O(n^2) per batch, or "runs on the UI thread"), when it is
- triggered (scroll, incoming message batch, recomposition), and its severity.
-- The fix is specific and grounded in this repo — prefer pointing at patterns the
- codebase already uses (`groupMembersIndexes` id->index map at
- `ChatModel.kt:139`; the async `Base64AsyncImage` at `Images.kt:36`; the desktop
- `loadedImageCache` / `base64BitmapCache`; the `mapItemsToIds` Set in
- `ChatItemsLoader.kt`).
-- Plain and formal. No LLM/AI tells: no "As an AI", no hedging ("it seems",
- "might possibly"), no marketing adjectives, no emoji, no per-section filler.
- Write as an engineer filing findings.
-- Severity labels: High (blocks the UI thread or scales O(n^2) on common paths),
- Medium (per-event or per-recomposition waste on hot paths), Low (bounded or
- startup-only).
+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.
-## Issues to cover (grounded findings)
-Data model / collections (`model/ChatModel.kt`, `views/chat/ChatItemsLoader.kt`):
-- `addToChatItems` allocates a new `SnapshotStateList` and copies the whole list
- on every single insert — O(n) per add, O(n^2) for the per-item batch loop in
- `CR.NewChatItems` (`ChatModel.kt:500-506`; `SimpleXAPI.kt:2899-2928`).
-- `addChatItem` dedupes with `none { it.id == ... }` — O(n) scan per add before
- the O(n) copy (`ChatModel.kt:582-590`).
-- `upsertChatItem` / `updateChatItem` locate items with
- `indexOfFirst { it.id == ... }` — O(n) per update, O(m*n) per status/reaction
- batch (`ChatModel.kt:638-639, 661-662`).
-- Chat lookup by id (`getChatIndex` / `getChat` / `hasChat`) is a linear scan
- called per incoming item (`ChatModel.kt:377-379`, used at 460, 533, 615, 677 …).
-- `getChatItemIndexOrNull` linear scan is called from render-time composables
- (`ChatModel.kt:1122-1145`; callers `ChatItemView.kt:887`, `ChatView.kt:3333`,
- `CIChatFeatureView.kt:79`, `MarkedDeletedItemView.kt:46`).
-- `upsertGroupMember` maps the entire `chatItems` list and does an O(n) equality
- check on every member update (`ChatModel.kt:941-955`).
-- Pagination duplicate-removal uses `List.indexOf`/`contains` on `splits` inside
- per-item `removeAll` predicates — O(items*splits)
- (`ChatItemsLoader.kt:196-, 341-364`).
+## 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.
-Compose recomposition (`views/chatlist/*`, `views/chat/*`):
-- `ChatList` re-runs `filteredChats(... allChats.value.toList() ...)` inline on
- every recomposition — full copy + full filter, not memoized
- (`ChatListView.kt:936-943`, `1443-1471`).
-- The per-row `remember(chat.id, chats)` is keyed on the freshly allocated
- filtered list, so it is invalidated for every visible row every pass
- (`ChatListView.kt:1001-1006`).
-- Whole-list reassignment: `replaceAll` and `reorderChat` throw away and rebuild
- the `SnapshotStateList`, invalidating all readers (`ChatModel.kt:3526-3528`,
- `406-413`).
-- Unstable `selectChatItem` lambda allocated per item makes `ChatItemView`
- non-skippable (`ChatView.kt:1950`).
-- `EventItemView` reads `chatsCtx.chatItems.value` and reverses/scans the list in
- composition, recomputing text unmemoized (`ChatItemView.kt:605-643`).
-- Chat-list rows read model-wide `deletedChats`/`chatRunning` in the row body, so
- one deletion recomposes every row (`ChatListNavLinkView.kt:42`).
-- Toolbar rebuilds a mutable list of `@Composable` lambdas each recomposition
- (`ChatListView.kt:483-568`).
+## 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.
-Main-thread blocking (`views/chat/ChatView.kt`, helpers, platform):
-- `runBlocking { getLoadedImage(...) }` decrypts + decodes a full image on the
- composition thread in the fullscreen gallery — High
- (`ChatView.kt:3599`; `getMedia` at `ImageFullScreenView.kt:49, 97`).
-- `Thread.sleep(10)` busy-wait in `ModalManager.runAtomically` on the UI thread
- (`ModalView.kt:241-247`).
-- `runBlocking { progressJob?.cancelAndJoin() }` in audio recorder `stop()`
- (`RecAndPlay.android.kt:82-84`, `RecAndPlay.desktop.kt:80`).
-- `Thread.sleep(50)` file-existence polling at desktop startup — Low
- (`SingleInstance.kt:50-53`).
+### 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.
-Image / media decoding and memory:
-- Chat-list previews call `base64ToBitmap` in the composable body with no
- `remember`, re-decoding on every recomposition (`ChatPreviewView.kt:320, 357`).
-- Media items decode the base64 preview synchronously during composition
- (`CIImageView.kt:45`, `CIVideoView.kt:42`, `ImageFullScreenView.kt:157`) while
- the async `Base64AsyncImage` (`Images.kt:36`) exists and is unused.
-- Android `base64ToBitmap` has no cache and decodes the byte array twice at full
- resolution, never setting `inSampleSize` (`Images.android.kt:26-42`), unlike the
- desktop cache (`Images.desktop.kt:24-50`).
-- Android `getLoadedImage` has no cache and re-reads the whole file with
- `readBytes()` on every call (`Utils.android.kt:172-194`), unlike the desktop
+- **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`).
-- Full undecoded file bytes are retained per visible media item and passed to
- Coil with `Size.ORIGINAL` (`CIImageView.kt:167-213`, `CIImageView.android.kt:30-34`).
-- Desktop `resizeImageToStrSize` recomputes `hasAlpha()` (full pixel scan) on each
- shrink iteration (`Images.desktop.kt:57-68, 105-115, 143-155`).
+ 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
-- The report file exists at the path above and follows the requested structure
- (Brief, TOC, `## Issue N` / description / `### Fix`).
+- `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.
-- Issues are ordered by severity/impact and grouped by theme in the TOC.
-- The "verified-good patterns" section is present.
-- The prose is plain and free of LLM/AI tells; no application code is modified.
+ 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 the review could not confirm at the
- call site (WebRTC signaling decode in `CallView.*:786/290`, the `rememberSaveable`
- Saver decode in `ComposeView.kt:235`) are excluded, not stated as defects.
-- Distinguish Android-only from desktop-only from shared issues; several problems
- are platform-specific (the caches exist on desktop but not Android).
-- Keep the report descriptive: it recommends fixes but must not edit code, and
- must not overstate — note where a hot path is already throttled (e.g.
- `PopChatCollector`) or already optimized (Set-based dedupe, stable keys).
+- 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.