Merge branch 'main' into refactor-db-file-backup-sot

This commit is contained in:
Jason Ross
2026-07-02 10:14:38 -05:00
committed by GitHub
15 changed files with 523 additions and 65 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 ==
@@ -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(
+8
View File
@@ -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)