perf(reader): faster cached message-open — single WebView render, off-main IO, no wasted openMessage work #186

Closed
opened 2026-07-03 00:39:59 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-03 00:39:59 +00:00 (Migrated from github.com)

Follow-up to #148 (opening messages still slow even fully cached). Two profiling investigations — a message-open profiler (measured on an API 37 emulator + JVM microbenchmarks) and a DB access-pattern audit — identified the dominant costs on the cached-open critical path. #170 addresses only the SEEN-flag network round-trip, which the profiler found is NOT the dominant cost for cached opens — these three fixes are. Critical path: tap (MailboxScreen.kt:321) → ReaderScreen shows a spinner while state.loading → gated on repository.openMessage() → then HtmlBody render.

Fix 1 — WebView: render once + pool/pre-warm (biggest win)

ui/reader/HtmlBody.kt:74-132. A fresh WebView is constructed per reader open on the main thread (~20ms; 32ms first-ever) + ~23ms (p90 48ms) to load a ~122KB doc. Worse, the AndroidView update key includes inlineImages.keys (:127): openMessage returns first with an empty inline-image map → body renders → ReaderViewModel.kt:73-76 resolves cid: images → key changes → loadDataWithBaseURL runs a second time, discarding the first render. Any email with an inline (cid:) image — most newsletters — pays the full render cost twice.

  • Resolve inline images before the first render (fold inlineImages into the same state update as the body, or hold loading until both are ready) so the WebView loads once.
  • Pool/pre-warm a single WebView (or a small cache) instead of constructing one per open. (Watch for WebView leaks / correct lifecycle.)
  • Don't let a late inline-image update reload if the body already displayed.

Fix 2 — openMessage: stop doing unused work for a cached/already-read message

data/repository/MailRepositoryImpl.kt:106-122. For a cached, already-read message:

  • connectionFactory.imapParamsFor(account) is called unconditionally (:110) but the bodyFetched && isRead branch never uses params. It runs KeystoreCrypto.decrypt (measured ~4.7ms, p90 8.6ms) + a DataStore read (strictStartTls) on the main thread, every open, for password accounts — pure waste.
  • The second messageDao.getById(id) (:121) re-reads the whole row including the body blob even when nothing changed (row already read at :107) — two full-body reads per open (over-fetch; independently confirmed by the DB audit — the lookup is a tight indexed SEARCH, this is over-FETCH not over-scan).
  • Compute params lazily only inside the branch that needs it (!bodyFetched / the async SEEN path).
  • In the already-read case return entity.toDomain() from the row already read at :107 (or entity.copy(...) in the unread branch) instead of a second getById.
  • Add a body-less projection DAO method (SELECT id, accountId, folder, uid, isRead, isStarred, bodyFetched, isHtml …) for routing/flags callers (downloadAttachment, the first read in openMessage, setStarred/deleteMessage/expunge/moveByRole/buildReplyDraft); reserve SELECT * (getById) for the one read that returns the body. No migration — mirrors the existing MessageSummary projection.
  • De-dup the attachment N+1: inlineImages (:129-136) calls downloadAttachment per cid: image, each re-getById + re-getForMessage; pass folder/accountId + the already-fetched attachment rows into the loop instead.

Fix 3 — Get the repository's non-suspend CPU/IO off the main thread

MailRepositoryImpl has no withContext; viewModelScope is Dispatchers.Main.immediate, so the Keystore decrypt and the per-part File.exists()/length() (:152-158) + inline-image readBytes() (:129-136) run on the UI thread during the open animation.

  • Wrap openMessage, downloadedAttachmentParts, inlineImages (and residual crypto) bodies in withContext(Dispatchers.IO/Default).
  • (Optional) stream inline-image bytes into the WebResourceResponse from the file instead of readBytes() into a heap ByteArray.

Already-optimal / low priority (not required)

  • The tapped-message DB lookup is already a tight indexed SEARCH (PK, ~4.6µs/lookup at 50k rows) — no index change needed on the tap path.
  • Minor: wrapHtml's trimIndent() (HtmlBody.kt:190) scans the whole body (~1-4ms on device); could drop trimIndent() and build the wrapper by concatenation. Low priority.

Acceptance

Opening an already-downloaded message: renders once (no double WebView load), does no Keystore/DataStore/2nd-DB work it doesn't need, and runs its file/crypto IO off the main thread. Re-measure reader-open latency before/after on a cached message that has inline images. Perf-sensitive area — verify no WebView leak and no behavior regression in the reader.

Follow-up to #148 (opening messages still slow even fully cached). Two profiling investigations — a message-open profiler (measured on an API 37 emulator + JVM microbenchmarks) and a DB access-pattern audit — identified the dominant costs on the cached-open critical path. **#170 addresses only the SEEN-flag network round-trip, which the profiler found is NOT the dominant cost for cached opens** — these three fixes are. Critical path: tap (`MailboxScreen.kt:321`) → `ReaderScreen` shows a spinner while `state.loading` → gated on `repository.openMessage()` → then `HtmlBody` render. ## Fix 1 — WebView: render once + pool/pre-warm (biggest win) `ui/reader/HtmlBody.kt:74-132`. A fresh `WebView` is constructed per reader open **on the main thread** (~20ms; 32ms first-ever) + ~23ms (p90 48ms) to load a ~122KB doc. Worse, the `AndroidView` `update` key includes `inlineImages.keys` (:127): `openMessage` returns first with an **empty** inline-image map → body renders → `ReaderViewModel.kt:73-76` resolves `cid:` images → key changes → `loadDataWithBaseURL` runs a **second time**, discarding the first render. Any email with an inline (`cid:`) image — most newsletters — pays the full render cost **twice**. - [ ] Resolve inline images **before** the first render (fold `inlineImages` into the same state update as the body, or hold `loading` until both are ready) so the WebView loads once. - [ ] Pool/pre-warm a single WebView (or a small cache) instead of constructing one per open. (Watch for WebView leaks / correct lifecycle.) - [ ] Don't let a late inline-image update reload if the body already displayed. ## Fix 2 — `openMessage`: stop doing unused work for a cached/already-read message `data/repository/MailRepositoryImpl.kt:106-122`. For a cached, already-read message: - `connectionFactory.imapParamsFor(account)` is called **unconditionally** (:110) but the `bodyFetched && isRead` branch never uses `params`. It runs `KeystoreCrypto.decrypt` (measured ~4.7ms, p90 8.6ms) + a DataStore read (`strictStartTls`) on the **main thread**, every open, for password accounts — pure waste. - The second `messageDao.getById(id)` (:121) re-reads the whole row **including the `body` blob** even when nothing changed (row already read at :107) — two full-body reads per open (over-fetch; independently confirmed by the DB audit — the *lookup* is a tight indexed SEARCH, this is over-FETCH not over-scan). - [ ] Compute `params` lazily only inside the branch that needs it (`!bodyFetched` / the async SEEN path). - [ ] In the already-read case return `entity.toDomain()` from the row already read at :107 (or `entity.copy(...)` in the unread branch) instead of a second `getById`. - [ ] Add a body-less **projection** DAO method (`SELECT id, accountId, folder, uid, isRead, isStarred, bodyFetched, isHtml …`) for routing/flags callers (`downloadAttachment`, the first read in `openMessage`, `setStarred`/`deleteMessage`/`expunge`/`moveByRole`/`buildReplyDraft`); reserve `SELECT *` (`getById`) for the one read that returns the body. **No migration** — mirrors the existing `MessageSummary` projection. - [ ] De-dup the attachment N+1: `inlineImages` (:129-136) calls `downloadAttachment` per `cid:` image, each re-`getById` + re-`getForMessage`; pass folder/accountId + the already-fetched attachment rows into the loop instead. ## Fix 3 — Get the repository's non-suspend CPU/IO off the main thread `MailRepositoryImpl` has **no `withContext`**; `viewModelScope` is `Dispatchers.Main.immediate`, so the Keystore decrypt and the per-part `File.exists()/length()` (:152-158) + inline-image `readBytes()` (:129-136) run on the UI thread during the open animation. - [ ] Wrap `openMessage`, `downloadedAttachmentParts`, `inlineImages` (and residual crypto) bodies in `withContext(Dispatchers.IO/Default)`. - [ ] (Optional) stream inline-image bytes into the `WebResourceResponse` from the file instead of `readBytes()` into a heap `ByteArray`. ## Already-optimal / low priority (not required) - The tapped-message DB **lookup** is already a tight indexed SEARCH (PK, ~4.6µs/lookup at 50k rows) — **no index change needed** on the tap path. - Minor: `wrapHtml`'s `trimIndent()` (`HtmlBody.kt:190`) scans the whole body (~1-4ms on device); could drop `trimIndent()` and build the wrapper by concatenation. Low priority. ## Acceptance Opening an already-downloaded message: renders **once** (no double WebView load), does no Keystore/DataStore/2nd-DB work it doesn't need, and runs its file/crypto IO off the main thread. Re-measure reader-open latency before/after on a cached message that has inline images. Perf-sensitive area — verify no WebView leak and no behavior regression in the reader.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#186