Follow-up to #86. PR #123 flattened the per-account+folder list; this pages the unified "All inboxes" browse view — the remaining O(N) path that scanned in timestamp order (no folder-leading index) and materialized the entire unified inbox (~4k rows at a 20k cache) into memory on every emission.
MessageDao.pagingUnifiedFolderSummaries(folder) — a Paging 3 PagingSource<Int, MessageSummary> over WHERE folder = ? AND inInbox = 1 ORDER BY timestampMillis DESC (synced rows only; unified search keeps observeUnifiedFolderSummaries, which also surfaces transient inInbox = 0 hits).
MailRepository.pagedUnifiedFolderMessages(folder) — wraps it in a Pager (pageSize 40, initialLoadSize 120, no placeholders) and maps summaries to domain.
MailboxViewModel.pagedMessages — flatMapLatest to the paged flow while browsing the unified inbox (no account, no active search), else empty; cachedIn(viewModelScope). The messages list flow now stays empty while browsing the unified inbox, so the whole inbox is never pulled into memory; it still serves per-account views and unified search. Selection carries each row's accountId (captured at tap time) so Move still resolves the selection's account without a full in-memory list.
MailboxScreen renders the unified browse list via collectAsLazyPagingItems(); per-account/search render the flat list unchanged.
The per-account+folder path from #86/#123 is untouched (still flat — no regression).
Profiling — before / after
Measured on the libremail_api29 emulator with a temporary instrumented probe (removed after measuring, like #86's), seeded across 3 accounts × 5 folders (INBOX ≈ 20%). Full write-up in docs/perf/issue-124-unified-inbox-paging.md.
total rows
INBOX rows
CURRENT whole-inbox first-emit (med/min ms)
PAGED first page 120 (med/min)
PAGED deep page 40 (med/min)
5,000
1,000
6.80 / 4.93
5.44 / 4.64
3.67 / 2.83
20,000
4,000
24.56 / 18.89
6.82 / 5.67
12.86 / 10.52
The current query grows with inbox size; the paged first page is flat (~5–7 ms) regardless of total cache — ~3.6× at 20k, and it stays flat as the cache keeps growing (full-history backfill). Cross-checked on a physical Pixel (API 37) at 20k: 27.2 → 8.6 ms first page (same shape).
No index, no schema migration
EXPLAIN QUERY PLAN for the paged query (existing indices only):
SCAN TABLE messages USING INDEX index_messages_timestampMillis (LIMIT 120)
The LIMIT lets the planner stop after a screenful, so the first page is already flat without a folder-leading index. A (folder, inInbox, timestampMillis) index would only help a deep scroll (large OFFSET), which is rare, bounded by scroll depth (not total cache), and already faster than the current whole-inbox load. So no index and no messages schema change — the cache DB stays at version 15, and there is no #118 version coordination needed. (Had an index been required, it would have been v15→v16 and would have collided with the in-flight #118 — that renumber dance is avoided entirely.)
Tests
MailRepositoryImplTest: pagedUnifiedFolderMessages maps paged summaries to domain (via asSnapshot over a fake PagingSource).
MailboxViewModelTest: unified browse keeps the list flow empty (paged path taken); a concrete account renders the flat scoped list; selection / canMove / selectAll updated for the accountId-capturing selection.
MailboxScreenTest (E2E) already drives the unified/paged path with long-press selection, archive, spam, delete, and move — 11/11 green on the emulator, confirming paged rendering + selection.
Fast gate (assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt + compileDebugAndroidTestKotlin) is green.
## Summary
Follow-up to #86. PR #123 flattened the per-account+folder list; this pages the unified **"All inboxes"** browse view — the remaining O(N) path that scanned in timestamp order (no `folder`-leading index) and materialized the entire unified inbox (~4k rows at a 20k cache) into memory on every emission.
- **`MessageDao.pagingUnifiedFolderSummaries(folder)`** — a Paging 3 `PagingSource<Int, MessageSummary>` over `WHERE folder = ? AND inInbox = 1 ORDER BY timestampMillis DESC` (synced rows only; unified **search** keeps `observeUnifiedFolderSummaries`, which also surfaces transient `inInbox = 0` hits).
- **`MailRepository.pagedUnifiedFolderMessages(folder)`** — wraps it in a `Pager` (pageSize 40, initialLoadSize 120, no placeholders) and maps summaries to domain.
- **`MailboxViewModel.pagedMessages`** — `flatMapLatest` to the paged flow while browsing the unified inbox (no account, no active search), else empty; `cachedIn(viewModelScope)`. The `messages` list flow now stays **empty** while browsing the unified inbox, so the whole inbox is never pulled into memory; it still serves per-account views and unified search. Selection carries each row's `accountId` (captured at tap time) so **Move** still resolves the selection's account without a full in-memory list.
- **`MailboxScreen`** renders the unified browse list via `collectAsLazyPagingItems()`; per-account/search render the flat list unchanged.
The per-account+folder path from #86/#123 is untouched (still flat — no regression).
## Profiling — before / after
Measured on the `libremail_api29` emulator with a temporary instrumented probe (removed after measuring, like #86's), seeded across 3 accounts × 5 folders (INBOX ≈ 20%). Full write-up in `docs/perf/issue-124-unified-inbox-paging.md`.
| total rows | INBOX rows | CURRENT whole-inbox first-emit (med/min ms) | PAGED first page 120 (med/min) | PAGED deep page 40 (med/min) |
|---:|---:|---:|---:|---:|
| 5,000 | 1,000 | 6.80 / 4.93 | 5.44 / 4.64 | 3.67 / 2.83 |
| 20,000 | 4,000 | **24.56 / 18.89** | **6.82 / 5.67** | 12.86 / 10.52 |
The current query grows with inbox size; the paged **first page is flat** (~5–7 ms) regardless of total cache — **~3.6× at 20k**, and it stays flat as the cache keeps growing (full-history backfill). Cross-checked on a physical Pixel (API 37) at 20k: 27.2 → 8.6 ms first page (same shape).
## No index, no schema migration
`EXPLAIN QUERY PLAN` for the paged query (existing indices only):
```
SCAN TABLE messages USING INDEX index_messages_timestampMillis (LIMIT 120)
```
The `LIMIT` lets the planner stop after a screenful, so the first page is already flat **without** a `folder`-leading index. A `(folder, inInbox, timestampMillis)` index would only help a deep scroll (large `OFFSET`), which is rare, bounded by scroll depth (not total cache), and already faster than the current whole-inbox load. So **no index and no `messages` schema change** — the cache DB stays at **version 15**, and there is **no #118 version coordination needed**. (Had an index been required, it would have been v15→v16 and would have collided with the in-flight #118 — that renumber dance is avoided entirely.)
## Tests
- `MailRepositoryImplTest`: `pagedUnifiedFolderMessages` maps paged summaries to domain (via `asSnapshot` over a fake `PagingSource`).
- `MailboxViewModelTest`: unified browse keeps the list flow empty (paged path taken); a concrete account renders the flat scoped list; selection / `canMove` / `selectAll` updated for the accountId-capturing selection.
- `MailboxScreenTest` (E2E) already drives the unified/paged path with long-press selection, archive, spam, delete, and move — **11/11 green on the emulator**, confirming paged rendering + selection.
Fast gate (`assembleDebug` + `testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt` + `compileDebugAndroidTestKotlin`) is green.
Closes #124
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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
Follow-up to #86. PR #123 flattened the per-account+folder list; this pages the unified "All inboxes" browse view — the remaining O(N) path that scanned in timestamp order (no
folder-leading index) and materialized the entire unified inbox (~4k rows at a 20k cache) into memory on every emission.MessageDao.pagingUnifiedFolderSummaries(folder)— a Paging 3PagingSource<Int, MessageSummary>overWHERE folder = ? AND inInbox = 1 ORDER BY timestampMillis DESC(synced rows only; unified search keepsobserveUnifiedFolderSummaries, which also surfaces transientinInbox = 0hits).MailRepository.pagedUnifiedFolderMessages(folder)— wraps it in aPager(pageSize 40, initialLoadSize 120, no placeholders) and maps summaries to domain.MailboxViewModel.pagedMessages—flatMapLatestto the paged flow while browsing the unified inbox (no account, no active search), else empty;cachedIn(viewModelScope). Themessageslist flow now stays empty while browsing the unified inbox, so the whole inbox is never pulled into memory; it still serves per-account views and unified search. Selection carries each row'saccountId(captured at tap time) so Move still resolves the selection's account without a full in-memory list.MailboxScreenrenders the unified browse list viacollectAsLazyPagingItems(); per-account/search render the flat list unchanged.The per-account+folder path from #86/#123 is untouched (still flat — no regression).
Profiling — before / after
Measured on the
libremail_api29emulator with a temporary instrumented probe (removed after measuring, like #86's), seeded across 3 accounts × 5 folders (INBOX ≈ 20%). Full write-up indocs/perf/issue-124-unified-inbox-paging.md.The current query grows with inbox size; the paged first page is flat (~5–7 ms) regardless of total cache — ~3.6× at 20k, and it stays flat as the cache keeps growing (full-history backfill). Cross-checked on a physical Pixel (API 37) at 20k: 27.2 → 8.6 ms first page (same shape).
No index, no schema migration
EXPLAIN QUERY PLANfor the paged query (existing indices only):The
LIMITlets the planner stop after a screenful, so the first page is already flat without afolder-leading index. A(folder, inInbox, timestampMillis)index would only help a deep scroll (largeOFFSET), which is rare, bounded by scroll depth (not total cache), and already faster than the current whole-inbox load. So no index and nomessagesschema change — the cache DB stays at version 15, and there is no #118 version coordination needed. (Had an index been required, it would have been v15→v16 and would have collided with the in-flight #118 — that renumber dance is avoided entirely.)Tests
MailRepositoryImplTest:pagedUnifiedFolderMessagesmaps paged summaries to domain (viaasSnapshotover a fakePagingSource).MailboxViewModelTest: unified browse keeps the list flow empty (paged path taken); a concrete account renders the flat scoped list; selection /canMove/selectAllupdated for the accountId-capturing selection.MailboxScreenTest(E2E) already drives the unified/paged path with long-press selection, archive, spam, delete, and move — 11/11 green on the emulator, confirming paged rendering + selection.Fast gate (
assembleDebug+testDebugUnitTest+lintDebug+ktlintCheck+detekt+compileDebugAndroidTestKotlin) is green.Closes #124
🤖 Generated with Claude Code