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 AndroidViewupdate 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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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) →ReaderScreenshows a spinner whilestate.loading→ gated onrepository.openMessage()→ thenHtmlBodyrender.Fix 1 — WebView: render once + pool/pre-warm (biggest win)
ui/reader/HtmlBody.kt:74-132. A freshWebViewis constructed per reader open on the main thread (~20ms; 32ms first-ever) + ~23ms (p90 48ms) to load a ~122KB doc. Worse, theAndroidViewupdatekey includesinlineImages.keys(:127):openMessagereturns first with an empty inline-image map → body renders →ReaderViewModel.kt:73-76resolvescid:images → key changes →loadDataWithBaseURLruns a second time, discarding the first render. Any email with an inline (cid:) image — most newsletters — pays the full render cost twice.inlineImagesinto the same state update as the body, or holdloadinguntil both are ready) so the WebView loads once.Fix 2 —
openMessage: stop doing unused work for a cached/already-read messagedata/repository/MailRepositoryImpl.kt:106-122. For a cached, already-read message:connectionFactory.imapParamsFor(account)is called unconditionally (:110) but thebodyFetched && isReadbranch never usesparams. It runsKeystoreCrypto.decrypt(measured ~4.7ms, p90 8.6ms) + a DataStore read (strictStartTls) on the main thread, every open, for password accounts — pure waste.messageDao.getById(id)(:121) re-reads the whole row including thebodyblob 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).paramslazily only inside the branch that needs it (!bodyFetched/ the async SEEN path).entity.toDomain()from the row already read at :107 (orentity.copy(...)in the unread branch) instead of a secondgetById.SELECT id, accountId, folder, uid, isRead, isStarred, bodyFetched, isHtml …) for routing/flags callers (downloadAttachment, the first read inopenMessage,setStarred/deleteMessage/expunge/moveByRole/buildReplyDraft); reserveSELECT *(getById) for the one read that returns the body. No migration — mirrors the existingMessageSummaryprojection.inlineImages(:129-136) callsdownloadAttachmentpercid: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
MailRepositoryImplhas nowithContext;viewModelScopeisDispatchers.Main.immediate, so the Keystore decrypt and the per-partFile.exists()/length()(:152-158) + inline-imagereadBytes()(:129-136) run on the UI thread during the open animation.openMessage,downloadedAttachmentParts,inlineImages(and residual crypto) bodies inwithContext(Dispatchers.IO/Default).WebResourceResponsefrom the file instead ofreadBytes()into a heapByteArray.Already-optimal / low priority (not required)
wrapHtml'strimIndent()(HtmlBody.kt:190) scans the whole body (~1-4ms on device); could droptrimIndent()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.