Merge branch 'main' into fix-workmanager-update-policy

This commit is contained in:
Jason Ross
2026-07-02 03:07:05 -05:00
committed by GitHub
20 changed files with 620 additions and 72 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()
@@ -21,12 +21,12 @@ import org.libremail.data.local.entity.MessageEntity
*
* These queries *define* the device-only retention floor — the pruner deletes below it
* ([MessageDao.syncedIdsBeyondCountInFolder] / [MessageDao.syncedIdsOlderThan]) and the backfiller
* stops above it ([MessageDao.lowestSyncedUid] / [MessageDao.countSynced] /
* [MessageDao.oldestSyncedTimestamp]) — so the whole "backfill and prune never fight over the same
* rows" guarantee rests on their SQL. The [org.libremail.data.sync.MailPruner] /
* [org.libremail.data.sync.MailBackfiller] unit tests mock the DAO, so the `ORDER BY … DESC LIMIT`
* newest-N selection, the strict age cutoff, and the windowed reconcile that spares backfilled history
* are exercised here against a real database instead.
* pages and stops around it ([MessageDao.lowestSyncedUid] / [MessageDao.countSynced]) — so the whole
* "backfill and prune never fight over the same rows" guarantee rests on their SQL. The
* [org.libremail.data.sync.MailPruner] / [org.libremail.data.sync.MailBackfiller] unit tests mock
* the DAO, so the `ORDER BY … DESC LIMIT` newest-N selection, the strict age cutoff, and the
* windowed reconcile that spares backfilled history are exercised here against a real database
* instead.
*/
@RunWith(AndroidJUnit4::class)
class MessageDaoRetentionTest {
@@ -153,6 +153,9 @@ class MessageDaoRetentionTest {
/**
* The backfiller's floor probes reflect only an account's synced rows in the given folder, and are
* null/zero for a folder with nothing cached (so the backfiller then starts from `Long.MAX_VALUE`).
* The paging boundary additionally skips `uid <= 0` placeholder rows (#95): a row migrated before
* the `uid` column existed (backfilled to 0) or one whose UID the server failed to resolve (-1)
* must not collapse MIN(uid) to a bound the backfiller treats as "folder fully paged".
*/
@Test
fun floorProbesReflectOnlySyncedRowsInTheFolder() = runBlocking {
@@ -160,18 +163,22 @@ class MessageDaoRetentionTest {
listOf(
message("a", uid = 30, timestampMillis = 300),
message("d", uid = 10, timestampMillis = 100),
message("legacy", uid = 0, timestampMillis = 40), // pre-uid-column migration row (#95)
message("unresolved", uid = -1, timestampMillis = 30), // UIDFolder.getUID failure (#95)
message("search", uid = 1, timestampMillis = 1, inInbox = false), // excluded
message("archive", uid = 5, timestampMillis = 50, folder = "Archive"), // different folder
),
)
assertEquals(10L, dao.lowestSyncedUid("acct", "INBOX"))
assertEquals(2, dao.countSynced("acct", "INBOX"))
assertEquals(100L, dao.oldestSyncedTimestamp("acct", "INBOX"))
assertEquals(4, dao.countSynced("acct", "INBOX"))
assertNull(dao.lowestSyncedUid("acct", "Nonexistent"))
assertEquals(0, dao.countSynced("acct", "Nonexistent"))
assertNull(dao.oldestSyncedTimestamp("acct", "Nonexistent"))
// A folder holding ONLY placeholder rows has no usable boundary: null (start from the top).
dao.insertNew(listOf(message("only-legacy", uid = 0, folder = "Imported")))
assertNull(dao.lowestSyncedUid("acct", "Imported"))
}
/** Backfill / prune enumerate their targets via [syncedFolders]: distinct synced folders, per account. */
@@ -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
@@ -42,16 +43,20 @@ class FolderDrawerTest {
folders = listOf(
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
folder("imap:a", "[Gmail]/Sent Mail", "Sent Mail", FolderRole.SENT),
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
// Give ARCHIVE a server name that differs from its friendly label so the assertion
// below actually discriminates a role-to-label regression (displayName != friendly).
folder("imap:a", "[Gmail]/All Mail", "All Mail", FolderRole.ARCHIVE),
folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL),
),
)
composeTestRule.onNodeWithText(string(R.string.folder_inbox)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed()
// Standard folders use the friendly role name ("Sent"), not the raw server name ("Sent Mail").
// Standard folders use the friendly role name, not the raw server name — verified for both
// Sent ("Sent Mail" -> "Sent") and Archive ("All Mail" -> "Archive").
composeTestRule.onNodeWithText(string(R.string.folder_sent)).assertIsDisplayed()
composeTestRule.onNodeWithText("Sent Mail").assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed()
composeTestRule.onNodeWithText("All Mail").assertDoesNotExist()
// Normal folders keep their server name.
composeTestRule.onNodeWithText("Receipts").assertIsDisplayed()
// A single account shows no account switcher / "All Inboxes" entry.
@@ -141,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 = {},
@@ -158,6 +187,8 @@ class FolderDrawerTest {
accounts = accounts,
drawerAccount = drawerAccount,
folders = folders,
folderUnreadCounts = folderUnreadCounts,
accountsWithUnread = accountsWithUnread,
selectedAccountId = selectedAccountId,
selectedFolder = selectedFolder,
onSelectUnifiedInbox = onSelectUnifiedInbox,