Merge branch 'main' into feat-contacts-permission-onboarding

This commit is contained in:
Jason Ross
2026-07-02 09:47:21 -05:00
committed by GitHub
12 changed files with 376 additions and 50 deletions
+7
View File
@@ -200,9 +200,14 @@ dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.paging)
ksp(libs.androidx.room.compiler)
implementation(libs.sqlcipher.android)
// Paging 3 — the unified inbox list is paged so its cost scales with the screen (issue #124).
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
// Raise kotlinx-serialization to the version Room's schema-bundle serializers were compiled
// against (see libs.versions.toml). AGP 9 consistent resolution shares it with the androidTest
// classpath so MigrationTestHelper can parse the exported schema JSON.
@@ -214,6 +219,8 @@ dependencies {
testImplementation(libs.turbine)
testImplementation(libs.mockk)
testImplementation(libs.greenmail)
// asSnapshot() drives a PagingData flow to a concrete list in JVM unit tests (issue #124).
testImplementation(libs.androidx.paging.testing)
// The real org.json for unit tests (android.jar ships a stubbed, no-op version).
testImplementation("org.json:json:20231013")
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
@@ -92,6 +93,9 @@ class FakeMailRepository(
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 observeFolders(accountId: String): Flow<List<Folder>> = flowOf(
folders.filter {
it.accountId ==
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
@@ -54,6 +55,24 @@ interface MessageDao {
)
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
* `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.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE folder = :folder AND inInbox = 1 ORDER BY timestampMillis DESC",
)
fun pagingUnifiedFolderSummaries(folder: 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
@@ -3,6 +3,10 @@ package org.libremail.data.repository
import android.content.Context
import android.net.Uri
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.mail.Flags
import kotlinx.coroutines.flow.Flow
@@ -66,6 +70,19 @@ class MailRepositoryImpl @Inject constructor(
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
// three pages up front fills the first viewport without a visible gap. Placeholders
// are off: the row height varies (snippet/account label), so a fixed-height placeholder
// would jump, and the list never needs a scrollbar sized to the full (uncounted) inbox.
pageSize = MAILBOX_PAGE_SIZE,
initialLoadSize = MAILBOX_PAGE_SIZE * 3,
enablePlaceholders = false,
),
pagingSourceFactory = { messageDao.pagingUnifiedFolderSummaries(folder) },
).flow.map { page -> page.map { it.toDomain() } }
override fun observeFolders(accountId: String): Flow<List<Folder>> =
folderDao.observeForAccount(accountId).map { rows ->
rows.map { it.toDomain() }
@@ -380,5 +397,8 @@ class MailRepositoryImpl @Inject constructor(
private const val SEARCH_LIMIT = 50
/** Rows per page for the unified inbox (issue #124) — a page is a few screenfuls of message rows. */
private const val MAILBOX_PAGE_SIZE = 40
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
private fun uidOf(id: String): String = id.substringAfterLast(':')
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.repository
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
@@ -27,6 +28,14 @@ interface MailRepository {
/** 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.
*/
fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>>
/** The account's cached IMAP folders for the navigation drawer. */
fun observeFolders(accountId: String): Flow<List<Folder>>
@@ -80,6 +80,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import kotlinx.coroutines.launch
import org.libremail.R
import org.libremail.domain.model.Account
@@ -102,6 +105,8 @@ fun MailboxScreen(
viewModel: MailboxViewModel = hiltViewModel(),
) {
val messages by viewModel.messages.collectAsStateWithLifecycle()
// The unified "All inboxes" browse list is paged (issue #124); per-account/search render [messages].
val pagedMessages = viewModel.pagedMessages.collectAsLazyPagingItems()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle()
val selectedFolder by viewModel.selectedFolder.collectAsStateWithLifecycle()
@@ -123,6 +128,10 @@ 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)
@@ -187,7 +196,13 @@ fun MailboxScreen(
onDelete = viewModel::requestDelete,
onSpam = viewModel::requestSpam,
onMove = { showMovePicker = true },
onSelectAll = viewModel::selectAll,
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,
)
},
onReply = { viewModel.reply(ReplyMode.REPLY) },
onReplyAll = viewModel::requestReplyAll,
onForward = { viewModel.reply(ReplyMode.FORWARD) },
@@ -278,7 +293,42 @@ fun MailboxScreen(
modifier = Modifier.fillMaxSize(),
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
if (messages.isEmpty()) {
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()) {
item {
if (searchActive && searchQuery.isNotBlank()) {
NoResultsState(Modifier.fillParentMaxSize())
@@ -296,12 +346,12 @@ fun MailboxScreen(
selected = message.id in selectedIds,
onClick = {
if (selectionMode) {
viewModel.toggleSelection(message.id)
viewModel.toggleSelection(message.id, message.accountId)
} else {
onOpenMessage(message.id)
}
},
onLongClick = { viewModel.startSelection(message.id) },
onLongClick = { viewModel.startSelection(message.id, message.accountId) },
)
HorizontalDivider()
}
@@ -4,10 +4,13 @@ package org.libremail.ui.mailbox
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -116,27 +119,62 @@ class MailboxViewModel @Inject constructor(
private val _searchQuery = MutableStateFlow("")
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) ->
// 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)
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 {
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) }
// 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]).
*/
val pagedMessages: Flow<PagingData<Message>> =
combine(
_selectedAccountId,
_selectedFolder,
_searchQuery.map { it.isBlank() }.distinctUntilChanged(),
) { accountId, folder, browsing -> Triple(accountId, folder, browsing) }
.distinctUntilChanged()
.flatMapLatest { (accountId, folder, browsing) ->
if (accountId == null && browsing) {
mailRepository.pagedUnifiedFolderMessages(folder)
} else {
flowOf(PagingData.empty())
}
}
.cachedIn(viewModelScope)
val draftCount: StateFlow<Int> = mailRepository.observeDrafts()
.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
@@ -156,6 +194,11 @@ class MailboxViewModel @Inject constructor(
private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
val selectedIds: StateFlow<Set<String>> = _selectedIds.asStateFlow()
// Account id per selected message id, captured at selection time. The unified inbox is paged
// (issue #124), so the full list isn't held in memory; this lets [selectionAccountId] decide
// whether a selection sits within a single account (→ Move offered) without materializing it.
private val selectionAccounts = MutableStateFlow<Map<String, String>>(emptyMap())
private val _pendingConfirm = MutableStateFlow<PendingAction?>(null)
val pendingConfirm: StateFlow<PendingAction?> = _pendingConfirm.asStateFlow()
@@ -173,9 +216,8 @@ class MailboxViewModel @Inject constructor(
/** The single account every selected message belongs to, or null if the selection spans accounts. */
private val selectionAccountId: StateFlow<String?> =
combine(_selectedIds, messages) { ids, msgs ->
msgs.filter { it.id in ids }.map { it.accountId }.distinct().singleOrNull()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
selectionAccounts.map { it.values.distinct().singleOrNull() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** Whether "Move" is offered: only when the whole selection sits in a single account's folder tree. */
val canMove: StateFlow<Boolean> = selectionAccountId
@@ -187,20 +229,30 @@ class MailboxViewModel @Inject constructor(
.flatMapLatest { acct -> if (acct == null) flowOf(emptyList()) else mailRepository.observeFolders(acct) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun startSelection(id: String) {
fun startSelection(id: String, accountId: String) {
_selectedIds.value = setOf(id)
selectionAccounts.value = mapOf(id to accountId)
}
fun toggleSelection(id: String) {
_selectedIds.value = _selectedIds.value.let { if (id in it) it - id else it + id }
fun toggleSelection(id: String, accountId: String) {
if (id in _selectedIds.value) {
_selectedIds.value = _selectedIds.value - id
selectionAccounts.value = selectionAccounts.value - id
} else {
_selectedIds.value = _selectedIds.value + id
selectionAccounts.value = selectionAccounts.value + (id to accountId)
}
}
fun clearSelection() {
_selectedIds.value = emptySet()
selectionAccounts.value = emptyMap()
}
fun selectAll() {
_selectedIds.value = messages.value.map { it.id }.toSet()
/** Selects everything currently shown; [items] is the visible list (paged snapshot or list). */
fun selectAll(items: List<Message>) {
_selectedIds.value = items.map { it.id }.toSet()
selectionAccounts.value = items.associate { it.id to it.accountId }
}
fun archiveSelected() = runOnSelection { mailRepository.archive(it) }
@@ -2,6 +2,9 @@
package org.libremail.data.repository
import android.content.Context
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.testing.asSnapshot
import app.cash.turbine.test
import io.mockk.Runs
import io.mockk.coEvery
@@ -105,6 +108,20 @@ class MailRepositoryImplTest {
}
}
@Test
fun `pagedUnifiedFolderMessages maps the paged summaries to domain messages`() = runTest {
every { messageDao.pagingUnifiedFolderSummaries("INBOX") } returns FakeSummaryPagingSource(
listOf(messageSummary("1", "INBOX"), messageSummary("2", "INBOX", accountId = "acct2")),
)
val items = repository.pagedUnifiedFolderMessages("INBOX").asSnapshot()
assertEquals(listOf("1", "2"), items.map { it.id })
assertEquals("Ada", items.first().sender)
// The list projection never carries a body — the reader loads it on demand (see MessageSummary).
assertEquals("", items.first().body)
}
@Test
fun `observeFolders maps cached folders with their roles and server special-use flag`() = runTest {
every { folderDao.observeForAccount("acct") } returns flowOf(
@@ -604,4 +621,12 @@ class MailRepositoryImplTest {
secret = "secret",
useXoauth2 = false,
)
/** Serves a fixed set of summaries as a single page, standing in for Room's generated source. */
private class FakeSummaryPagingSource(private val rows: List<MessageSummary>) :
PagingSource<Int, MessageSummary>() {
override fun getRefreshKey(state: PagingState<Int, MessageSummary>): Int? = null
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageSummary> =
LoadResult.Page(data = rows, prevKey = null, nextKey = null)
}
}
@@ -2,6 +2,7 @@
package org.libremail.ui.mailbox
import androidx.lifecycle.SavedStateHandle
import androidx.paging.PagingData
import app.cash.turbine.test
import io.mockk.coEvery
import io.mockk.coVerify
@@ -53,7 +54,7 @@ class MailboxViewModelTest {
private val bob = account("imap:b", "bob@example.org")
@Test
fun `default view shows only inbox messages across all accounts`() = runTest(testDispatcher) {
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(
@@ -66,7 +67,24 @@ class MailboxViewModelTest {
assertEquals("INBOX", vm.selectedFolder.value)
assertNull(vm.selectedAccountId.value)
assertEquals(setOf("imap:a:INBOX:1", "imap:b:INBOX:1"), vm.messages.value.map { it.id }.toSet())
// 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())
}
@Test
fun `selecting a concrete account renders the flat scoped list`() = runTest(testDispatcher) {
val vm = createViewModel(
accounts = listOf(alice),
messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:a:Archive:1", "imap:a", "Archive")),
)
backgroundScope.launch { vm.messages.collect {} }
vm.selectAccount("imap:a")
// 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 })
}
@Test
@@ -200,11 +218,11 @@ class MailboxViewModelTest {
fun `toggle adds then removes a message from the selection`() = runTest(testDispatcher) {
val vm = createViewModel(accounts = listOf(alice), messages = emptyList())
vm.startSelection("a")
vm.startSelection("a", "acct")
assertEquals(setOf("a"), vm.selectedIds.value)
vm.toggleSelection("b")
vm.toggleSelection("b", "acct")
assertEquals(setOf("a", "b"), vm.selectedIds.value)
vm.toggleSelection("a")
vm.toggleSelection("a", "acct")
assertEquals(setOf("b"), vm.selectedIds.value)
vm.clearSelection()
assertTrue(vm.selectedIds.value.isEmpty())
@@ -212,13 +230,11 @@ class MailboxViewModelTest {
@Test
fun `selectAll selects every visible message`() = runTest(testDispatcher) {
val vm = createViewModel(
accounts = listOf(alice),
messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:a:INBOX:2", "imap:a", "INBOX")),
)
backgroundScope.launch { vm.messages.collect {} }
val shown = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:a:INBOX:2", "imap:a", "INBOX"))
val vm = createViewModel(accounts = listOf(alice), messages = shown)
vm.selectAll()
// The screen passes what's shown (paged snapshot or flat list); the VM records their ids.
vm.selectAll(shown)
assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), vm.selectedIds.value)
}
@@ -234,7 +250,7 @@ class MailboxViewModelTest {
)
backgroundScope.launch { vm.messages.collect {} }
vm.startSelection("imap:a:INBOX:1")
vm.startSelection("imap:a:INBOX:1", "imap:a")
vm.requestDelete()
val pending = vm.pendingConfirm.value
assertTrue(pending is PendingAction.Delete && !pending.permanent)
@@ -265,7 +281,7 @@ class MailboxViewModelTest {
backgroundScope.launch { vm.currentFolderRole.collect {} }
vm.selectFolder("imap:a", "Spam")
vm.startSelection("imap:a:Spam:1")
vm.startSelection("imap:a:Spam:1", "imap:a")
vm.requestDelete()
val pending = vm.pendingConfirm.value
assertTrue(pending is PendingAction.Delete && pending.permanent)
@@ -285,7 +301,7 @@ class MailboxViewModelTest {
repo = repo,
)
vm.startSelection("imap:a:INBOX:1")
vm.startSelection("imap:a:INBOX:1", "imap:a")
vm.requestSpam()
assertTrue(vm.pendingConfirm.value is PendingAction.Spam)
coVerify(exactly = 0) { repo.reportSpam(any()) }
@@ -304,7 +320,7 @@ class MailboxViewModelTest {
repo = repo,
)
vm.startSelection("imap:a:INBOX:1")
vm.startSelection("imap:a:INBOX:1", "imap:a")
vm.reply(ReplyMode.FORWARD)
assertEquals(MailboxEvent.OpenCompose("draft1"), vm.events.first())
@@ -321,7 +337,7 @@ class MailboxViewModelTest {
repo = repo,
)
vm.startSelection("imap:a:INBOX:1")
vm.startSelection("imap:a:INBOX:1", "imap:a")
vm.archiveSelected()
coVerify { repo.archive(listOf("imap:a:INBOX:1")) }
@@ -338,7 +354,7 @@ class MailboxViewModelTest {
repo = repo,
)
vm.startSelection("imap:a:INBOX:1")
vm.startSelection("imap:a:INBOX:1", "imap:a")
vm.reply(ReplyMode.REPLY)
assertEquals(MailboxEvent.OpenCompose("d2"), vm.events.first())
@@ -352,7 +368,7 @@ class MailboxViewModelTest {
messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
repo = repo,
)
vm.startSelection("imap:a:INBOX:1")
vm.startSelection("imap:a:INBOX:1", "imap:a")
vm.requestSpam()
vm.dismissConfirm()
@@ -371,10 +387,10 @@ class MailboxViewModelTest {
backgroundScope.launch { vm.messages.collect {} }
backgroundScope.launch { vm.canMove.collect {} }
vm.startSelection("imap:a:INBOX:1")
vm.startSelection("imap:a:INBOX:1", "imap:a")
assertEquals(true, vm.canMove.value)
vm.toggleSelection("imap:b:INBOX:1")
vm.toggleSelection("imap:b:INBOX:1", "imap:b")
assertEquals(false, vm.canMove.value)
}
@@ -388,7 +404,7 @@ class MailboxViewModelTest {
repo = repo,
)
vm.startSelection("imap:a:INBOX:1")
vm.startSelection("imap:a:INBOX:1", "imap:a")
vm.moveSelected("Receipts")
coVerify { repo.moveToFolder(listOf("imap:a:INBOX:1"), "Receipts") }
@@ -404,7 +420,7 @@ class MailboxViewModelTest {
messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
repo = repo,
)
vm.startSelection("imap:a:INBOX:1")
vm.startSelection("imap:a:INBOX:1", "imap:a")
vm.requestReplyAll()
assertTrue(vm.pendingConfirm.value is PendingAction.ReplyAll)
@@ -493,6 +509,12 @@ class MailboxViewModelTest {
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 { repo.pagedUnifiedFolderMessages(any()) } answers {
val folder = firstArg<String>()
flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
}
every { repo.observeDrafts() } returns flowOf(emptyList())
every { repo.observeOutbox() } returns flowOf(emptyList())
every { repo.observeUnreadCounts() } returns MutableStateFlow(unreadCounts)