The app crashes with a fatal IllegalStateException: Couldn't read row 14, col 0 from CursorWindow when the message list is being observed. The mailbox query MessageDao.observeAll() does SELECT * FROM messages, which pulls every message's full body column into a single 2 MB CursorWindow. Once enough large (HTML) bodies are cached, a row no longer fits the window and any read of it throws, taking down the process.
Captured from adb logcat -b crash on the connected device.
Crash signature
java.lang.IllegalStateException: Couldn't read row 14, col 0 from CursorWindow.
Make sure the Cursor is initialized correctly before accessing data from it.
Deobfuscated stack trace
Retraced against app/build/outputs/mapping/release/mapping.txt (pg_map_id matches the crash's r8-map-id):
java.lang.IllegalStateException: Couldn't read row 14, col 0 from CursorWindow.
at android.database.CursorWindow.nativeGetString(Native Method)
at android.database.CursorWindow.getString(CursorWindow.java:505)
at android.database.AbstractWindowedCursor.getString(AbstractWindowedCursor.java:54)
at androidx.sqlite.driver.SupportSQLiteStatement$RowSQLiteStatement.getText(SupportSQLiteStatement.android.kt:370)
at org.libremail.data.local.dao.MessageDao_Impl.observeAll$lambda$0(MessageDao_Impl.kt:89)
at androidx.room.util.DBUtil...performSuspending$...internalPerform(DBUtil__DBUtil_android.kt:173)
at androidx.room.coroutines.PassthroughConnectionPool$useConnection$2.invokeSuspend(PassthroughConnectionPool.java:59)
at androidx.room.RoomDatabase.useConnection(RoomDatabase.android.kt:619)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1100)
Root cause
MessageDao.observeAll() backs the mailbox list:
// MessageDao.kt:13
@Query("SELECT * FROM messages ORDER BY timestampMillis DESC")funobserveAll():Flow<List<MessageEntity>>
MessageEntity (MessageEntity.kt) carries the full email body as a column:
valsnippet:String,valbody:String,// full (often HTML) message body — can be very large
The list consumer only needs header/preview fields — MailRepositoryImpl.observeMessages() maps rows straight to the list domain model and the body is separately (lazily) fetched via getById/openMessage when a message is opened. But because the observe query is SELECT *, every row drags its entire body through the shared 2 MB CursorWindow. With ~14+ rows of cached HTML bodies, the cumulative/row size exceeds the window and the read faults. col 0 is incidental (first column read once the window is already over capacity); the real problem is the oversized body payload in a list query.
Suggested fix
Stop selecting body (and any other large columns) in the list/observe path — project only the columns the list renders:
Add a lightweight list projection (a @Query selecting explicit columns into a small DTO, or a @DatabaseView / partial-entity data class) and point observeMessages() at it, e.g. SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC.
Keep loading the full body only in the detail path (getById), which is already how openMessage works.
This both fixes the CursorWindow overflow and reduces memory churn for the list. A regression test with a large (>2 MB total across rows) cached body would guard it.
Repro
Sync/open enough messages so that several have large HTML bodies cached (bodyFetched = 1).
Return to the mailbox list so observeAll() re-emits.
App crashes as the cursor walks past the row that overflows the window.
Logs pulled from the connected device via adb logcat -b crash and retraced with the release R8 mapping.
## Summary
The app crashes with a fatal `IllegalStateException: Couldn't read row 14, col 0 from CursorWindow` when the message list is being observed. The mailbox query `MessageDao.observeAll()` does `SELECT * FROM messages`, which pulls every message's **full `body`** column into a single 2 MB `CursorWindow`. Once enough large (HTML) bodies are cached, a row no longer fits the window and any read of it throws, taking down the process.
## Environment
- **App:** `org.libremail.app` v0.1.0 (versionCode 1) — release/minified build
- **Device:** Pixel 10 Pro XL, Android 17 (SDK 37), build `CP2A.260605.012`
- **Observed:** 2026-07-01 ~13:24 (crashed twice back-to-back)
- Captured from `adb logcat -b crash` on the connected device.
## Crash signature
```
java.lang.IllegalStateException: Couldn't read row 14, col 0 from CursorWindow.
Make sure the Cursor is initialized correctly before accessing data from it.
```
## Deobfuscated stack trace
Retraced against `app/build/outputs/mapping/release/mapping.txt` (`pg_map_id` matches the crash's `r8-map-id`):
```
java.lang.IllegalStateException: Couldn't read row 14, col 0 from CursorWindow.
at android.database.CursorWindow.nativeGetString(Native Method)
at android.database.CursorWindow.getString(CursorWindow.java:505)
at android.database.AbstractWindowedCursor.getString(AbstractWindowedCursor.java:54)
at androidx.sqlite.driver.SupportSQLiteStatement$RowSQLiteStatement.getText(SupportSQLiteStatement.android.kt:370)
at org.libremail.data.local.dao.MessageDao_Impl.observeAll$lambda$0(MessageDao_Impl.kt:89)
at androidx.room.util.DBUtil...performSuspending$...internalPerform(DBUtil__DBUtil_android.kt:173)
at androidx.room.coroutines.PassthroughConnectionPool$useConnection$2.invokeSuspend(PassthroughConnectionPool.java:59)
at androidx.room.RoomDatabase.useConnection(RoomDatabase.android.kt:619)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1100)
```
## Root cause
`MessageDao.observeAll()` backs the mailbox list:
```kotlin
// MessageDao.kt:13
@Query("SELECT * FROM messages ORDER BY timestampMillis DESC")
fun observeAll(): Flow<List<MessageEntity>>
```
`MessageEntity` (`MessageEntity.kt`) carries the full email body as a column:
```kotlin
val snippet: String,
val body: String, // full (often HTML) message body — can be very large
```
The list consumer only needs header/preview fields — `MailRepositoryImpl.observeMessages()` maps rows straight to the list domain model and the body is separately (lazily) fetched via `getById`/`openMessage` when a message is opened. But because the observe query is `SELECT *`, every row drags its entire `body` through the shared 2 MB `CursorWindow`. With ~14+ rows of cached HTML bodies, the cumulative/row size exceeds the window and the read faults. `col 0` is incidental (first column read once the window is already over capacity); the real problem is the oversized `body` payload in a list query.
## Suggested fix
Stop selecting `body` (and any other large columns) in the list/observe path — project only the columns the list renders:
- Add a lightweight list projection (a `@Query` selecting explicit columns into a small DTO, or a `@DatabaseView` / partial-entity `data class`) and point `observeMessages()` at it, e.g.
`SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC`.
- Keep loading the full `body` only in the detail path (`getById`), which is already how `openMessage` works.
This both fixes the CursorWindow overflow and reduces memory churn for the list. A regression test with a large (>2 MB total across rows) cached body would guard it.
## Repro
1. Sync/open enough messages so that several have large HTML bodies cached (`bodyFetched = 1`).
2. Return to the mailbox list so `observeAll()` re-emits.
3. App crashes as the cursor walks past the row that overflows the window.
---
_Logs pulled from the connected device via `adb logcat -b crash` and retraced with the release R8 mapping._
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.
Summary
The app crashes with a fatal
IllegalStateException: Couldn't read row 14, col 0 from CursorWindowwhen the message list is being observed. The mailbox queryMessageDao.observeAll()doesSELECT * FROM messages, which pulls every message's fullbodycolumn into a single 2 MBCursorWindow. Once enough large (HTML) bodies are cached, a row no longer fits the window and any read of it throws, taking down the process.Environment
org.libremail.appv0.1.0 (versionCode 1) — release/minified buildCP2A.260605.012adb logcat -b crashon the connected device.Crash signature
Deobfuscated stack trace
Retraced against
app/build/outputs/mapping/release/mapping.txt(pg_map_idmatches the crash'sr8-map-id):Root cause
MessageDao.observeAll()backs the mailbox list:MessageEntity(MessageEntity.kt) carries the full email body as a column:The list consumer only needs header/preview fields —
MailRepositoryImpl.observeMessages()maps rows straight to the list domain model and the body is separately (lazily) fetched viagetById/openMessagewhen a message is opened. But because the observe query isSELECT *, every row drags its entirebodythrough the shared 2 MBCursorWindow. With ~14+ rows of cached HTML bodies, the cumulative/row size exceeds the window and the read faults.col 0is incidental (first column read once the window is already over capacity); the real problem is the oversizedbodypayload in a list query.Suggested fix
Stop selecting
body(and any other large columns) in the list/observe path — project only the columns the list renders:@Queryselecting explicit columns into a small DTO, or a@DatabaseView/ partial-entitydata class) and pointobserveMessages()at it, e.g.SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC.bodyonly in the detail path (getById), which is already howopenMessageworks.This both fixes the CursorWindow overflow and reduces memory churn for the list. A regression test with a large (>2 MB total across rows) cached body would guard it.
Repro
bodyFetched = 1).observeAll()re-emits.Logs pulled from the connected device via
adb logcat -b crashand retraced with the release R8 mapping.