perf(mailbox): page the per-account folder view and search #223

Merged
JMR-dev merged 2 commits from perf-mailbox-page-folder-search into main 2026-07-03 16:00:45 +00:00
JMR-dev commented 2026-07-03 15:33:24 +00:00 (Migrated from github.com)

What

Issue #124 paged only the unified All inboxes browse list. The per-account folder view and every search path (unified and per-account) were still non-paged: they loaded the whole folder / entire unified inbox into memory and re-materialized it on each cache write. This extends Paging 3 to all of them, mirroring the existing unified pager, so every mailbox list's query, mapping, and recomposition cost scales with the visible window rather than the whole cache.

Changes

  • MessageDao — three new read-only PagingSource<Int, MessageSummary> @Query methods:
    • pagingFolderSummaries(accountId, folder) — per-account browse (inInbox = 1), the account-scoped counterpart of pagingUnifiedFolderSummaries.
    • pagingUnifiedFolderSearchSummaries(folder, pattern) / pagingFolderSearchSummaries(accountId, folder, pattern) — paged search. They LIKE-match the same columns the old in-memory matchesSearch scanned (sender, sender address, subject, snippet) and, like the old filter, leave inInbox unfiltered so transient inInbox = 0 server-search hits still surface.
    • The now-dead observeFolderSummaries / observeUnifiedFolderSummaries are removed. No schema change / migration (read-only queries only).
  • MailRepository — pagedFolderMessages + the two paged-search flows, built via a shared mailboxPager whose PagingConfig uses pageSize = MAILBOX_PAGE_SIZE, initialLoadSize = MAILBOX_PAGE_SIZE * 3, enablePlaceholders = false, and maxSize = MAILBOX_PAGE_SIZE * 5 (so a long scroll drops far-offscreen pages). A likePattern helper escapes the LIKE metacharacters (\ % _) so a query containing them still matches literally. The dead observe*FolderMessages methods are removed.
  • MailboxViewModel — collapses the old messages list flow and the unified-only paged flow into a single pagedMessages that dispatches each (account, folder, query) to the matching pager; .cachedIn(viewModelScope) is kept so "select all" reads the loaded snapshot. matchesSearch (now done in SQL) is removed.
  • MailboxScreen — renders every mode from the one paged list. The empty state is gated on itemCount == 0 && loadState.refresh is LoadState.NotLoading && loadState.append.endOfPaginationReached, so it no longer flashes on an empty→loaded transition — while still preserving search "no results", the initial-sync spinner (#149), and browse "no messages".

Behaviour preserved

Search filtering columns/scope, multi-select and "select all", unread counts, and the whole browse-vs-search state machine (server search + 400 ms debounce + open/close/clear) are unchanged — only the local list materialization moved from Kotlin into paged SQL. LIKE is ASCII-case-insensitive by SQLite default, matching matchesSearch's common case.

Coordination with #216

#216 (perf-mailbox-list) owns pagedUnifiedFolderMessages (adding the same maxSize) and the MessageRow timestamp memoization — both are left untouched here. The maxSize above is applied only to the new configs.

Tests / gate

  • New MailRepositoryImplTest cases cover the three new paged flows and assert the escaped LIKE pattern (%50\%\_off%).
  • MailboxViewModelTest now verifies pagedMessages dispatches to the right pager for each of the four modes; obsolete observe* repo tests are removed.
  • E2E Fakes.kt gained the three paged overrides; MailboxScreenTest compiles unchanged.
  • Green locally on the fast gate (JDK 21): assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt. Emulator E2E left to CI.

Nothing deferred — both per-account browse and search are paged.

Closes #214

🤖 Generated with Claude Code

## What Issue #124 paged only the unified **All inboxes** browse list. The **per-account folder view** and **every search path** (unified and per-account) were still non-paged: they loaded the whole folder / entire unified inbox into memory and re-materialized it on each cache write. This extends Paging 3 to all of them, mirroring the existing unified pager, so every mailbox list's query, mapping, and recomposition cost scales with the visible window rather than the whole cache. ## Changes - **`MessageDao`** — three new read-only `PagingSource<Int, MessageSummary>` `@Query` methods: - `pagingFolderSummaries(accountId, folder)` — per-account browse (`inInbox = 1`), the account-scoped counterpart of `pagingUnifiedFolderSummaries`. - `pagingUnifiedFolderSearchSummaries(folder, pattern)` / `pagingFolderSearchSummaries(accountId, folder, pattern)` — paged search. They `LIKE`-match the **same columns** the old in-memory `matchesSearch` scanned (sender, sender address, subject, snippet) and, like the old filter, leave `inInbox` **unfiltered** so transient `inInbox = 0` server-search hits still surface. - The now-dead `observeFolderSummaries` / `observeUnifiedFolderSummaries` are removed. No schema change / migration (read-only queries only). - **`MailRepository`** — `pagedFolderMessages` + the two paged-search flows, built via a shared `mailboxPager` whose `PagingConfig` uses `pageSize = MAILBOX_PAGE_SIZE`, `initialLoadSize = MAILBOX_PAGE_SIZE * 3`, `enablePlaceholders = false`, **and `maxSize = MAILBOX_PAGE_SIZE * 5`** (so a long scroll drops far-offscreen pages). A `likePattern` helper escapes the `LIKE` metacharacters (`\ % _`) so a query containing them still matches literally. The dead `observe*FolderMessages` methods are removed. - **`MailboxViewModel`** — collapses the old `messages` list flow and the unified-only paged flow into a **single `pagedMessages`** that dispatches each `(account, folder, query)` to the matching pager; `.cachedIn(viewModelScope)` is kept so "select all" reads the loaded snapshot. `matchesSearch` (now done in SQL) is removed. - **`MailboxScreen`** — renders every mode from the one paged list. The empty state is gated on `itemCount == 0 && loadState.refresh is LoadState.NotLoading && loadState.append.endOfPaginationReached`, so it no longer flashes on an empty→loaded transition — while still preserving search "no results", the initial-sync spinner (#149), and browse "no messages". ## Behaviour preserved Search filtering columns/scope, multi-select and "select all", unread counts, and the whole browse-vs-search state machine (server search + 400 ms debounce + open/close/clear) are unchanged — only the local list *materialization* moved from Kotlin into paged SQL. `LIKE` is ASCII-case-insensitive by SQLite default, matching `matchesSearch`'s common case. ## Coordination with #216 #216 (`perf-mailbox-list`) owns `pagedUnifiedFolderMessages` (adding the same `maxSize`) and the `MessageRow` timestamp memoization — both are left **untouched** here. The `maxSize` above is applied only to the *new* configs. ## Tests / gate - New `MailRepositoryImplTest` cases cover the three new paged flows and assert the escaped `LIKE` pattern (`%50\%\_off%`). - `MailboxViewModelTest` now verifies `pagedMessages` dispatches to the right pager for each of the four modes; obsolete `observe*` repo tests are removed. - E2E `Fakes.kt` gained the three paged overrides; `MailboxScreenTest` compiles unchanged. - Green locally on the fast gate (JDK 21): `assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt`. Emulator E2E left to CI. Nothing deferred — both per-account browse **and** search are paged. Closes #214 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.