Merge main into fix-prune-backfill-cache-lock-gate

This commit is contained in:
Jason Ross
2026-07-03 11:01:14 -05:00
committed by GitHub
8 changed files with 319 additions and 248 deletions
@@ -88,15 +88,25 @@ class FakeMailRepository(
val movedToFolder = mutableListOf<Pair<List<String>, String>>()
val replyDrafts = mutableListOf<Pair<String, ReplyMode>>()
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
flowOf(messages.filter { it.accountId == accountId && it.folder == folder })
override fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>> =
flowOf(messages.filter { it.folder == folder })
override fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>> =
flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
override fun pagedFolderMessages(accountId: String, folder: String): Flow<PagingData<Message>> =
flowOf(PagingData.from(messages.filter { it.accountId == accountId && it.folder == folder && it.inInbox }))
override fun pagedUnifiedSearchMessages(folder: String, query: String): Flow<PagingData<Message>> =
flowOf(PagingData.from(messages.filter { it.folder == folder && it.matchesSearch(query) }))
override fun pagedFolderSearchMessages(
accountId: String,
folder: String,
query: String,
): Flow<PagingData<Message>> = flowOf(
PagingData.from(
messages.filter { it.accountId == accountId && it.folder == folder && it.matchesSearch(query) },
),
)
override fun observeFolders(accountId: String): Flow<List<Folder>> = flowOf(
folders.filter {
it.accountId ==
@@ -198,3 +208,9 @@ class FakeMailSyncer : Syncer {
override suspend fun syncAccount(accountId: String): Result<Int> = Result.success(0)
override suspend fun syncFolder(accountId: String, folder: String): Result<Int> = Result.success(0)
}
/** Mirrors the paged-search DAO queries' columns so the fake's search pagers filter like production. */
private fun Message.matchesSearch(query: String): Boolean = sender.contains(query, ignoreCase = true) ||
senderEmail.contains(query, ignoreCase = true) ||
subject.contains(query, ignoreCase = true) ||
snippet.contains(query, ignoreCase = true)
@@ -26,44 +26,14 @@ interface MessageDao {
)
fun observeSummaries(): Flow<List<MessageSummary>>
/**
* Mailbox-list projection scoped in SQL to one account's [folder], newest-first. Unlike
* [observeSummaries] this pulls only that folder's rows and re-emits only when they actually
* change, so the mailbox list's cost scales with what's shown, not the whole cache (issue #86).
* `inInbox` is *not* filtered here so the one query serves both the normal list (caller keeps
* `inInbox = 1` rows) and search (caller keeps rows matching the query, including transient
* `inInbox = 0` server-search hits). Served by the existing `(accountId, folder, uid)` index (its
* `(accountId, folder)` prefix), so no new index — and no schema migration — is needed.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE accountId = :accountId AND folder = :folder ORDER BY timestampMillis DESC",
)
fun observeFolderSummaries(accountId: String, folder: String): Flow<List<MessageSummary>>
/**
* Unified-inbox projection: [folder] across every account, newest-first. Scoped in SQL like
* [observeFolderSummaries] but without an account predicate (issue #86). No existing index leads
* with `folder`, so this still scans in timestamp order — far cheaper than [observeSummaries] (it
* materializes only this folder's rows, not the whole cache) but not O(1); a large multi-account
* unified inbox is a candidate for a `(folder, inInbox, timestampMillis)` index + paging.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE folder = :folder ORDER BY timestampMillis DESC",
)
fun observeUnifiedFolderSummaries(folder: String): Flow<List<MessageSummary>>
/**
* Paged unified-inbox projection: folder-synced rows of [folder] across every account,
* newest-first, as a Paging 3 [PagingSource] (issue #124). Unlike [observeUnifiedFolderSummaries]
* — which materializes the *entire* unified inbox (~thousands of rows) on every emission — Room
* loads only the requested window (LIMIT/OFFSET), so the mailbox list's query, mapping, and
* recomposition cost scale with what's on screen, not the whole cache. Filters `inInbox = 1`
* because the paged browse list shows only synced rows; unified *search* (which must also surface
* transient `inInbox = 0` hits) stays on [observeUnifiedFolderSummaries]. Profiling (see
* newest-first, as a Paging 3 [PagingSource] (issue #124). Room loads only the requested window
* (LIMIT/OFFSET), so the mailbox list's query, mapping, and recomposition cost scale with what's on
* screen, not the whole cache — instead of materializing the *entire* unified inbox (~thousands of
* rows) on every emission. Filters `inInbox = 1` because the paged browse list shows only synced
* rows; unified *search* (which must also surface transient `inInbox = 0` hits) is paged separately
* by [pagingUnifiedFolderSearchSummaries] (issue #214). Profiling (see
* `docs/perf/issue-124-unified-inbox-paging.md`) showed the first page loads flat regardless of
* total cache size on the existing indices, so no `(folder, …)` index / schema migration is added.
*/
@@ -74,6 +44,60 @@ interface MessageDao {
)
fun pagingUnifiedFolderSummaries(folder: String): PagingSource<Int, MessageSummary>
/**
* Paged per-account folder projection: [accountId]'s folder-synced rows of [folder], newest-first,
* as a Paging 3 [PagingSource] (issue #214). The account-scoped counterpart of
* [pagingUnifiedFolderSummaries] — brings #124's window-at-a-time loading to the per-account browse
* list so opening a large account folder no longer materializes the whole folder into memory.
* Filters `inInbox = 1` (synced browse rows only; per-account *search* uses
* [pagingFolderSearchSummaries], which also surfaces transient `inInbox = 0` hits). Served by the
* `(accountId, folder, uid)` index's `(accountId, folder)` prefix, so no new index / migration.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 ORDER BY timestampMillis DESC",
)
fun pagingFolderSummaries(accountId: String, folder: String): PagingSource<Int, MessageSummary>
/**
* Paged unified search: rows of [folder] across every account whose sender, sender address,
* subject, or snippet match [pattern] — a pre-built SQL `LIKE` pattern (`%term%`, with the LIKE
* metacharacters `\ % _` escaped by `\`) — newest-first, as a Paging 3 [PagingSource] (issue #214).
* Scans the same columns the old in-memory search filter (`matchesSearch`) did, but in SQL so a
* search over a large folder loads only the visible window. Unlike the browse pagers this does
* *not* filter `inInbox`, so it surfaces both synced rows and the transient `inInbox = 0`
* server-search hits `MailRepository.searchServer` inserts — exactly what the old filter saw. LIKE
* is case-insensitive for ASCII (SQLite's default), matching `matchesSearch`'s common case.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE folder = :folder AND (sender LIKE :pattern ESCAPE '\\' OR " +
"senderEmail LIKE :pattern ESCAPE '\\' OR subject LIKE :pattern ESCAPE '\\' OR " +
"snippet LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
)
fun pagingUnifiedFolderSearchSummaries(folder: String, pattern: String): PagingSource<Int, MessageSummary>
/**
* Paged per-account search: [accountId]'s rows of [folder] matching [pattern] (see
* [pagingUnifiedFolderSearchSummaries] for the pattern/column contract), newest-first, as a
* Paging 3 [PagingSource] (issue #214). Like the unified variant it leaves `inInbox` unfiltered so
* per-account search still surfaces transient server-search hits.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE accountId = :accountId AND folder = :folder AND (sender LIKE :pattern ESCAPE '\\' OR " +
"senderEmail LIKE :pattern ESCAPE '\\' OR subject LIKE :pattern ESCAPE '\\' OR " +
"snippet LIKE :pattern ESCAPE '\\') ORDER BY timestampMillis DESC",
)
fun pagingFolderSearchSummaries(
accountId: String,
folder: String,
pattern: String,
): PagingSource<Int, MessageSummary>
/**
* Live per-(account, folder) unread counts for the drawer's folder badges and the bold styling of
* accounts with unread mail. Counts only folder-synced rows (`inInbox = 1`), so transient
@@ -6,6 +6,7 @@ import android.net.Uri
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingSource
import androidx.paging.map
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.mail.Flags
@@ -30,6 +31,7 @@ import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageRouting
import org.libremail.data.local.entity.MessageSummary
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
@@ -82,12 +84,6 @@ class MailRepositoryImpl @Inject constructor(
// Hilt's SingletonComponent), so the scope's lifetime is the process's, not any one caller's coroutine.
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
messageDao.observeFolderSummaries(accountId, folder).map { rows -> rows.map { it.toDomain() } }
override fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>> =
messageDao.observeUnifiedFolderSummaries(folder).map { rows -> rows.map { it.toDomain() } }
override fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>> = Pager(
config = PagingConfig(
// A page comfortably exceeds a screenful so scrolling rarely waits on a load; loading
@@ -106,6 +102,36 @@ class MailRepositoryImpl @Inject constructor(
pagingSourceFactory = { messageDao.pagingUnifiedFolderSummaries(folder) },
).flow.map { page -> page.map { it.toDomain() } }
override fun pagedFolderMessages(accountId: String, folder: String): Flow<PagingData<Message>> =
mailboxPager { messageDao.pagingFolderSummaries(accountId, folder) }
override fun pagedUnifiedSearchMessages(folder: String, query: String): Flow<PagingData<Message>> =
mailboxPager { messageDao.pagingUnifiedFolderSearchSummaries(folder, likePattern(query)) }
override fun pagedFolderSearchMessages(
accountId: String,
folder: String,
query: String,
): Flow<PagingData<Message>> =
mailboxPager { messageDao.pagingFolderSearchSummaries(accountId, folder, likePattern(query)) }
/**
* Shared [Pager] for the per-account and search mailbox lists (issue #214). Same window sizing as
* the unified browse pager, plus a bounded `maxSize` so scrolling a long list drops far-offscreen
* pages instead of retaining the whole scrolled-through range in memory. Placeholders stay off (the
* row height varies, and the list never sizes a scrollbar to the full uncounted result).
*/
private fun mailboxPager(pagingSourceFactory: () -> PagingSource<Int, MessageSummary>): Flow<PagingData<Message>> =
Pager(
config = PagingConfig(
pageSize = MAILBOX_PAGE_SIZE,
initialLoadSize = MAILBOX_PAGE_SIZE * 3,
enablePlaceholders = false,
maxSize = MAILBOX_PAGE_SIZE * 5,
),
pagingSourceFactory = pagingSourceFactory,
).flow.map { page -> page.map { it.toDomain() } }
override fun observeFolders(accountId: String): Flow<List<Folder>> =
folderDao.observeForAccount(accountId).map { rows ->
rows.map { it.toDomain() }
@@ -520,3 +546,13 @@ private const val SEEN_FLAG_RETRY_BACKOFF_MS = 2_000L
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
private fun uidOf(id: String): String = id.substringAfterLast(':')
/**
* Builds the SQL `LIKE` pattern the paged-search DAO queries take (issue #214), preserving the old
* `matchesSearch` literal-substring semantics: escape the LIKE metacharacters (`\ % _`) — the `\`
* first, so the escapes just added aren't themselves re-escaped — then wrap the term in wildcards.
*/
private fun likePattern(query: String): String {
val escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return "%$escaped%"
}
@@ -19,24 +19,34 @@ import java.io.File
* The UI always reads from here; the cache is the single source of truth.
*/
interface MailRepository {
/**
* The cached messages of one account's [folder], newest-first — the scoped source for the mailbox
* list (issue #86). Includes transient server-search rows so search can surface them; the caller
* filters `inInbox`/query. Re-emits only when that folder's rows change, not on every cache write.
*/
fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>>
/** Like [observeFolderMessages] but for [folder] across every account (the unified inbox). */
fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>>
/**
* The unified inbox as a [PagingData] stream so the list's query, mapping, and recomposition cost
* scale with the visible window rather than the whole cache (issue #124). Emits only folder-synced
* rows of [folder] across every account, newest-first — unified *search* still uses
* [observeUnifiedFolderMessages]. Callers must `cachedIn` a scope before collecting.
* rows of [folder] across every account, newest-first — unified *search* is paged separately by
* [pagedUnifiedSearchMessages] (issue #214). Callers must `cachedIn` a scope before collecting.
*/
fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>>
/**
* The per-account browse list as a [PagingData] stream — the account-scoped counterpart of
* [pagedUnifiedFolderMessages] (issue #214). Folder-synced rows of [accountId]'s [folder],
* newest-first, loaded a window at a time. Callers must `cachedIn` a scope before collecting.
*/
fun pagedFolderMessages(accountId: String, folder: String): Flow<PagingData<Message>>
/**
* Unified *search* as a [PagingData] stream (issue #214): rows of [folder] across every account
* matching [query], newest-first, including the transient server-search hits [searchServer] adds.
* Scans the same fields the previous in-memory search filter did. `cachedIn` before collecting.
*/
fun pagedUnifiedSearchMessages(folder: String, query: String): Flow<PagingData<Message>>
/**
* Per-account *search* counterpart of [pagedUnifiedSearchMessages] (issue #214): [accountId]'s
* [folder] rows matching [query], newest-first. Callers must `cachedIn` before collecting.
*/
fun pagedFolderSearchMessages(accountId: String, folder: String, query: String): Flow<PagingData<Message>>
/** The account's cached IMAP folders for the navigation drawer. */
fun observeFolders(accountId: String): Flow<List<Folder>>
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
@@ -104,8 +103,8 @@ fun MailboxScreen(
onSelectTab: (org.libremail.ui.TopDest) -> Unit,
viewModel: MailboxViewModel = hiltViewModel(),
) {
val messages by viewModel.messages.collectAsStateWithLifecycle()
// The unified "All inboxes" browse list is paged (issue #124); per-account/search render [messages].
// Every mailbox mode — unified/per-account browse and search — is a single paged list now
// (issues #124, #214); the ViewModel dispatches the right pager by (account, folder, query).
val pagedMessages = viewModel.pagedMessages.collectAsLazyPagingItems()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle()
@@ -129,10 +128,6 @@ fun MailboxScreen(
val moveTargetFolders by viewModel.moveTargetFolders.collectAsStateWithLifecycle()
val actionInProgress by viewModel.actionInProgress.collectAsStateWithLifecycle()
val selectionMode = selectedIds.isNotEmpty()
// The unified inbox (no account filter, not searching) renders the paged list; a concrete account
// or an active search renders the flat [messages] list. Hoisted here so the selection bar's
// "Select all" can read the right source (issue #124).
val showPaged = selectedAccountId == null && searchQuery.isBlank()
var showMovePicker by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -198,11 +193,9 @@ fun MailboxScreen(
onSpam = viewModel::requestSpam,
onMove = { showMovePicker = true },
onSelectAll = {
// "Select all" acts on what's shown: the loaded paged window for the unified
// inbox, or the whole flat list for a per-account/search view.
viewModel.selectAll(
if (showPaged) pagedMessages.itemSnapshotList.items else messages,
)
// "Select all" acts on what's shown: the currently loaded window of the
// paged list (issues #124, #214).
viewModel.selectAll(pagedMessages.itemSnapshotList.items)
},
onReply = { viewModel.reply(ReplyMode.REPLY) },
onReplyAll = viewModel::requestReplyAll,
@@ -294,59 +287,36 @@ fun MailboxScreen(
modifier = Modifier.fillMaxSize(),
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
if (showPaged) {
if (pagedMessages.itemCount == 0) {
item {
// Hold the empty state back until the first page settles so
// it doesn't flash before rows arrive (search never lands here).
if (pagedMessages.loadState.refresh !is LoadState.Loading) {
NoMessagesState(Modifier.fillParentMaxSize())
}
}
} else {
items(
count = pagedMessages.itemCount,
key = pagedMessages.itemKey { it.id },
) { index ->
val message = pagedMessages[index] ?: return@items
val accountLabel =
if (showAccount) accountsById[message.accountId]?.email else null
MessageRow(
message = message,
accountLabel = accountLabel,
selected = message.id in selectedIds,
onClick = {
if (selectionMode) {
viewModel.toggleSelection(message.id, message.accountId)
} else {
onOpenMessage(message.id)
}
},
onLongClick = {
viewModel.startSelection(message.id, message.accountId)
},
)
HorizontalDivider()
}
}
} else if (messages.isEmpty()) {
if (pagedMessages.itemCount == 0) {
item {
// Hold the empty state back while the folder's initial background
// sync is still in flight, so opening an uncached folder doesn't
// flash "No messages to display" before the fetch has a chance to
// populate anything (issue #149).
if (searchActive && searchQuery.isNotBlank()) {
NoResultsState(Modifier.fillParentMaxSize())
} else if (isSyncingFolder) {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
NoMessagesState(Modifier.fillParentMaxSize())
val loadState = pagedMessages.loadState
// "Settled" = the refresh finished and there is no further page,
// so an empty list is genuinely empty. Gating on it (not just
// "refresh not Loading") holds the empty state back on an
// empty→loaded transition — before the first page, or before a
// search's debounced server hits land (issues #124, #214).
val settled = loadState.refresh is LoadState.NotLoading &&
loadState.append.endOfPaginationReached
when {
searchActive && searchQuery.isNotBlank() ->
if (settled) NoResultsState(Modifier.fillParentMaxSize())
// An uncached per-account folder still doing its initial
// background sync: show the spinner rather than "No messages"
// until it settles or the fetch populates rows (issue #149).
isSyncingFolder ->
Box(
Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
settled -> NoMessagesState(Modifier.fillParentMaxSize())
}
}
} else {
items(messages, key = { it.id }) { message ->
items(
count = pagedMessages.itemCount,
key = pagedMessages.itemKey { it.id },
) { index ->
val message = pagedMessages[index] ?: return@items
val accountLabel =
if (showAccount) accountsById[message.accountId]?.email else null
MessageRow(
@@ -120,57 +120,32 @@ class MailboxViewModel @Inject constructor(
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
/**
* The list-rendered messages: a per-account folder (SQL-scoped and already flat, issue #86) or —
* for the unified inbox — *only* an active search's matches across accounts. The unified **browse**
* list is paged instead (see [pagedMessages], issue #124), so this flow stays empty while browsing
* the unified inbox and the whole cache is never pulled into memory on each write.
*/
val messages: StateFlow<List<Message>> =
combine(_selectedAccountId, _selectedFolder) { accountId, folder -> accountId to folder }
.distinctUntilChanged()
.flatMapLatest { (accountId, folder) ->
if (accountId != null) {
// Per-account+folder: the SQL-scoped, already-flat list. The one client-side pass
// distinguishes the normal list (synced rows) from an active search (any matching
// row, including transient server-search hits) over the small folder-scoped set.
combine(mailRepository.observeFolderMessages(accountId, folder), _searchQuery) { rows, query ->
val q = query.trim()
rows.filter { if (q.isEmpty()) it.inInbox else it.matchesSearch(q) }
}
} else {
// Unified inbox: browse is paged via [pagedMessages]; this backs only an active
// search over the folder's rows across accounts (bounded by the per-account search
// limit), staying empty while browsing so the whole inbox is never materialized.
_searchQuery.flatMapLatest { query ->
val q = query.trim()
if (q.isEmpty()) {
flowOf(emptyList())
} else {
mailRepository.observeUnifiedFolderMessages(folder)
.map { rows -> rows.filter { it.matchesSearch(q) } }
}
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/**
* The unified "All inboxes" browse list as a paged stream (issue #124): folder-synced rows across
* every account, newest-first, loaded a window at a time so query/mapping/recomposition cost scale
* with the screen, not the total cache. Emits empty paging data whenever a concrete account is
* selected or a search is active (those render from [messages]).
* The mailbox list as a single paged stream (issues #124, #214), loaded a window at a time so the
* query, mapping, and recomposition cost scale with the screen, not the whole cache. One flow backs
* every mode — unified browse, per-account browse, and search (unified or per-account) — dispatching
* to the matching repository pager by the current (account, folder, query). Browse shows only
* folder-synced rows; search re-pages on each keystroke and (via its DAO query) also surfaces the
* transient server-search hits. `cachedIn` so the loaded window survives configuration changes and
* the selection's "select all" can read it.
*/
val pagedMessages: Flow<PagingData<Message>> =
combine(
_selectedAccountId,
_selectedFolder,
_searchQuery.map { it.isBlank() }.distinctUntilChanged(),
) { accountId, folder, browsing -> Triple(accountId, folder, browsing) }
combine(_selectedAccountId, _selectedFolder, _searchQuery) { accountId, folder, query ->
Triple(accountId, folder, query.trim())
}
.distinctUntilChanged()
.flatMapLatest { (accountId, folder, browsing) ->
if (accountId == null && browsing) {
mailRepository.pagedUnifiedFolderMessages(folder)
.flatMapLatest { (accountId, folder, query) ->
if (query.isEmpty()) {
if (accountId == null) {
mailRepository.pagedUnifiedFolderMessages(folder)
} else {
mailRepository.pagedFolderMessages(accountId, folder)
}
} else {
flowOf(PagingData.empty())
if (accountId == null) {
mailRepository.pagedUnifiedSearchMessages(folder, query)
} else {
mailRepository.pagedFolderSearchMessages(accountId, folder, query)
}
}
}
.cachedIn(viewModelScope)
@@ -474,9 +449,3 @@ private fun withInbox(accountId: String, folders: List<Folder>): List<Folder> =
} else {
listOf(Folder(accountId, INBOX, INBOX, FolderRole.INBOX, selectable = true)) + folders
}
/** Local match over the always-populated header fields (and snippet, once a body is cached). */
private fun Message.matchesSearch(query: String): Boolean = sender.contains(query, ignoreCase = true) ||
senderEmail.contains(query, ignoreCase = true) ||
subject.contains(query, ignoreCase = true) ||
snippet.contains(query, ignoreCase = true)
@@ -83,38 +83,6 @@ class MailRepositoryImplTest {
attachmentUriGrants = mockk(relaxed = true),
)
@Test
fun `observeFolderMessages is empty when the folder has no cached rows`() = runTest {
every { messageDao.observeFolderSummaries("acct", "INBOX") } returns flowOf(emptyList())
repository.observeFolderMessages("acct", "INBOX").test {
assertTrue(awaitItem().isEmpty())
awaitComplete()
}
}
@Test
fun `observeFolderMessages maps the folder's cached entities`() = runTest {
every { messageDao.observeFolderSummaries("acct", "INBOX") } returns
flowOf(listOf(messageSummary("1", "INBOX")))
repository.observeFolderMessages("acct", "INBOX").test {
val items = awaitItem()
assertEquals(1, items.size)
assertEquals("Ada", items.first().sender)
assertEquals("INBOX", items.first().folder)
awaitComplete()
}
}
@Test
fun `observeUnifiedFolderMessages maps the folder's rows across accounts`() = runTest {
every { messageDao.observeUnifiedFolderSummaries("INBOX") } returns
flowOf(listOf(messageSummary("1", "INBOX")))
repository.observeUnifiedFolderMessages("INBOX").test {
assertEquals(1, awaitItem().size)
awaitComplete()
}
}
@Test
fun `pagedUnifiedFolderMessages maps the paged summaries to domain messages`() = runTest {
every { messageDao.pagingUnifiedFolderSummaries("INBOX") } returns FakeSummaryPagingSource(
@@ -129,6 +97,44 @@ class MailRepositoryImplTest {
assertEquals("", items.first().body)
}
@Test
fun `pagedFolderMessages maps the account folder's paged summaries to domain messages`() = runTest {
every { messageDao.pagingFolderSummaries("acct", "INBOX") } returns FakeSummaryPagingSource(
listOf(messageSummary("1", "INBOX"), messageSummary("2", "INBOX")),
)
val items = repository.pagedFolderMessages("acct", "INBOX").asSnapshot()
assertEquals(listOf("1", "2"), items.map { it.id })
// The list projection never carries a body — the reader loads it on demand (see MessageSummary).
assertEquals("", items.first().body)
}
@Test
fun `pagedUnifiedSearchMessages escapes LIKE metacharacters and maps the paged hits`() = runTest {
val pattern = slot<String>()
every { messageDao.pagingUnifiedFolderSearchSummaries("INBOX", capture(pattern)) } returns
FakeSummaryPagingSource(listOf(messageSummary("1", "INBOX")))
val items = repository.pagedUnifiedSearchMessages("INBOX", "50%_off").asSnapshot()
assertEquals(listOf("1"), items.map { it.id })
// The query's LIKE metacharacters are escaped (so they match literally) then wrapped in wildcards.
assertEquals("%50\\%\\_off%", pattern.captured)
}
@Test
fun `pagedFolderSearchMessages scopes the paged search to the account and folder`() = runTest {
val pattern = slot<String>()
every { messageDao.pagingFolderSearchSummaries("acct", "INBOX", capture(pattern)) } returns
FakeSummaryPagingSource(listOf(messageSummary("1", "INBOX")))
val items = repository.pagedFolderSearchMessages("acct", "INBOX", "hi").asSnapshot()
assertEquals(listOf("1"), items.map { it.id })
assertEquals("%hi%", pattern.captured)
}
@Test
fun `observeFolders maps cached folders with their roles and server special-use flag`() = runTest {
every { folderDao.observeForAccount("acct") } returns flowOf(
@@ -8,6 +8,7 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
@@ -54,43 +56,59 @@ class MailboxViewModelTest {
private val alice = account("imap:a", "alice@example.org")
private val bob = account("imap:b", "bob@example.org")
// Every mailbox mode is a single paged flow (issues #124, #214); the ViewModel's only job is to
// dispatch each (account, folder, query) to the matching repository pager. The paged rows/scoping
// themselves are covered by MailRepositoryImplTest and the MailboxScreen UI test.
@Test
fun `unified browse keeps the whole inbox out of the in-memory list flow`() = runTest(testDispatcher) {
val vm = createViewModel(
accounts = listOf(alice, bob),
messages = listOf(
msg("imap:a:INBOX:1", "imap:a", "INBOX"),
msg("imap:a:Archive:1", "imap:a", "Archive"),
msg("imap:b:INBOX:1", "imap:b", "INBOX"),
),
)
backgroundScope.launch { vm.messages.collect {} }
fun `pagedMessages dispatches to the matching repository pager for each mode`() = runTest(testDispatcher) {
val repo = mockk<MailRepository>(relaxed = true)
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), repo = repo)
backgroundScope.launch { vm.pagedMessages.collect {} }
runCurrent()
// Default: unified "All inboxes" browse.
assertEquals("INBOX", vm.selectedFolder.value)
assertNull(vm.selectedAccountId.value)
// Unified browse is paged (issue #124): the flat list flow stays empty so the whole unified
// inbox is never materialized on every write. The paged rows themselves are covered by
// MailRepositoryImplTest's pagedUnifiedFolderMessages test and the MailboxScreen UI test.
assertTrue(vm.messages.value.isEmpty())
verify { repo.pagedUnifiedFolderMessages("INBOX") }
// A query flips the unified view to the unified search pager.
vm.onSearchQuery("hello")
runCurrent()
verify { repo.pagedUnifiedSearchMessages("INBOX", "hello") }
// Clearing the query and picking an account browses that account's folder via the per-account pager.
vm.onSearchQuery("")
vm.selectAccount("imap:a")
runCurrent()
verify { repo.pagedFolderMessages("imap:a", "INBOX") }
// Searching within a concrete account uses the per-account search pager.
vm.onSearchQuery("world")
runCurrent()
verify { repo.pagedFolderSearchMessages("imap:a", "INBOX", "world") }
}
@Test
fun `selecting a concrete account renders the flat scoped list`() = runTest(testDispatcher) {
fun `selecting a concrete account pages that account's folder`() = runTest(testDispatcher) {
val repo = mockk<MailRepository>(relaxed = true)
val vm = createViewModel(
accounts = listOf(alice),
messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:a:Archive:1", "imap:a", "Archive")),
repo = repo,
)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
vm.selectAccount("imap:a")
runCurrent()
// A concrete account uses the SQL-scoped, already-flat list (issue #86), not the paged path.
assertEquals(listOf("imap:a:INBOX:1"), vm.messages.value.map { it.id })
// A concrete account uses the SQL-scoped per-account pager (issue #214), not the unified one.
verify { repo.pagedFolderMessages("imap:a", "INBOX") }
}
@Test
fun `selecting a folder scopes the view to that account and folder and syncs it`() = runTest(testDispatcher) {
val syncer = mockk<MailSyncer>(relaxed = true)
val repo = mockk<MailRepository>(relaxed = true)
val vm = createViewModel(
accounts = listOf(alice),
messages = listOf(
@@ -98,14 +116,16 @@ class MailboxViewModelTest {
msg("imap:a:Archive:1", "imap:a", "Archive"),
),
syncer = syncer,
repo = repo,
)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
vm.selectFolder("imap:a", "Archive")
runCurrent()
assertEquals("Archive", vm.selectedFolder.value)
assertEquals("imap:a", vm.selectedAccountId.value)
assertEquals(listOf("imap:a:Archive:1"), vm.messages.value.map { it.id })
verify { repo.pagedFolderMessages("imap:a", "Archive") }
coVerify { syncer.syncFolder("imap:a", "Archive") }
}
@@ -120,7 +140,7 @@ class MailboxViewModelTest {
Result.success(0)
}
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), syncer = syncer)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
vm.isSyncingFolder.test {
assertEquals(false, awaitItem())
@@ -145,7 +165,7 @@ class MailboxViewModelTest {
Result.failure(IllegalStateException("boom"))
}
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), syncer = syncer)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
vm.isSyncingFolder.test {
assertEquals(false, awaitItem())
@@ -174,7 +194,7 @@ class MailboxViewModelTest {
Result.success(0)
}
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), syncer = syncer)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
vm.isSyncingFolder.test {
assertEquals(false, awaitItem())
@@ -233,7 +253,7 @@ class MailboxViewModelTest {
accounts = listOf(alice, bob),
messages = listOf(msg("imap:a:Archive:1", "imap:a", "Archive")),
)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
vm.selectFolder("imap:a", "Archive")
assertEquals("Archive", vm.selectedFolder.value)
@@ -338,7 +358,7 @@ class MailboxViewModelTest {
messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
repo = repo,
)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
vm.startSelection("imap:a:INBOX:1", "imap:a")
vm.requestDelete()
@@ -367,7 +387,7 @@ class MailboxViewModelTest {
),
repo = repo,
)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
backgroundScope.launch { vm.currentFolderRole.collect {} }
vm.selectFolder("imap:a", "Spam")
@@ -474,7 +494,7 @@ class MailboxViewModelTest {
accounts = listOf(alice, bob),
messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:b:INBOX:1", "imap:b", "INBOX")),
)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
backgroundScope.launch { vm.canMove.collect {} }
vm.startSelection("imap:a:INBOX:1", "imap:a")
@@ -523,16 +543,20 @@ class MailboxViewModelTest {
@Test
fun `opens filtered to the account passed as a nav argument`() = runTest(testDispatcher) {
val repo = mockk<MailRepository>(relaxed = true)
val vm = createViewModel(
accounts = listOf(alice, bob),
messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:b:INBOX:1", "imap:b", "INBOX")),
initialAccountId = "imap:a",
repo = repo,
)
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.pagedMessages.collect {} }
runCurrent()
assertEquals("imap:a", vm.selectedAccountId.value)
assertEquals("INBOX", vm.selectedFolder.value)
assertEquals(listOf("imap:a:INBOX:1"), vm.messages.value.map { it.id })
// Seeded to a concrete account, so it pages that account's inbox (not the unified pager).
verify { repo.pagedFolderMessages("imap:a", "INBOX") }
}
@Test
@@ -588,23 +612,33 @@ class MailboxViewModelTest {
repo: MailRepository = mockk(relaxed = true),
initialAccountId: String? = null,
): MailboxViewModel {
// Mimic the SQL scoping: return the seed list narrowed to the queried account+folder / folder,
// exactly as MessageDao.observeFolderSummaries / observeUnifiedFolderSummaries do (issue #86).
every { repo.observeFolderMessages(any(), any()) } answers {
val accountId = firstArg<String>()
val folder = secondArg<String>()
MutableStateFlow(messages.filter { it.accountId == accountId && it.folder == folder })
}
every { repo.observeUnifiedFolderMessages(any()) } answers {
val folder = firstArg<String>()
MutableStateFlow(messages.filter { it.folder == folder })
}
// The unified browse list is paged (issue #124); mirror the DAO's inInbox-scoped, folder-scoped
// projection as a single static page.
// Every mailbox mode is paged (issues #124, #214). Mirror each DAO projection as a single static
// page over the seed list: browse pagers keep folder-synced (inInbox) rows; search pagers match
// the same fields the DAO's LIKE query does, leaving inInbox unfiltered.
every { repo.pagedUnifiedFolderMessages(any()) } answers {
val folder = firstArg<String>()
flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
}
every { repo.pagedFolderMessages(any(), any()) } answers {
val accountId = firstArg<String>()
val folder = secondArg<String>()
flowOf(PagingData.from(messages.filter { it.accountId == accountId && it.folder == folder && it.inInbox }))
}
every { repo.pagedUnifiedSearchMessages(any(), any()) } answers {
val folder = firstArg<String>()
val query = secondArg<String>()
flowOf(PagingData.from(messages.filter { it.folder == folder && it.matchesSearch(query) }))
}
every { repo.pagedFolderSearchMessages(any(), any(), any()) } answers {
val accountId = firstArg<String>()
val folder = secondArg<String>()
val query = thirdArg<String>()
flowOf(
PagingData.from(
messages.filter { it.accountId == accountId && it.folder == folder && it.matchesSearch(query) },
),
)
}
every { repo.observeDrafts() } returns flowOf(emptyList())
every { repo.observeOutbox() } returns flowOf(emptyList())
every { repo.observeUnreadCounts() } returns MutableStateFlow(unreadCounts)
@@ -647,3 +681,9 @@ class MailboxViewModelTest {
inInbox = true,
)
}
/** Mirrors the paged-search DAO queries' columns so the stubbed search pagers filter like production. */
private fun Message.matchesSearch(query: String): Boolean = sender.contains(query, ignoreCase = true) ||
senderEmail.contains(query, ignoreCase = true) ||
subject.contains(query, ignoreCase = true) ||
snippet.contains(query, ignoreCase = true)