Merge branch 'main' into feat-persist-imap-delimiter
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user