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

Follow-up to #148: opening an already-cached message was still slow. Three fixes
on the cached-open critical path (issue #186).

Fix 1 - WebView renders once. 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.
ReaderViewModel now resolves inline images and folds them into the SAME state
update as the body, and HtmlBody drops inline images from the reload key, so the
WebView loads exactly once and a late inline-image change never reloads. The
WebView is also destroyed onRelease so it (and its Context) is not leaked.
Pool/pre-warm is left as a TODO (leak-prone; single-render is the dominant win).

Fix 2 - openMessage does no wasted work for a cached, already-read message. Added
a body-less MessageRouting projection (mirrors MessageSummary, no migration);
the routing/flag callers (openMessage's first read, downloadAttachment, setStarred,
deleteMessage, expunge, moveByRole/moveToFolder, buildReplyDraft, prefetchMessage)
route on it, and getById (SELECT *) is reserved for the single read that returns
the body. imapParamsFor (Keystore decrypt + DataStore read) is resolved lazily,
only in the fetch / SEEN-push branches; the cached+read path also skips the
account lookup. De-duped the inlineImages attachment N+1 via a shared
ensureAttachmentFile helper that takes 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.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 20:33:07 -05:00
co-authored by Claude Opus 4.8
parent 1d63d6c7e1
commit aafb4f8f6a
8 changed files with 414 additions and 120 deletions
@@ -30,6 +30,7 @@ import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.MessageRouting
import org.libremail.data.local.entity.MessageSummary
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.settings.AccountSettingsRepository
@@ -176,6 +177,7 @@ class MailRepositoryImplTest {
@Test
fun `openMessage fetches the body from the message's own folder, not the inbox`() = runTest {
val id = "acct:Archive:5"
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "Archive")
coEvery { messageDao.getById(id) } returns messageEntity(id, "Archive")
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -194,6 +196,7 @@ class MailRepositoryImplTest {
@Test
fun `openMessage derives a readable plain-text snippet from an HTML body`() = runTest {
val id = "acct:INBOX:20"
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -224,6 +227,7 @@ class MailRepositoryImplTest {
// (CompletableDeferred.complete), and JUnit4 requires @Test methods to return void/Unit.
runBlocking {
val id = "acct:INBOX:40"
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true)
coEvery { messageDao.getById(id) } returns
messageEntity(id, "INBOX", bodyFetched = true) // isRead = false
coEvery { accountDao.getById("acct") } returns accountEntity()
@@ -252,6 +256,7 @@ class MailRepositoryImplTest {
@Test
fun `a failed SEEN-flag push is retried in the background`() = runBlocking {
val id = "acct:INBOX:41"
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true)
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX", bodyFetched = true)
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -267,12 +272,35 @@ class MailRepositoryImplTest {
}
}
@Test
fun `openMessage does no credential, network, or second full read for a cached read message`() = runTest {
val id = "acct:INBOX:42"
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true, isRead = true)
coEvery { messageDao.getById(id) } returns
messageEntity(id, "INBOX", bodyFetched = true, isRead = true, body = "Cached body")
val result = repository.openMessage(id)
// The reader still gets the stored body back...
assertEquals("Cached body", result.getOrThrow().body)
// ...but a cached, already-read open resolves no account/credentials, touches no network, and
// never re-marks the row read (issue #186 — the Keystore decrypt + DataStore read are skipped).
coVerify(exactly = 0) { accountDao.getById(any()) }
coVerify(exactly = 0) { connectionFactory.imapParamsFor(any()) }
coVerify(exactly = 0) { imapClient.fetchBodyMarkingSeen(any(), any(), any()) }
coVerify(exactly = 0) { imapClient.setFlag(any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { messageDao.setRead(any(), any()) }
// Routing used the body-less projection; the only full-body read is the single returned value.
coVerify(exactly = 1) { messageDao.getRouting(id) }
coVerify(exactly = 1) { messageDao.getById(id) }
}
@Test
fun `prefetchMessage leaves a plain-text body's literal angle brackets in the snippet`() = runTest {
val cache = Files.createTempDirectory("attach").toFile()
every { context.cacheDir } returns cache
val id = "acct:INBOX:21"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
coEvery { imapClient.fetchBodyPeek(any(), "INBOX", "21") } returns
@@ -290,7 +318,7 @@ class MailRepositoryImplTest {
@Test
fun `archive moves messages to the account's archive folder and drops the local rows`() = runTest {
val id = "acct:INBOX:5"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
coEvery { messageDao.deleteByIds(listOf(id)) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -306,7 +334,7 @@ class MailRepositoryImplTest {
@Test
fun `trash falls back to a permanent delete when the account has no trash folder`() = runTest {
val id = "acct:INBOX:7"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -324,7 +352,7 @@ class MailRepositoryImplTest {
@Test
fun `archive fails when the account has no archive folder`() = runTest {
val id = "acct:INBOX:9"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -338,7 +366,7 @@ class MailRepositoryImplTest {
@Test
fun `expunge permanently deletes each message from its own folder`() = runTest {
val id = "acct:Trash:3"
coEvery { messageDao.getById(id) } returns messageEntity(id, "Trash")
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "Trash"))
coEvery { messageDao.deleteByIds(listOf(id)) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -351,7 +379,7 @@ class MailRepositoryImplTest {
@Test
fun `moveToFolder moves messages to the chosen destination`() = runTest {
val id = "acct:INBOX:11"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
coEvery { messageDao.deleteByIds(listOf(id)) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -364,7 +392,7 @@ class MailRepositoryImplTest {
@Test
fun `buildReplyDraft fetches the original and saves a prefilled draft`() = runTest {
val id = "acct:INBOX:2"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
coEvery { signatureRepository.getDefault(any()) } returns null
@@ -393,7 +421,7 @@ class MailRepositoryImplTest {
@Test
fun `buildReplyDraft bakes the account default signature above the quote`() = runTest {
val id = "acct:INBOX:3"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
coEvery { signatureRepository.getDefault("acct") } returns org.libremail.domain.model.Signature(
@@ -429,7 +457,7 @@ class MailRepositoryImplTest {
val cache = Files.createTempDirectory("attach").toFile()
every { context.cacheDir } returns cache
val id = "acct:INBOX:4"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "report.pdf"))
val cached = File(cache, "attachments/acct_INBOX_4/0/report.pdf").apply {
parentFile?.mkdirs()
@@ -447,7 +475,7 @@ class MailRepositoryImplTest {
val cache = Files.createTempDirectory("attach").toFile()
every { context.cacheDir } returns cache
val id = "acct:INBOX:6"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "a.txt"))
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -482,7 +510,7 @@ class MailRepositoryImplTest {
val cache = Files.createTempDirectory("attach").toFile()
every { context.cacheDir } returns cache
val id = "acct:INBOX:30"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
coEvery { attachmentDao.getForMessage(id) } returns listOf(
AttachmentEntity(id, 0, "logo.png", "image/png", 4, contentId = "logo1"),
AttachmentEntity(id, 1, "invoice.pdf", "application/pdf", 10, contentId = null),
@@ -508,7 +536,7 @@ class MailRepositoryImplTest {
val cache = Files.createTempDirectory("attach").toFile()
every { context.cacheDir } returns cache
val id = "acct:INBOX:10"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") // bodyFetched = false
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX") // bodyFetched = false
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
coEvery { imapClient.fetchBodyPeek(any(), "INBOX", "10") } returns
@@ -533,8 +561,10 @@ class MailRepositoryImplTest {
fun `archive resolves a separate destination folder for each account`() = runTest {
val id1 = "acct:INBOX:1"
val id2 = "acct2:INBOX:1"
coEvery { messageDao.getById(id1) } returns messageEntity(id1, "INBOX", accountId = "acct")
coEvery { messageDao.getById(id2) } returns messageEntity(id2, "INBOX", accountId = "acct2")
coEvery { messageDao.getRoutingByIds(listOf(id1, id2)) } returns listOf(
messageRouting(id1, "INBOX", accountId = "acct"),
messageRouting(id2, "INBOX", accountId = "acct2"),
)
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity(id = "acct")
coEvery { accountDao.getById("acct2") } returns accountEntity(id = "acct2", email = "bob@example.org")
@@ -554,7 +584,7 @@ class MailRepositoryImplTest {
val cache = Files.createTempDirectory("attach").toFile()
every { context.cacheDir } returns cache
val id = "acct:INBOX:12"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX", bodyFetched = true)
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true)
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "f.bin"))
@@ -570,7 +600,7 @@ class MailRepositoryImplTest {
@Test
fun `archive refreshes folders once and retries when the archive folder is initially missing`() = runTest {
val id = "acct:INBOX:14"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -590,7 +620,7 @@ class MailRepositoryImplTest {
@Test
fun `reportSpam prefers the special-use spam folder when the user folder is listed first`() = runTest {
val id = "acct:INBOX:16"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -610,7 +640,7 @@ class MailRepositoryImplTest {
@Test
fun `reportSpam prefers the special-use spam folder when it is listed first`() = runTest {
val id = "acct:INBOX:18"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -629,7 +659,7 @@ class MailRepositoryImplTest {
@Test
fun `reportSpam keeps the first listed folder when no special-use folder holds the role`() = runTest {
val id = "acct:INBOX:20"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
@@ -658,21 +688,44 @@ class MailRepositoryImplTest {
private fun attachmentEntity(messageId: String, partIndex: Int, filename: String) =
AttachmentEntity(messageId, partIndex, filename, "application/octet-stream", 10L)
private fun messageEntity(id: String, folder: String, accountId: String = "acct", bodyFetched: Boolean = false) =
MessageEntity(
id = id,
accountId = accountId,
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
snippet = "snippet",
body = "",
timestampMillis = 1_000L,
isRead = false,
isStarred = false,
folder = folder,
bodyFetched = bodyFetched,
)
private fun messageEntity(
id: String,
folder: String,
accountId: String = "acct",
bodyFetched: Boolean = false,
isRead: Boolean = false,
body: String = "",
) = MessageEntity(
id = id,
accountId = accountId,
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
snippet = "snippet",
body = body,
timestampMillis = 1_000L,
isRead = isRead,
isStarred = false,
folder = folder,
bodyFetched = bodyFetched,
)
private fun messageRouting(
id: String,
folder: String,
accountId: String = "acct",
bodyFetched: Boolean = false,
isRead: Boolean = false,
) = MessageRouting(
id = id,
accountId = accountId,
folder = folder,
uid = id.substringAfterLast(':').toLongOrNull() ?: 0L,
isRead = isRead,
isStarred = false,
bodyFetched = bodyFetched,
isHtml = false,
)
private fun messageSummary(id: String, folder: String, accountId: String = "acct", bodyFetched: Boolean = false) =
MessageSummary(
@@ -19,6 +19,7 @@ import org.junit.Test
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
@@ -66,6 +67,28 @@ class ReaderViewModelTest {
assertEquals(setOf(0), vm.state.value.downloaded)
}
@Test
fun `inline images resolve into the same state update as the body so the reader renders once`() =
runTest(dispatcher) {
val htmlMessage = message.copy(isHtml = true, body = "<img src=\"cid:logo\">")
val image = InlineImage("logo", "image/png", byteArrayOf(1, 2, 3))
val repo = mockk<MailRepository>(relaxed = true)
coEvery { repo.openMessage(messageId) } returns Result.success(htmlMessage)
coEvery { repo.inlineImages(messageId) } returns listOf(image)
every { repo.observeAttachments(messageId) } returns flowOf(emptyList())
coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet()
val vm = viewModel(repo)
advanceUntilIdle()
val state = vm.state.value
// The body and its inline cid: image land together (loading already false), so HtmlBody
// first composes with the image in place and loads the WebView exactly once (issue #186).
assertEquals(false, state.loading)
assertEquals(htmlMessage, state.message)
assertEquals(setOf("logo"), state.inlineImages.keys)
}
@Test
fun `downloading an attachment adds its part to the downloaded set`() = runTest(dispatcher) {
val repo = mockk<MailRepository>(relaxed = true)