Merge branch 'main' into feat-persist-imap-delimiter

This commit is contained in:
Jason Ross
2026-07-02 07:51:05 -05:00
committed by GitHub
8 changed files with 223 additions and 28 deletions
@@ -86,7 +86,11 @@ class FakeMailRepository(
val movedToFolder = mutableListOf<Pair<List<String>, String>>()
val replyDrafts = mutableListOf<Pair<String, ReplyMode>>()
override fun observeMessages(): Flow<List<Message>> = flowOf(messages)
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 observeFolders(accountId: String): Flow<List<Folder>> = flowOf(
folders.filter {
@@ -24,6 +24,36 @@ 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>>
/**
* 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
@@ -60,9 +60,11 @@ class MailRepositoryImpl @Inject constructor(
private val signatureRepository: SignatureRepository,
) : MailRepository {
override fun observeMessages(): Flow<List<Message>> = messageDao.observeSummaries().map { rows ->
rows.map { it.toDomain() }
}
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 observeFolders(accountId: String): Flow<List<Folder>> =
folderDao.observeForAccount(accountId).map { rows ->
@@ -17,7 +17,15 @@ import java.io.File
* The UI always reads from here; the cache is the single source of truth.
*/
interface MailRepository {
fun observeMessages(): Flow<List<Message>>
/**
* 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 account's cached IMAP folders for the navigation drawer. */
fun observeFolders(accountId: String): Flow<List<Folder>>
@@ -117,21 +117,25 @@ class MailboxViewModel @Inject constructor(
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val messages: StateFlow<List<Message>> =
combine(
mailRepository.observeMessages(),
_selectedAccountId,
_selectedFolder,
_searchQuery,
) { all, accountId, folder, query ->
val q = query.trim()
all.filter { message ->
(accountId == null || message.accountId == accountId) &&
message.folder == folder &&
// Outside of search show only synced rows; while searching show every match in this
// folder, including transient server-search hits that aren't synced.
(if (q.isEmpty()) message.inInbox else message.matchesSearch(q))
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
combine(_selectedAccountId, _selectedFolder) { accountId, folder -> accountId to folder }
.distinctUntilChanged()
.flatMapLatest { (accountId, folder) ->
// Scope the query in SQL to the viewed account+folder (or [folder] across accounts for
// the unified inbox) so Room only re-queries/re-emits when those rows change, and the
// cost scales with the folder, not the whole cache (issue #86).
val scoped = if (accountId == null) {
mailRepository.observeUnifiedFolderMessages(folder)
} else {
mailRepository.observeFolderMessages(accountId, folder)
}
// The only remaining client-side pass distinguishes the normal list (synced rows) from
// an active search (any matching row, including transient server-search hits) — a match
// over the small folder-scoped set, never the whole cache.
combine(scoped, _searchQuery) { rows, query ->
val q = query.trim()
rows.filter { if (q.isEmpty()) it.inInbox else it.matchesSearch(q) }
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val draftCount: StateFlow<Int> = mailRepository.observeDrafts()
.map { it.size }
@@ -74,18 +74,19 @@ class MailRepositoryImplTest {
)
@Test
fun `observeMessages is empty when the cache is empty`() = runTest {
every { messageDao.observeSummaries() } returns flowOf(emptyList())
repository.observeMessages().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 `observeMessages maps cached entities`() = runTest {
every { messageDao.observeSummaries() } returns flowOf(listOf(messageSummary("1", "INBOX")))
repository.observeMessages().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)
@@ -94,6 +95,16 @@ class MailRepositoryImplTest {
}
}
@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 `observeFolders maps cached folders with their roles and server special-use flag`() = runTest {
every { folderDao.observeForAccount("acct") } returns flowOf(
@@ -167,7 +167,6 @@ class MailboxViewModelTest {
@Test
fun `folder list lags a drawer-account switch until the new folders arrive`() = runTest(testDispatcher) {
val repo = mockk<MailRepository>(relaxed = true)
every { repo.observeMessages() } returns MutableStateFlow(emptyList<Message>())
every { repo.observeDrafts() } returns flowOf(emptyList())
every { repo.observeOutbox() } returns flowOf(emptyList())
every { repo.observeFolders("imap:a") } returns
@@ -483,7 +482,17 @@ class MailboxViewModelTest {
repo: MailRepository = mockk(relaxed = true),
initialAccountId: String? = null,
): MailboxViewModel {
every { repo.observeMessages() } returns MutableStateFlow(messages)
// 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 })
}
every { repo.observeDrafts() } returns flowOf(emptyList())
every { repo.observeOutbox() } returns flowOf(emptyList())
every { repo.observeUnreadCounts() } returns MutableStateFlow(unreadCounts)