Merge branch 'main' into fix-backfill-gaps

This commit is contained in:
Jason Ross
2026-07-02 02:50:56 -05:00
committed by GitHub
14 changed files with 264 additions and 7 deletions
@@ -53,6 +53,34 @@ class LibreMailDatabaseTest {
isStarred = false,
)
@Test
fun observeUnreadCountsAggregatesUnreadSyncedRowsPerAccountAndFolder() = runBlocking {
val messageDao = db.messageDao()
messageDao.insertNew(
listOf(
// acct / INBOX: two unread + one read -> counts 2.
message("acct:INBOX:1").copy(folder = "INBOX", isRead = false),
message("acct:INBOX:2").copy(folder = "INBOX", isRead = false),
message("acct:INBOX:3").copy(folder = "INBOX", isRead = true),
// acct / Archive: one unread -> counts 1.
message("acct:Archive:1").copy(folder = "Archive", isRead = false),
// An unread server-search hit (inInbox = false) must never inflate a badge.
message("acct:INBOX:search").copy(folder = "INBOX", isRead = false, inInbox = false),
// A second account's unread inbox row is counted under its own accountId.
message("acct2:INBOX:1").copy(accountId = "acct2", folder = "INBOX", isRead = false),
),
)
val counts = messageDao.observeUnreadCounts().first()
.associate { (it.accountId to it.folder) to it.unreadCount }
assertEquals(2, counts[("acct" to "INBOX")])
assertEquals(1, counts[("acct" to "Archive")])
assertEquals(1, counts[("acct2" to "INBOX")])
// Fully-read folders and search-only rows produce no group at all.
assertEquals(3, counts.size)
}
@Test
fun deletingMessageCascadesToItsAttachments() = runBlocking {
val messageDao = db.messageDao()
@@ -14,6 +14,7 @@ import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.UnreadCount
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
import java.io.File
@@ -72,6 +73,7 @@ class FakeMailRepository(
private val folders: List<Folder> = emptyList(),
private val attachments: List<Attachment> = emptyList(),
private val downloadedParts: Set<Int> = emptySet(),
private val unreadCounts: List<UnreadCount> = emptyList(),
) : MailRepository {
val sentMessages = mutableListOf<OutgoingMessage>()
@@ -93,6 +95,8 @@ class FakeMailRepository(
},
)
override fun observeUnreadCounts(): Flow<List<UnreadCount>> = flowOf(unreadCounts)
override suspend fun refreshFolders(accountId: String): Result<Unit> = Result.success(Unit)
override suspend fun getMessage(id: String): Message? = messages.firstOrNull { it.id == id }
@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -145,10 +146,34 @@ class FolderDrawerTest {
assertTrue(unifiedTapped)
}
@Test
fun folderWithUnreadMail_showsCountBadge_andReadFolderShowsNone() {
setContent(
accounts = listOf(alice),
drawerAccount = alice,
folders = listOf(
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
),
folderUnreadCounts = mapOf("INBOX" to 3),
)
// The inbox badge announces its exact count for screen readers.
val threeUnread = composeTestRule.activity.resources
.getQuantityString(R.plurals.folder_unread_count_description, 3, 3)
composeTestRule.onNodeWithContentDescription(threeUnread).assertIsDisplayed()
// Archive has no unread mail, so no badge is rendered for it.
val oneUnread = composeTestRule.activity.resources
.getQuantityString(R.plurals.folder_unread_count_description, 1, 1)
composeTestRule.onNodeWithContentDescription(oneUnread).assertDoesNotExist()
}
private fun setContent(
accounts: List<Account>,
drawerAccount: Account?,
folders: List<Folder>,
folderUnreadCounts: Map<String, Int> = emptyMap(),
accountsWithUnread: Set<String> = emptySet(),
selectedAccountId: String? = null,
selectedFolder: String = "INBOX",
onSelectUnifiedInbox: () -> Unit = {},
@@ -162,6 +187,8 @@ class FolderDrawerTest {
accounts = accounts,
drawerAccount = drawerAccount,
folders = folders,
folderUnreadCounts = folderUnreadCounts,
accountsWithUnread = accountsWithUnread,
selectedAccountId = selectedAccountId,
selectedFolder = selectedFolder,
onSelectUnifiedInbox = onSelectUnifiedInbox,
@@ -8,6 +8,7 @@ import org.libremail.data.local.entity.AccountSettingsEntity
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.FolderUnreadCount
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.MessageSummary
import org.libremail.data.local.entity.OutboxEntity
@@ -26,6 +27,7 @@ import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.ServerConfig
import org.libremail.domain.model.SmtpParams
import org.libremail.domain.model.UnreadCount
import org.libremail.mail.AttachmentPart
import org.libremail.mail.FetchedFolder
import org.libremail.mail.FetchedMessage
@@ -168,6 +170,12 @@ internal fun FetchedFolder.toEntity(accountId: String, sortOrder: Int): FolderEn
specialUse = FolderRole.isServerSpecial(attributes),
)
internal fun FolderUnreadCount.toDomain(): UnreadCount = UnreadCount(
accountId = accountId,
folder = folder,
count = unreadCount,
)
internal fun AttachmentEntity.toDomain(): Attachment = Attachment(
messageId = messageId,
partIndex = partIndex,
@@ -6,6 +6,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.FolderUnreadCount
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.MessageSummary
@@ -23,6 +24,19 @@ interface MessageDao {
)
fun observeSummaries(): Flow<List<MessageSummary>>
/**
* Live per-(account, folder) unread counts for the drawer's folder badges and the bold styling of
* accounts with unread mail. Counts only folder-synced rows (`inInbox = 1`), so transient
* server-search hits never inflate a badge; read rows and folders with no unread mail are simply
* absent from the result. A pure `COUNT(*)` aggregate — no message rows are pulled into memory —
* whose `GROUP BY accountId, folder` is served by the existing `(accountId, folder, uid)` index.
*/
@Query(
"SELECT accountId, folder, COUNT(*) AS unreadCount FROM messages " +
"WHERE inInbox = 1 AND isRead = 0 GROUP BY accountId, folder",
)
fun observeUnreadCounts(): Flow<List<FolderUnreadCount>>
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
suspend fun getById(id: String): MessageEntity?
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
/**
* Aggregate projection of [MessageEntity] counting unread, folder-synced rows per (account, folder).
* Produced by `MessageDao.observeUnreadCounts`' `GROUP BY` query — never a stored table — so the
* counts come straight from SQLite without pulling any message rows into memory.
*/
data class FolderUnreadCount(val accountId: String, val folder: String, val unreadCount: Int)
@@ -36,6 +36,7 @@ import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.UnreadCount
import org.libremail.domain.repository.MailRepository
import org.libremail.mail.ImapClient
import java.io.File
@@ -68,6 +69,10 @@ class MailRepositoryImpl @Inject constructor(
rows.map { it.toDomain() }
}
override fun observeUnreadCounts(): Flow<List<UnreadCount>> = messageDao.observeUnreadCounts().map { rows ->
rows.map { it.toDomain() }
}
override suspend fun refreshFolders(accountId: String): Result<Unit> = runCatching {
val account = accountDao.getById(accountId)?.toDomain() ?: error("Account not found")
val params = connectionFactory.imapParamsFor(account)
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/**
* The number of unread, folder-synced messages in one account's folder. Emitted per (account, folder)
* pair that currently holds unread mail; pairs with no unread mail are simply absent. Feeds the
* drawer's per-folder unread badges (#83) and the bold styling of accounts that have unread mail (#84).
*/
data class UnreadCount(val accountId: String, val folder: String, val count: Int)
@@ -9,6 +9,7 @@ import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.UnreadCount
import java.io.File
/**
@@ -21,6 +22,13 @@ interface MailRepository {
/** The account's cached IMAP folders for the navigation drawer. */
fun observeFolders(accountId: String): Flow<List<Folder>>
/**
* Live per-(account, folder) unread counts across every account, for the drawer's folder badges
* and the bold styling of accounts with unread mail. Only (account, folder) pairs that currently
* hold unread, folder-synced mail are emitted.
*/
fun observeUnreadCounts(): Flow<List<UnreadCount>>
/** Refreshes the account's folder list from the server into the cache. */
suspend fun refreshFolders(accountId: String): Result<Unit>
@@ -27,7 +27,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.libremail.R
@@ -37,13 +41,17 @@ import org.libremail.domain.model.FolderRole
/**
* The navigation drawer's contents: an optional account switcher and "All Inboxes" entry (only with
* 2+ accounts), then the drawer account's folders — standard folders (Inbox, Sent, …) first.
* 2+ accounts), then the drawer account's folders — standard folders (Inbox, Sent, …) first. Each
* folder shows its unread count as a trailing badge ([folderUnreadCounts]), and accounts with unread
* mail ([accountsWithUnread]) render their email in bold.
*/
@Composable
fun FolderDrawer(
accounts: List<Account>,
drawerAccount: Account?,
folders: List<Folder>,
folderUnreadCounts: Map<String, Int>,
accountsWithUnread: Set<String>,
selectedAccountId: String?,
selectedFolder: String,
onSelectUnifiedInbox: () -> Unit,
@@ -63,7 +71,7 @@ fun FolderDrawer(
)
if (accounts.size >= 2 && drawerAccount != null) {
AccountSwitcher(accounts, drawerAccount, onSelectDrawerAccount)
AccountSwitcher(accounts, drawerAccount, accountsWithUnread, onSelectDrawerAccount)
NavigationDrawerItem(
label = { Text(stringResource(R.string.folder_all_inboxes)) },
icon = { Icon(Icons.Filled.Email, contentDescription = null) },
@@ -87,9 +95,15 @@ fun FolderDrawer(
val iconContent: (@Composable () -> Unit)? = folderIcon(folder.role)?.let { vector ->
{ Icon(vector, contentDescription = null) }
}
val unread = folderUnreadCounts[folder.fullName] ?: 0
NavigationDrawerItem(
label = { Text(resolvedLabels[folder.fullName] ?: folderDisplayLabel(folder)) },
icon = iconContent,
badge = if (unread > 0) {
{ UnreadBadgeLabel(unread) }
} else {
null
},
selected = isSelected,
onClick = {
if (folder.selectable && drawerAccount != null) {
@@ -103,19 +117,34 @@ fun FolderDrawer(
}
@Composable
private fun AccountSwitcher(accounts: List<Account>, current: Account, onSelect: (String) -> Unit) {
private fun AccountSwitcher(
accounts: List<Account>,
current: Account,
accountsWithUnread: Set<String>,
onSelect: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
TextButton(
onClick = { expanded = true },
modifier = Modifier.padding(horizontal = 16.dp),
) {
Text(current.email, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
current.email,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (current.id in accountsWithUnread) FontWeight.Bold else FontWeight.Normal,
)
Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.drawer_switch_account))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
accounts.forEach { account ->
DropdownMenuItem(
text = { Text(account.email) },
text = {
Text(
account.email,
fontWeight = if (account.id in accountsWithUnread) FontWeight.Bold else FontWeight.Normal,
)
},
onClick = {
onSelect(account.id)
expanded = false
@@ -125,6 +154,29 @@ private fun AccountSwitcher(accounts: List<Account>, current: Account, onSelect:
}
}
/** Cap for a folder's visible unread badge; higher counts render as "99+" to keep the row compact. */
private const val UNREAD_BADGE_CAP = 99
/**
* A folder row's trailing unread-count badge. The visible label is capped at [UNREAD_BADGE_CAP] as
* "99+" so a large count can't blow out the row, while the semantics announce the exact count as
* "N unread messages" (overriding the terse glyph) for screen readers.
*/
@Composable
private fun UnreadBadgeLabel(count: Int) {
val display = if (count > UNREAD_BADGE_CAP) {
stringResource(R.string.folder_unread_overflow)
} else {
count.toString()
}
val description = pluralStringResource(R.plurals.folder_unread_count_description, count, count)
Text(
text = display,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.clearAndSetSemantics { contentDescription = description },
)
}
/**
* De-duplicated display labels for [folders], keyed by [Folder.fullName] — the one resolution used
* by the drawer, the move-to picker, and the app-bar title, so a folder reads the same everywhere.
@@ -106,6 +106,8 @@ fun MailboxScreen(
val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle()
val selectedFolder by viewModel.selectedFolder.collectAsStateWithLifecycle()
val folders by viewModel.folders.collectAsStateWithLifecycle()
val folderUnreadCounts by viewModel.folderUnreadCounts.collectAsStateWithLifecycle()
val accountsWithUnread by viewModel.accountsWithUnread.collectAsStateWithLifecycle()
val drawerAccount by viewModel.drawerAccount.collectAsStateWithLifecycle()
val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle()
val draftCount by viewModel.draftCount.collectAsStateWithLifecycle()
@@ -156,6 +158,8 @@ fun MailboxScreen(
accounts = accounts,
drawerAccount = drawerAccount,
folders = folders,
folderUnreadCounts = folderUnreadCounts,
accountsWithUnread = accountsWithUnread,
selectedAccountId = selectedAccountId,
selectedFolder = selectedFolder,
onSelectUnifiedInbox = {
@@ -256,6 +260,7 @@ fun MailboxScreen(
AccountFilterRow(
accounts = accounts,
selectedId = selectedAccountId,
accountsWithUnread = accountsWithUnread,
onSelect = viewModel::selectAccount,
)
}
@@ -411,7 +416,12 @@ private fun OutboxEntry(count: Int, onClick: () -> Unit) {
}
@Composable
private fun AccountFilterRow(accounts: List<Account>, selectedId: String?, onSelect: (String?) -> Unit) {
private fun AccountFilterRow(
accounts: List<Account>,
selectedId: String?,
accountsWithUnread: Set<String>,
onSelect: (String?) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -428,7 +438,13 @@ private fun AccountFilterRow(accounts: List<Account>, selectedId: String?, onSel
FilterChip(
selected = selectedId == account.id,
onClick = { onSelect(account.id) },
label = { Text(account.email, maxLines = 1) },
label = {
Text(
account.email,
maxLines = 1,
fontWeight = if (account.id in accountsWithUnread) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
@@ -28,6 +28,7 @@ import org.libremail.domain.model.Folder
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.UnreadCount
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
@@ -87,6 +88,28 @@ class MailboxViewModel @Inject constructor(
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/**
* Live unread counts across every account, shared by the two drawer indicators below so the
* underlying `COUNT` query is collected once no matter how many observers derive from it.
*/
private val unreadCounts: StateFlow<List<UnreadCount>> = mailRepository.observeUnreadCounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** #83: unread-message count keyed by folder full name for the drawer account (absent = none). */
val folderUnreadCounts: StateFlow<Map<String, Int>> =
combine(drawerAccount, unreadCounts) { account, counts ->
if (account == null) {
emptyMap()
} else {
counts.filter { it.accountId == account.id }.associate { it.folder to it.count }
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** #84: ids of accounts that currently have unread mail in any folder. */
val accountsWithUnread: StateFlow<Set<String>> = unreadCounts
.map { counts -> counts.filter { it.count > 0 }.map { it.accountId }.toSet() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet())
private val _searchActive = MutableStateFlow(false)
val searchActive: StateFlow<Boolean> = _searchActive.asStateFlow()
+7
View File
@@ -68,6 +68,13 @@
<string name="folder_archive">Archive</string>
<string name="folder_spam">Spam</string>
<string name="folder_trash">Trash</string>
<!-- Unread badge shown after a folder name; caps very large counts. -->
<string name="folder_unread_overflow">99+</string>
<!-- Screen-reader description for a folder's unread badge, e.g. "12 unread messages". -->
<plurals name="folder_unread_count_description">
<item quantity="one">%1$d unread message</item>
<item quantity="other">%1$d unread messages</item>
</plurals>
<!-- Compose -->
<string name="compose_from">From</string>
@@ -30,6 +30,7 @@ import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.ServerConfig
import org.libremail.domain.model.UnreadCount
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
@@ -429,10 +430,55 @@ class MailboxViewModelTest {
assertEquals(listOf("imap:a:INBOX:1"), vm.messages.value.map { it.id })
}
@Test
fun `folderUnreadCounts maps the drawer account's folders to their unread counts`() = runTest(testDispatcher) {
val vm = createViewModel(
accounts = listOf(alice, bob),
messages = emptyList(),
unreadCounts = listOf(
UnreadCount("imap:a", "INBOX", 3),
UnreadCount("imap:a", "[Gmail]/Spam", 1),
UnreadCount("imap:b", "INBOX", 9),
),
)
backgroundScope.launch { vm.folderUnreadCounts.collect {} }
// The drawer defaults to the first account (alice); bob's counts are excluded.
assertEquals(mapOf("INBOX" to 3, "[Gmail]/Spam" to 1), vm.folderUnreadCounts.value)
}
@Test
fun `folderUnreadCounts follows the drawer-account switch`() = runTest(testDispatcher) {
val vm = createViewModel(
accounts = listOf(alice, bob),
messages = emptyList(),
unreadCounts = listOf(UnreadCount("imap:a", "INBOX", 3), UnreadCount("imap:b", "INBOX", 9)),
)
backgroundScope.launch { vm.folderUnreadCounts.collect {} }
vm.setDrawerAccount("imap:b")
assertEquals(mapOf("INBOX" to 9), vm.folderUnreadCounts.value)
}
@Test
fun `accountsWithUnread lists every account that has unread mail in any folder`() = runTest(testDispatcher) {
val vm = createViewModel(
accounts = listOf(alice, bob),
messages = emptyList(),
// alice has unread mail (in a non-inbox folder too); bob has none.
unreadCounts = listOf(UnreadCount("imap:a", "INBOX", 3), UnreadCount("imap:a", "Archive", 2)),
)
backgroundScope.launch { vm.accountsWithUnread.collect {} }
assertEquals(setOf("imap:a"), vm.accountsWithUnread.value)
}
private fun createViewModel(
accounts: List<Account>,
messages: List<Message>,
folders: Map<String, List<Folder>> = emptyMap(),
unreadCounts: List<UnreadCount> = emptyList(),
syncer: MailSyncer = mockk(relaxed = true),
repo: MailRepository = mockk(relaxed = true),
initialAccountId: String? = null,
@@ -440,6 +486,7 @@ class MailboxViewModelTest {
every { repo.observeMessages() } returns MutableStateFlow(messages)
every { repo.observeDrafts() } returns flowOf(emptyList())
every { repo.observeOutbox() } returns flowOf(emptyList())
every { repo.observeUnreadCounts() } returns MutableStateFlow(unreadCounts)
accounts.forEach { account ->
every { repo.observeFolders(account.id) } returns MutableStateFlow(folders[account.id] ?: emptyList())
}