Merge branch 'main' into refactor-db-file-backup-sot
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 ==
|
||||
|
||||
@@ -6,6 +6,8 @@ import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
@@ -20,7 +22,7 @@ import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/** End-to-end test that the reader marks an already-cached attachment as downloaded. */
|
||||
/** Compose UI tests for the reader's attachment list: the downloaded indicator and the accordion. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReaderScreenTest {
|
||||
|
||||
@@ -29,6 +31,9 @@ class ReaderScreenTest {
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun seeMore(extraCount: Int) =
|
||||
composeTestRule.activity.resources.getQuantityString(R.plurals.attachments_see_more, extraCount, extraCount)
|
||||
|
||||
private val messageId = "imap:a:INBOX:1"
|
||||
private val message = Message(
|
||||
id = messageId, accountId = "imap:a", sender = "Sender", senderEmail = "s@example.org",
|
||||
@@ -36,29 +41,74 @@ class ReaderScreenTest {
|
||||
isRead = true, isStarred = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun reader_showsDownloadedIndicator_forCachedAttachment() {
|
||||
private fun attachment(partIndex: Int, filename: String) =
|
||||
Attachment(messageId, partIndex, filename, "application/pdf", 1_000L)
|
||||
|
||||
/** Renders [ReaderScreen] for the fixed [message] with the given [attachments] and awaits load. */
|
||||
private fun renderReader(attachments: List<Attachment>, downloadedParts: Set<Int> = emptySet()) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message),
|
||||
attachments = listOf(Attachment(messageId, 0, "report.pdf", "application/pdf", 1234L)),
|
||||
downloadedParts = setOf(0),
|
||||
attachments = attachments,
|
||||
downloadedParts = downloadedParts,
|
||||
)
|
||||
val viewModel = ReaderViewModel(
|
||||
SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)),
|
||||
repo,
|
||||
SettingsRepository(context),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText("report.pdf").fetchSemanticsNodes().isNotEmpty()
|
||||
composeTestRule.onAllNodesWithText(attachments.first().filename).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_showsDownloadedIndicator_forCachedAttachment() {
|
||||
renderReader(listOf(attachment(0, "report.pdf")), downloadedParts = setOf(0))
|
||||
|
||||
composeTestRule.onNodeWithText("report.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.attachment_downloaded)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_singleAttachment_showsNoAccordion() {
|
||||
renderReader(listOf(attachment(0, "solo.pdf")))
|
||||
|
||||
composeTestRule.onNodeWithText("solo.pdf").assertIsDisplayed()
|
||||
// A lone attachment has no "See more" control.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.attachments_expand)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_multipleAttachments_collapseExtrasUntilExpanded() {
|
||||
renderReader(listOf(attachment(0, "one.pdf"), attachment(1, "two.pdf"), attachment(2, "three.pdf")))
|
||||
|
||||
// First row shown; the two extras are hidden behind the collapsed accordion.
|
||||
composeTestRule.onNodeWithText("one.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(seeMore(2)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("two.pdf").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("three.pdf").assertDoesNotExist()
|
||||
|
||||
// Tapping the control reveals the remaining rows.
|
||||
composeTestRule.onNodeWithText(seeMore(2)).performClick()
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText("two.pdf").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeTestRule.onNodeWithText("two.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("three.pdf").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_twoAttachments_useSingularPlural() {
|
||||
renderReader(listOf(attachment(0, "a.pdf"), attachment(1, "b.pdf")))
|
||||
|
||||
// Exactly one extra: the singular plural form, e.g. "See 1 more attachment".
|
||||
composeTestRule.onNodeWithText(seeMore(1)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("b.pdf").assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -4,6 +4,8 @@ package org.libremail.ui.reader
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -42,12 +45,18 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
@@ -213,18 +222,83 @@ private fun Attachments(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
attachments.forEach { attachment ->
|
||||
AttachmentRow(
|
||||
attachment = attachment,
|
||||
downloading = attachment.partIndex in downloading,
|
||||
downloaded = attachment.partIndex in downloaded,
|
||||
onClick = { onDownload(attachment) },
|
||||
// The first attachment always shows. Any extras collapse behind an accordion so a message
|
||||
// with many attachments can't push its body off-screen (#134).
|
||||
val first = attachments.first()
|
||||
AttachmentRow(
|
||||
attachment = first,
|
||||
downloading = first.partIndex in downloading,
|
||||
downloaded = first.partIndex in downloaded,
|
||||
onClick = { onDownload(first) },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val extras = attachments.drop(1)
|
||||
if (extras.isNotEmpty()) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
AttachmentsToggle(
|
||||
extraCount = extras.size,
|
||||
expanded = expanded,
|
||||
onToggle = { expanded = !expanded },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column {
|
||||
extras.forEach { attachment ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AttachmentRow(
|
||||
attachment = attachment,
|
||||
downloading = attachment.partIndex in downloading,
|
||||
downloaded = attachment.partIndex in downloaded,
|
||||
onClick = { onDownload(attachment) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-by-default control that reveals the 2nd..Nth attachments. It is a single clickable
|
||||
* [Role.Button] whose label ("See x more attachments" / "See fewer attachments") and rotating
|
||||
* chevron expose the expanded state to screen readers.
|
||||
*/
|
||||
@Composable
|
||||
private fun AttachmentsToggle(extraCount: Int, expanded: Boolean, onToggle: () -> Unit) {
|
||||
val label = if (expanded) {
|
||||
stringResource(R.string.attachments_see_fewer)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.attachments_see_more, extraCount, extraCount)
|
||||
}
|
||||
val chevronDescription = stringResource(
|
||||
if (expanded) R.string.attachments_collapse else R.string.attachments_expand,
|
||||
)
|
||||
val rotation by animateFloatAsState(
|
||||
targetValue = if (expanded) 180f else 0f,
|
||||
label = "attachmentsChevronRotation",
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable(role = Role.Button, onClick = onToggle)
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = chevronDescription,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.rotate(rotation),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentRow(attachment: Attachment, downloading: Boolean, downloaded: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
|
||||
@@ -131,6 +131,14 @@
|
||||
<string name="message_available_offline">Available offline</string>
|
||||
<string name="attachment_download_failed">Couldn\'t download %1$s</string>
|
||||
<string name="attachment_no_app">No app can open this file</string>
|
||||
<!-- Reader attachment accordion (#134). %1$d = attachments beyond the first, always one or more. -->
|
||||
<plurals name="attachments_see_more">
|
||||
<item quantity="one">See %1$d more attachment</item>
|
||||
<item quantity="other">See %1$d more attachments</item>
|
||||
</plurals>
|
||||
<string name="attachments_see_fewer">See fewer attachments</string>
|
||||
<string name="attachments_expand">Expand attachments</string>
|
||||
<string name="attachments_collapse">Collapse attachments</string>
|
||||
|
||||
<!-- Onboarding -->
|
||||
<string name="onboarding_welcome_title">Welcome to LibreMail</string>
|
||||
|
||||
@@ -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