perf(reader): render message body once, move openMessage IO off-main, drop wasted work #188

Merged
JMR-dev merged 2 commits from perf-186-message-open into main 2026-07-03 02:02:42 +00:00
JMR-dev commented 2026-07-03 01:09:47 +00:00 (Migrated from github.com)

Follow-up to #148 — opening an already-cached message was still slow. Fixes the three dominant costs the profiling in #186 identified on the cached-open critical path.

Fix 1 — WebView renders once (biggest win)

ui/reader/HtmlBody.kt + ui/reader/ReaderViewModel.kt. The reader resolved cid: inline images after the first render, so the AndroidView update key (which included inlineImages.keys) changed and reloaded the whole document a second time for any inline-image email. Now:

  • ReaderViewModel resolves inline images and folds them into the same state update as the body, so HtmlBody first composes with the images already in place.
  • HtmlBody drops inline images from the reload key — a late inline-image change no longer reloads the page.
  • The WebView is destroyed in onRelease so neither it nor its Context leaks.

WebView pool/pre-warm is intentionally deferred (leak-prone) with a TODO(#186) — the single-render fix is the dominant win.

Fix 2 — openMessage does no wasted work for a cached/read message

data/repository/MailRepositoryImpl.kt + a new MessageRouting projection.

  • Body-less MessageRouting projection (mirrors MessageSummary, no migration). Routing/flag callers route on it; getById (SELECT *) is reserved for the single read that returns the body.
  • connectionFactory.imapParamsFor (Keystore decrypt + DataStore read) is resolved lazily, only in the fetch / SEEN-push branches. The cached + already-read path also skips the account lookup entirely.
  • De-duped the inlineImages attachment N+1 (was a getById + getForMessage per cid: image) via a shared ensureAttachmentFile helper taking the already-resolved account/folder.

Fix 3 — repository IO off the main thread

openMessage, inlineImages, downloadedAttachmentParts, and downloadAttachment now run in withContext(Dispatchers.IO), so their DB / file / crypto work no longer runs on the Main.immediate viewModelScope during the open animation.

Tests / validation

  • New MailRepositoryImplTest: a cached, already-read openMessage does no imapParamsFor, no network, no setRead, and exactly one full-body getById (routing goes through the projection).
  • New ReaderViewModelTest: inline images land in the same state update as the body (reader renders once).
  • New instrumented MessageDaoRoutingTest: the projection maps every routing/flag column.
  • Existing repository tests updated to the projection DAO methods.
  • Local gate green: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt + compileDebugAndroidTestKotlin (JDK 21).

Reader behavior (content, read/SEEN semantics) is unchanged. Does not touch the async SEEN network push handled separately by #170.

Closes #186

🤖 Generated with Claude Code

Follow-up to #148 — opening an already-cached message was still slow. Fixes the three dominant costs the profiling in #186 identified on the cached-open critical path. ## Fix 1 — WebView renders once (biggest win) `ui/reader/HtmlBody.kt` + `ui/reader/ReaderViewModel.kt`. The reader resolved `cid:` inline images *after* the first render, so the `AndroidView` `update` key (which included `inlineImages.keys`) changed and reloaded the whole document a **second time** for any inline-image email. Now: - `ReaderViewModel` resolves inline images and folds them into the **same** state update as the body, so `HtmlBody` first composes with the images already in place. - `HtmlBody` drops inline images from the reload key — a late inline-image change no longer reloads the page. - The WebView is destroyed in `onRelease` so neither it nor its `Context` leaks. WebView pool/pre-warm is intentionally **deferred** (leak-prone) with a `TODO(#186)` — the single-render fix is the dominant win. ## Fix 2 — `openMessage` does no wasted work for a cached/read message `data/repository/MailRepositoryImpl.kt` + a new `MessageRouting` projection. - Body-less `MessageRouting` projection (mirrors `MessageSummary`, **no migration**). Routing/flag callers route on it; `getById` (`SELECT *`) is reserved for the single read that returns the body. - `connectionFactory.imapParamsFor` (Keystore decrypt + DataStore read) is resolved **lazily**, only in the fetch / SEEN-push branches. The cached + already-read path also skips the account lookup entirely. - De-duped the `inlineImages` attachment N+1 (was a `getById` + `getForMessage` per `cid:` image) via a shared `ensureAttachmentFile` helper taking the already-resolved account/folder. ## Fix 3 — repository IO off the main thread `openMessage`, `inlineImages`, `downloadedAttachmentParts`, and `downloadAttachment` now run in `withContext(Dispatchers.IO)`, so their DB / file / crypto work no longer runs on the `Main.immediate` `viewModelScope` during the open animation. ## Tests / validation - New `MailRepositoryImplTest`: a cached, already-read `openMessage` does no `imapParamsFor`, no network, no `setRead`, and exactly one full-body `getById` (routing goes through the projection). - New `ReaderViewModelTest`: inline images land in the same state update as the body (reader renders once). - New instrumented `MessageDaoRoutingTest`: the projection maps every routing/flag column. - Existing repository tests updated to the projection DAO methods. - Local gate green: `assembleDebug` + `testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt` + `compileDebugAndroidTestKotlin` (JDK 21). Reader behavior (content, read/SEEN semantics) is unchanged. Does **not** touch the async SEEN network push handled separately by #170. Closes #186 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.