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