diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt index 0d1a748..0e0a82d 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt @@ -53,7 +53,7 @@ class DatabaseEncryptionTest { DatabaseEncryption.ensureEncrypted(dbFile, passphrase) assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile)) openEncrypted().apply { - assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id }) + assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id }) close() } @@ -61,7 +61,7 @@ class DatabaseEncryptionTest { DatabaseEncryption.ensurePlaintext(dbFile, passphrase) assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile)) openPlaintext().apply { - assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id }) + assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id }) close() } } diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt index 460b2e2..59c8b69 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt @@ -102,10 +102,30 @@ class LibreMailDatabaseTest { assertEquals(listOf("acct:1"), messageDao.getSyncedIds("acct", "INBOX")) messageDao.deleteSearchRows() - val remaining = messageDao.observeAll().first().map { it.id } + val remaining = messageDao.observeSummaries().first().map { it.id } assertEquals(listOf("acct:1"), remaining) } + @Test + fun observeSummariesReadsRowsWhoseBodiesExceedTheCursorWindow() = runBlocking { + val messageDao = db.messageDao() + // Each body is larger than SQLite's shared (~2 MB) CursorWindow. The old list query did + // SELECT * and dragged these bodies through the window, overflowing it with + // "Couldn't read row … from CursorWindow" (issue #51). observeSummaries omits body, so the + // rows stay tiny and read fine. + val hugeBody = "x".repeat(3 * 1024 * 1024) + messageDao.insertNew( + listOf( + message("acct:1", body = hugeBody), + message("acct:2", body = hugeBody), + ), + ) + + val ids = messageDao.observeSummaries().first().map { it.id }.toSet() + + assertEquals(setOf("acct:1", "acct:2"), ids) + } + @Test fun foldersAreStoredOrderedAndReplaceablePerAccount() = runBlocking { val folderDao = db.folderDao() @@ -146,7 +166,7 @@ class LibreMailDatabaseTest { messageDao.deleteSyncedNotIn("acct", "INBOX", listOf("acct:INBOX:1")) assertEquals( setOf("acct:INBOX:1", "acct:Archive:1"), - messageDao.observeAll().first().map { it.id }.toSet(), + messageDao.observeSummaries().first().map { it.id }.toSet(), ) } } diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index a6b444f..0b970b9 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -9,6 +9,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.MessageSummary import org.libremail.data.local.entity.OutboxEntity import org.libremail.data.local.entity.ServerConfigEmbedded import org.libremail.domain.model.Account @@ -103,6 +104,28 @@ internal fun MessageEntity.toDomain(): Message = Message( bodyFetched = bodyFetched, ) +/** + * Maps a mailbox-list projection to the domain model. [Message.body]/[Message.isHtml] are left + * empty because the list never renders them — the reader loads the body on demand (see + * [MessageSummary]). + */ +internal fun MessageSummary.toDomain(): Message = Message( + id = id, + accountId = accountId, + sender = sender, + senderEmail = senderEmail, + subject = subject, + snippet = snippet, + body = "", + isHtml = false, + timestampMillis = timestampMillis, + isRead = isRead, + isStarred = isStarred, + folder = folder, + inInbox = inInbox, + bodyFetched = bodyFetched, +) + internal fun FetchedMessage.toEntity(accountId: String, folder: String, inInbox: Boolean = true): MessageEntity = MessageEntity( id = "$accountId:$folder:$uid", diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt index db2495b..6832e45 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt @@ -7,11 +7,21 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow import org.libremail.data.local.entity.MessageEntity +import org.libremail.data.local.entity.MessageSummary @Dao interface MessageDao { - @Query("SELECT * FROM messages ORDER BY timestampMillis DESC") - fun observeAll(): Flow> + /** + * Mailbox-list projection ordered newest-first. Deliberately omits the large `body`/`isHtml` + * columns: the list observes every cached message at once, and pulling full bodies through + * SQLite's shared ~2 MB CursorWindow overflows it once enough large bodies are cached + * (issue #51). Bodies are loaded lazily per-message via [getById] when a message is opened. + */ + @Query( + "SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " + + "isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC", + ) + fun observeSummaries(): Flow> @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") suspend fun getById(id: String): MessageEntity? diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/MessageSummary.kt b/app/src/main/kotlin/org/libremail/data/local/entity/MessageSummary.kt new file mode 100644 index 0000000..c9b259d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/MessageSummary.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +/** + * Lightweight projection of [MessageEntity] for the mailbox list: every column the list renders + * or searches, but *not* the potentially large `body`/`isHtml`. + * + * The list observes every cached message at once, so selecting full HTML bodies would drag them + * all through SQLite's shared (~2 MB) CursorWindow and overflow it once enough large bodies are + * cached — crashing with "Couldn't read row N from CursorWindow" (issue #51). Bodies are read + * lazily, one message at a time, via `MessageDao.getById` when a message is opened. + */ +data class MessageSummary( + val id: String, + val accountId: String, + val sender: String, + val senderEmail: String, + val subject: String, + val snippet: String, + val timestampMillis: Long, + val isRead: Boolean, + val isStarred: Boolean, + val folder: String, + val inInbox: Boolean, + val bodyFetched: Boolean, +) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index d6ec723..6ae11cd 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -57,7 +57,7 @@ class MailRepositoryImpl @Inject constructor( private val signatureRepository: SignatureRepository, ) : MailRepository { - override fun observeMessages(): Flow> = messageDao.observeAll().map { rows -> + override fun observeMessages(): Flow> = messageDao.observeSummaries().map { rows -> rows.map { it.toDomain() } } diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index a40ed20..72ba7ac 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -23,6 +23,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.MessageSummary import org.libremail.data.local.entity.ServerConfigEmbedded import org.libremail.data.settings.AccountSettingsRepository import org.libremail.data.settings.SignatureRepository @@ -73,7 +74,7 @@ class MailRepositoryImplTest { @Test fun `observeMessages is empty when the cache is empty`() = runTest { - every { messageDao.observeAll() } returns flowOf(emptyList()) + every { messageDao.observeSummaries() } returns flowOf(emptyList()) repository.observeMessages().test { assertTrue(awaitItem().isEmpty()) awaitComplete() @@ -82,8 +83,7 @@ class MailRepositoryImplTest { @Test fun `observeMessages maps cached entities`() = runTest { - val entity = messageEntity("1", "INBOX") - every { messageDao.observeAll() } returns flowOf(listOf(entity)) + every { messageDao.observeSummaries() } returns flowOf(listOf(messageSummary("1", "INBOX"))) repository.observeMessages().test { val items = awaitItem() assertEquals(1, items.size) @@ -421,6 +421,22 @@ class MailRepositoryImplTest { bodyFetched = bodyFetched, ) + private fun messageSummary(id: String, folder: String, accountId: String = "acct", bodyFetched: Boolean = false) = + MessageSummary( + id = id, + accountId = accountId, + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "snippet", + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + folder = folder, + inInbox = true, + bodyFetched = bodyFetched, + ) + private fun accountEntity(id: String = "acct", email: String = "ada@example.org") = AccountEntity( id = id, email = email,