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
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.entity.MessageEntity
/**
* Real-SQLite behavior of the body-less routing projection [MessageDao.getRouting] /
* [MessageDao.getRoutingByIds] (issue #186). The open path and the flag/move callers route on these
* instead of pulling the whole `body` blob through [MessageDao.getById]. These tests pin that the
* projection maps every routing/flag column correctly (and can do so for a row whose body is large,
* which is exactly the over-fetch the projection avoids).
*/
@RunWith(AndroidJUnit4::class)
class MessageDaoRoutingTest {
private lateinit var db: LibreMailDatabase
private lateinit var dao: MessageDao
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
dao = db.messageDao()
}
@After
fun tearDown() = db.close()
@Suppress("LongParameterList")
private fun message(
id: String,
accountId: String = "acct",
folder: String = "INBOX",
uid: Long = 0L,
isRead: Boolean = false,
isStarred: Boolean = false,
bodyFetched: Boolean = false,
isHtml: Boolean = false,
body: String = "",
) = MessageEntity(
id = id,
accountId = accountId,
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
snippet = "",
body = body,
isHtml = isHtml,
timestampMillis = 1_000L,
isRead = isRead,
isStarred = isStarred,
folder = folder,
bodyFetched = bodyFetched,
uid = uid,
)
@Test
fun getRoutingProjectsEveryRoutingAndFlagColumn() = runBlocking {
dao.insertNew(
listOf(
message(
id = "acct:Archive:9",
folder = "Archive",
uid = 9,
isRead = true,
isStarred = true,
bodyFetched = true,
isHtml = true,
body = "x".repeat(4_000), // a large body the projection must not need to read
),
),
)
val routing = requireNotNull(dao.getRouting("acct:Archive:9"))
assertEquals("acct:Archive:9", routing.id)
assertEquals("acct", routing.accountId)
assertEquals("Archive", routing.folder)
assertEquals(9L, routing.uid)
assertEquals(true, routing.isRead)
assertEquals(true, routing.isStarred)
assertEquals(true, routing.bodyFetched)
assertEquals(true, routing.isHtml)
}
@Test
fun getRoutingIsNullForAnUnknownId() = runBlocking {
assertNull(dao.getRouting("acct:INBOX:404"))
}
@Test
fun getRoutingByIdsReturnsOnlyTheRequestedRows() = runBlocking {
dao.insertNew(
listOf(
message(id = "acct:INBOX:1", folder = "INBOX", uid = 1),
message(id = "acct:INBOX:2", folder = "INBOX", uid = 2),
message(id = "acct2:Sent:3", folder = "Sent", accountId = "acct2", uid = 3),
),
)
val routings = dao.getRoutingByIds(listOf("acct:INBOX:1", "acct2:Sent:3"))
assertEquals(setOf("acct:INBOX:1", "acct2:Sent:3"), routings.map { it.id }.toSet())
assertEquals(setOf("INBOX", "Sent"), routings.map { it.folder }.toSet())
}
}