From 96111b40dfc02ba5c09b8a66e9666d8cdbef6016 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 30 Jun 2026 17:20:22 -0500 Subject: [PATCH] feat: long-press multi-select, aggressive prefetch, and download indicators Add a long-press contextual action bar to the mailbox: Archive, Delete, Spam, Move, Select All, and (single-selection only) Reply, Reply All, and Forward. Reply All/Spam/Delete are confirmed first; deleting from Trash or Spam warns that it is permanent. - Delete moves to Trash and Spam moves to the Spam folder; only deletes already in Trash/Spam do a permanent IMAP expunge. Archive/Spam/Move resolve the destination per account so the unified inbox works. - Reply/Reply All/Forward force a fresh server fetch of the original (recipients + body via BODY.PEEK), build a quoted draft, and open compose; a spinner shows during the fetch and they error via snackbar if offline. Add a top-level "Message downloading" setting (Always fetch all / Fetch all on Wi-Fi / Always on-demand; default Always) that aggressively pre-caches full bodies and all attachment bytes during sync (outside the sync lock, without marking mail read), into a persistent per-part attachment cache so messages and attachments open instantly and offline. Show an "available offline" mark on cached list rows and a per-attachment "downloaded" check in the reader. Extract a Syncer interface (MailSyncer implements it) so the mailbox can be driven end-to-end in tests. Tests: 66 unit + 27 instrumented, all passing on the API 37 emulator. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/org/libremail/ui/Fakes.kt | 66 +++- .../libremail/ui/mailbox/MailboxScreenTest.kt | 178 ++++++++++ .../libremail/ui/reader/ReaderScreenTest.kt | 64 ++++ .../ui/settings/SettingsScreenTest.kt | 52 +++ app/src/main/AndroidManifest.xml | 1 + .../kotlin/org/libremail/data/ReplyBuilder.kt | 88 +++++ .../org/libremail/data/local/Mappers.kt | 1 + .../libremail/data/local/dao/AttachmentDao.kt | 4 + .../org/libremail/data/local/dao/FolderDao.kt | 4 + .../libremail/data/local/dao/MessageDao.kt | 11 + .../data/repository/MailRepositoryImpl.kt | 149 +++++++- .../data/settings/SettingsRepository.kt | 18 + .../org/libremail/data/sync/MailSyncer.kt | 89 +++-- .../kotlin/org/libremail/data/sync/Syncer.kt | 12 + .../org/libremail/di/RepositoryModule.kt | 6 + .../org/libremail/domain/model/Message.kt | 2 + .../org/libremail/domain/model/ReplyMode.kt | 5 + .../domain/repository/MailRepository.kt | 31 ++ .../kotlin/org/libremail/mail/ImapClient.kt | 96 +++++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 1 + .../org/libremail/ui/mailbox/MailboxScreen.kt | 327 ++++++++++++++++-- .../libremail/ui/mailbox/MailboxViewModel.kt | 143 +++++++- .../org/libremail/ui/reader/ReaderScreen.kt | 17 +- .../libremail/ui/reader/ReaderViewModel.kt | 5 +- .../libremail/ui/settings/SettingsScreen.kt | 47 +++ .../ui/settings/SettingsViewModel.kt | 2 + app/src/main/res/values/strings.xml | 32 ++ .../org/libremail/data/ReplyBuilderTest.kt | 108 ++++++ .../data/repository/MailRepositoryImplTest.kt | 275 ++++++++++++++- .../org/libremail/data/sync/MailSyncerTest.kt | 115 ++++++ .../org/libremail/mail/ImapClientTest.kt | 42 +++ .../ui/mailbox/MailboxViewModelTest.kt | 224 +++++++++++- .../ui/reader/ReaderViewModelTest.kt | 84 +++++ 33 files changed, 2214 insertions(+), 85 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt create mode 100644 app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt create mode 100644 app/src/main/kotlin/org/libremail/data/sync/Syncer.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/ReplyMode.kt create mode 100644 app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt create mode 100644 app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelTest.kt diff --git a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt index 7f8c824..e9cd6a1 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt @@ -13,6 +13,8 @@ import org.libremail.domain.model.ImapConnectionParams 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.data.sync.Syncer import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @@ -59,32 +61,79 @@ class FakeAccountRepository( */ class FakeMailRepository( var sendResult: Result = Result.success(Unit), + private val messages: List = emptyList(), + private val folders: List = emptyList(), + private val attachments: List = emptyList(), + private val downloadedParts: Set = emptySet(), ) : MailRepository { val sentMessages = mutableListOf() val savedDrafts = mutableListOf() val deletedDraftIds = mutableListOf() + val archivedIds = mutableListOf>() + val spammedIds = mutableListOf>() + val trashedIds = mutableListOf>() + val expungedIds = mutableListOf>() + val movedToFolder = mutableListOf, String>>() + val replyDrafts = mutableListOf>() - override fun observeMessages(): Flow> = flowOf(emptyList()) + override fun observeMessages(): Flow> = flowOf(messages) - override fun observeFolders(accountId: String): Flow> = flowOf(emptyList()) + override fun observeFolders(accountId: String): Flow> = + flowOf(folders.filter { it.accountId == accountId }) override suspend fun refreshFolders(accountId: String): Result = Result.success(Unit) - override suspend fun getMessage(id: String): Message? = null + override suspend fun getMessage(id: String): Message? = messages.firstOrNull { it.id == id } override suspend fun openMessage(id: String): Result = - Result.failure(UnsupportedOperationException("not used in UI tests")) + messages.firstOrNull { it.id == id }?.let { Result.success(it) } + ?: Result.failure(NoSuchElementException("no message $id")) - override fun observeAttachments(messageId: String): Flow> = flowOf(emptyList()) + override fun observeAttachments(messageId: String): Flow> = + flowOf(attachments.filter { it.messageId == messageId }) override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result = Result.failure(UnsupportedOperationException("not used in UI tests")) + override suspend fun prefetchMessage(messageId: String): Result = Result.success(Unit) + + override suspend fun downloadedAttachmentParts(messageId: String): Set = downloadedParts + override suspend fun setStarred(id: String, starred: Boolean): Result = Result.success(Unit) override suspend fun deleteMessage(id: String): Result = Result.success(Unit) + override suspend fun archive(ids: List): Result { + archivedIds += ids + return Result.success(Unit) + } + + override suspend fun reportSpam(ids: List): Result { + spammedIds += ids + return Result.success(Unit) + } + + override suspend fun trash(ids: List): Result { + trashedIds += ids + return Result.success(Unit) + } + + override suspend fun expunge(ids: List): Result { + expungedIds += ids + return Result.success(Unit) + } + + override suspend fun moveToFolder(ids: List, destFolderFullName: String): Result { + movedToFolder += ids to destFolderFullName + return Result.success(Unit) + } + + override suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result { + replyDrafts += messageId to mode + return Result.success("draft-$messageId") + } + override suspend fun sendMessage(outgoing: OutgoingMessage): Result { sentMessages += outgoing return sendResult @@ -112,3 +161,10 @@ class FakeMailRepository( override suspend fun clearSearchResults() {} } + +/** No-op [Syncer] for UI tests: the screen renders cached data, so sync calls do nothing. */ +class FakeMailSyncer : Syncer { + override suspend fun syncAll(): Result = Result.success(0) + override suspend fun syncAccount(accountId: String): Result = Result.success(0) + override suspend fun syncFolder(accountId: String, folder: String): Result = Result.success(0) +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt new file mode 100644 index 0000000..17d9312 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.mailbox + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick +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.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.Folder +import org.libremail.domain.model.FolderRole +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.Message +import org.libremail.domain.model.ServerConfig +import org.libremail.ui.FakeAccountRepository +import org.libremail.ui.FakeMailRepository +import org.libremail.ui.FakeMailSyncer +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for the mailbox's long-press contextual action bar. Drives the real + * [MailboxScreen] + [MailboxViewModel] backed by in-memory fakes. + */ +@RunWith(AndroidJUnit4::class) +class MailboxScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + private fun count(n: Int) = composeTestRule.activity.getString(R.string.cab_selected_count, n) + + private val account = Account( + id = "imap:a", + email = "a@example.org", + displayName = "A", + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) + + private fun message(uid: String, subject: String, bodyFetched: Boolean = false) = Message( + id = "imap:a:INBOX:$uid", + accountId = "imap:a", + sender = "Sender $uid", + senderEmail = "s$uid@example.org", + subject = subject, + snippet = "", + body = "", + isHtml = false, + timestampMillis = 1_000L, + isRead = true, + isStarred = false, + folder = "INBOX", + inInbox = true, + bodyFetched = bodyFetched, + ) + + private fun setContent(repo: FakeMailRepository) { + val viewModel = MailboxViewModel(repo, FakeAccountRepository(accounts = listOf(account)), FakeMailSyncer()) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + MailboxScreen( + onOpenMessage = {}, + onCompose = {}, + onOpenDrafts = {}, + onOpenOutbox = {}, + onAddAccount = {}, + onOpenCompose = {}, + onSelectTab = {}, + viewModel = viewModel, + ) + } + } + } + + private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun longPress_entersSelection_andCountTracksTaps() { + setContent(FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second")))) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + composeTestRule.onNodeWithText(count(1)).assertIsDisplayed() + + composeTestRule.onNodeWithText("Second").performClick() + composeTestRule.onNodeWithText(count(2)).assertIsDisplayed() + } + + @Test + fun overflow_showsReplyActions_forSingleSelection() { + setContent(FakeMailRepository(messages = listOf(message("1", "First")))) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick() + + composeTestRule.onNodeWithText(string(R.string.action_reply)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_reply_all)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_forward)).assertIsDisplayed() + } + + @Test + fun overflow_hidesReplyActions_forMultiSelection() { + setContent(FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second")))) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + composeTestRule.onNodeWithText("Second").performClick() + composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick() + + composeTestRule.onNodeWithText(string(R.string.action_archive)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist() + composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist() + } + + @Test + fun delete_confirmsMoveToTrash_thenTrashesViaRepository() { + val repo = FakeMailRepository(messages = listOf(message("1", "First"))) + setContent(repo) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).performClick() + composeTestRule.onNodeWithText(string(R.string.confirm_trash_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_move)).performClick() + + composeTestRule.waitUntil(5_000) { repo.trashedIds.isNotEmpty() } + assertEquals(listOf("imap:a:INBOX:1"), repo.trashedIds.first()) + } + + @Test + fun move_picker_movesSelectionToTheChosenFolder() { + val repo = FakeMailRepository( + messages = listOf(message("1", "First")), + folders = listOf( + Folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX, selectable = true), + Folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL, selectable = true), + ), + ) + setContent(repo) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick() + composeTestRule.onNodeWithText(string(R.string.action_move)).performClick() + // The off-screen navigation drawer also lists "Receipts", so scope the tap to the move dialog. + composeTestRule.onNode(hasText("Receipts") and hasAnyAncestor(isDialog())).performClick() + + composeTestRule.waitUntil(5_000) { repo.movedToFolder.isNotEmpty() } + assertEquals("Receipts", repo.movedToFolder.first().second) + } + + @Test + fun offlineIndicator_showsForCachedMessages() { + setContent(FakeMailRepository(messages = listOf(message("1", "Cached", bodyFetched = true)))) + waitForText("Cached") + + composeTestRule.onNodeWithContentDescription(string(R.string.message_available_offline)).assertIsDisplayed() + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt new file mode 100644 index 0000000..1ccae3e --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reader + +import androidx.activity.ComponentActivity +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.lifecycle.SavedStateHandle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Message +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. */ +@RunWith(AndroidJUnit4::class) +class ReaderScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private val messageId = "imap:a:INBOX:1" + private val message = Message( + id = messageId, accountId = "imap:a", sender = "Sender", senderEmail = "s@example.org", + subject = "Subject", snippet = "", body = "Hello body", isHtml = false, timestampMillis = 1_000L, + isRead = true, isStarred = false, + ) + + @Test + fun reader_showsDownloadedIndicator_forCachedAttachment() { + 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), + ) + 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.onNodeWithContentDescription(string(R.string.attachment_downloaded)).assertIsDisplayed() + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt new file mode 100644 index 0000000..2ef0e1e --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.data.settings.FetchPolicy +import org.libremail.data.settings.SettingsRepository +import org.libremail.ui.FakeAccountRepository +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end test for the top-level message-downloading setting: tapping a policy must round-trip + * through the real [SettingsRepository] (DataStore), proving the UI → ViewModel → persistence wiring. + */ +@RunWith(AndroidJUnit4::class) +class SettingsScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + @Test + fun selectingFetchPolicy_persistsThroughTheRepository() { + val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + val settingsRepository = SettingsRepository(context) + runBlocking { settingsRepository.setFetchPolicy(FetchPolicy.ALWAYS) } // known starting state + val viewModel = SettingsViewModel(FakeAccountRepository(), settingsRepository) + + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + SettingsScreen(onAddAccount = {}, onSelectTab = {}, viewModel = viewModel) + } + } + + composeTestRule.onNodeWithText(string(R.string.fetch_on_demand)).performScrollTo().performClick() + + composeTestRule.waitUntil(5_000) { + runBlocking { settingsRepository.fetchPolicy() } == FetchPolicy.ON_DEMAND + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 68e1c18..8ee562c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt new file mode 100644 index 0000000..0eda275 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import org.libremail.domain.model.ReplyMode +import org.libremail.mail.ReplyContext + +/** The pre-filled compose fields for a reply/forward (everything else the user fills in). */ +data class ReplyContent( + val to: String, + val cc: String, + val subject: String, + val body: String, +) + +/** + * Pure builder that turns an original message ([ReplyContext]) into the pre-filled compose fields for a + * reply, reply-all, or forward. Kept free of Android/IMAP dependencies so it can be unit-tested directly. + */ +object ReplyBuilder { + + fun build(context: ReplyContext, mode: ReplyMode, selfEmail: String): ReplyContent = when (mode) { + ReplyMode.REPLY -> ReplyContent( + to = context.fromEmail, + cc = "", + subject = prefixedSubject(context.subject, "Re:"), + body = quotedReply(context), + ) + + ReplyMode.REPLY_ALL -> ReplyContent( + to = context.fromEmail, + cc = replyAllCc(context, selfEmail).joinToString(", "), + subject = prefixedSubject(context.subject, "Re:"), + body = quotedReply(context), + ) + + ReplyMode.FORWARD -> ReplyContent( + to = "", + cc = "", + subject = prefixedSubject(context.subject, "Fwd:"), + body = forwardedBody(context), + ) + } + + /** Everyone on the original To/Cc except ourselves and the original sender (who becomes the To). */ + private fun replyAllCc(context: ReplyContext, selfEmail: String): List = + (context.toRecipients + context.ccRecipients) + .filter { it.isNotBlank() } + .distinctBy { it.lowercase(Locale.ROOT) } + .filterNot { it.equals(selfEmail, ignoreCase = true) || it.equals(context.fromEmail, ignoreCase = true) } + + /** Adds [prefix] unless the subject already starts with it (case-insensitive), avoiding "Re: Re:". */ + private fun prefixedSubject(subject: String, prefix: String): String { + val trimmed = subject.trim() + return if (trimmed.startsWith(prefix, ignoreCase = true)) trimmed else "$prefix $trimmed" + } + + private fun quotedReply(context: ReplyContext): String { + val original = bodyText(context).lineSequence().joinToString("\n") { "> $it" } + return "\n\nOn ${formatDate(context.sentDateMillis)}, ${context.fromEmail} wrote:\n$original" + } + + private fun forwardedBody(context: ReplyContext): String = buildString { + append("\n\n---------- Forwarded message ----------\n") + append("From: ${context.fromEmail}\n") + append("Date: ${formatDate(context.sentDateMillis)}\n") + append("Subject: ${context.subject}\n") + append("To: ${context.toRecipients.joinToString(", ")}\n\n") + append(bodyText(context)) + } + + /** The original body as plain text (HTML stripped), suitable for quoting in a plain-text compose. */ + private fun bodyText(context: ReplyContext): String = + if (context.isHtml) htmlToText(context.body) else context.body + + private fun htmlToText(html: String): String = + html + .replace(Regex("(?i)"), "\n") + .replace(Regex("(?i)"), "\n\n") + .replace(Regex("<[^>]*>"), "") + .replace(Regex("[ \\t]+"), " ") + .trim() + + private fun formatDate(millis: Long): String = + SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis)) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index 54e7ded..52a51bb 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -89,6 +89,7 @@ internal fun MessageEntity.toDomain(): Message = Message( isStarred = isStarred, folder = folder, inInbox = inInbox, + bodyFetched = bodyFetched, ) internal fun FetchedMessage.toEntity( diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt index b016c6e..db6d04a 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt @@ -14,6 +14,10 @@ interface AttachmentDao { @Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex") fun observeForMessage(messageId: String): Flow> + /** One-shot read of a message's cached attachment metadata (e.g. to pre-download their bytes). */ + @Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex") + suspend fun getForMessage(messageId: String): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(attachments: List) diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/FolderDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/FolderDao.kt index 6b4ee0a..67e8fe4 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/FolderDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/FolderDao.kt @@ -14,6 +14,10 @@ interface FolderDao { @Query("SELECT * FROM folders WHERE accountId = :accountId ORDER BY sortOrder ASC") fun observeForAccount(accountId: String): Flow> + /** One-shot read of an account's folders, e.g. to resolve a role folder for archive/spam/trash. */ + @Query("SELECT * FROM folders WHERE accountId = :accountId ORDER BY sortOrder ASC") + suspend fun getForAccountOnce(accountId: String): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(folders: List) diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt index 48c4d5b..db2495b 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt @@ -20,6 +20,13 @@ interface MessageDao { @Query("SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1") suspend fun getSyncedIds(accountId: String, folder: String): List + /** Ids of synced rows in [folder] whose body hasn't been cached yet (for aggressive prefetch). */ + @Query( + "SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder " + + "AND inInbox = 1 AND bodyFetched = 0", + ) + suspend fun getUnfetchedIds(accountId: String, folder: String): List + /** Inserts only new messages, leaving existing rows (and their cached bodies/flags) intact. */ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertNew(messages: List) @@ -57,6 +64,10 @@ interface MessageDao { @Query("DELETE FROM messages WHERE id = :id") suspend fun deleteById(id: String) + /** Optimistically removes moved/deleted rows; the next sync reconciles if a server op failed. */ + @Query("DELETE FROM messages WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) + @Query("DELETE FROM messages WHERE accountId = :accountId") suspend fun deleteByAccount(accountId: String) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index de3680e..b299d89 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -11,12 +11,15 @@ import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import org.libremail.data.ReplyBuilder import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.FolderDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.entity.FolderEntity +import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.OutboxEntity import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity @@ -25,12 +28,14 @@ import org.libremail.data.sync.SendScheduler import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft import org.libremail.domain.model.Folder +import org.libremail.domain.model.FolderRole +import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.Message 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.repository.MailRepository -import org.libremail.mail.DownloadedAttachment import org.libremail.mail.ImapClient @Singleton @@ -86,9 +91,38 @@ class MailRepositoryImpl @Inject constructor( override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result = runCatching { val entity = messageDao.getById(messageId) ?: error("Message not found") + val meta = attachmentDao.getForMessage(messageId).firstOrNull { it.partIndex == partIndex } + val target = attachmentFile(messageId, partIndex, meta?.filename ?: "attachment") + // Reuse a previously downloaded (or pre-fetched) file so it opens instantly and offline. + if (target.exists() && target.length() > 0L) return@runCatching target val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found") val params = connectionFactory.imapParamsFor(account) - saveToCache(imapClient.fetchAttachment(params, entity.folder, uidOf(messageId), partIndex)) + val downloaded = imapClient.fetchAttachment(params, entity.folder, uidOf(messageId), partIndex) + target.parentFile?.mkdirs() + target.outputStream().use { it.write(downloaded.bytes) } + target + } + + override suspend fun downloadedAttachmentParts(messageId: String): Set = + attachmentDao.getForMessage(messageId) + .filter { val file = attachmentFile(messageId, it.partIndex, it.filename); file.exists() && file.length() > 0L } + .map { it.partIndex } + .toSet() + + override suspend fun prefetchMessage(messageId: String): Result = runCatching { + val entity = messageDao.getById(messageId) ?: return@runCatching + val account = accountDao.getById(entity.accountId)?.toDomain() ?: return@runCatching + val params = connectionFactory.imapParamsFor(account) + // Cache the body (peek, so prefetching never marks the message read) and its attachment metadata. + if (!entity.bodyFetched) { + val content = imapClient.fetchBodyPeek(params, entity.folder, uidOf(messageId)) + messageDao.updateBody(messageId, content.body, content.isHtml, snippetOf(content.body)) + attachmentDao.replaceForMessage(messageId, content.attachments.map { it.toEntity(messageId) }) + } + // Auto-download every attachment's bytes into the persistent per-part cache (skips ones present). + attachmentDao.getForMessage(messageId).forEach { attachment -> + downloadAttachment(messageId, attachment.partIndex) + } } override suspend fun setStarred(id: String, starred: Boolean): Result = runCatching { @@ -111,6 +145,101 @@ class MailRepositoryImpl @Inject constructor( } } + override suspend fun archive(ids: List): Result = + moveByRole(ids, FolderRole.ARCHIVE, fallbackExpunge = false) + + override suspend fun reportSpam(ids: List): Result = + moveByRole(ids, FolderRole.SPAM, fallbackExpunge = false) + + override suspend fun trash(ids: List): Result = + moveByRole(ids, FolderRole.TRASH, fallbackExpunge = true) + + override suspend fun expunge(ids: List): Result = runCatching { + val entities = ids.mapNotNull { messageDao.getById(it) } + messageDao.deleteByIds(ids) // optimistic + forEachAccountFolder(entities) { params, folder, group -> + group.forEach { imapClient.deleteMessage(params, folder, uidOf(it.id)) } + } + } + + override suspend fun moveToFolder(ids: List, destFolderFullName: String): Result = runCatching { + val entities = ids.mapNotNull { messageDao.getById(it) } + messageDao.deleteByIds(ids) // optimistic + forEachAccountFolder(entities) { params, folder, group -> + if (folder != destFolderFullName) { + imapClient.moveMessages(params, folder, group.map { uidOf(it.id) }, destFolderFullName) + } + } + } + + override suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result = runCatching { + val entity = messageDao.getById(messageId) ?: error("Message not found") + val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found") + val params = connectionFactory.imapParamsFor(account) + val context = imapClient.fetchForReply(params, entity.folder, uidOf(messageId)) + val content = ReplyBuilder.build(context, mode, account.email) + val draftId = UUID.randomUUID().toString() + saveDraft( + Draft( + id = draftId, + accountId = entity.accountId, + to = content.to, + cc = content.cc, + subject = content.subject, + body = content.body, + updatedAt = System.currentTimeMillis(), + attachments = emptyList(), + ), + ) + draftId + } + + /** + * Moves messages to each account's folder for [role], resolving the destination per account so the + * unified inbox works. Removes local rows first (optimistic; reconciled on the next sync). When the + * role folder is missing: trash falls back to a permanent expunge ([fallbackExpunge]); others fail. + */ + private suspend fun moveByRole(ids: List, role: FolderRole, fallbackExpunge: Boolean): Result = + runCatching { + val entities = ids.mapNotNull { messageDao.getById(it) } + messageDao.deleteByIds(ids) // optimistic + val destByAccount = entities.map { it.accountId }.distinct() + .associateWith { resolveRoleFolder(it, role) } + forEachAccountFolder(entities) { params, folder, group -> + when (val dest = destByAccount[group.first().accountId]) { + null -> + if (fallbackExpunge) { + group.forEach { imapClient.deleteMessage(params, folder, uidOf(it.id)) } + } else { + error("No ${role.name.lowercase()} folder for this account") + } + else -> imapClient.moveMessages(params, folder, group.map { uidOf(it.id) }, dest) + } + } + } + + /** Groups [entities] by account then source folder and runs [block] once per (account, folder) group. */ + private suspend fun forEachAccountFolder( + entities: List, + block: suspend (params: ImapConnectionParams, folder: String, group: List) -> Unit, + ) { + entities.groupBy { it.accountId }.forEach { (accountId, accountMessages) -> + val account = accountDao.getById(accountId)?.toDomain() ?: return@forEach + val params = connectionFactory.imapParamsFor(account) + accountMessages.groupBy { it.folder }.forEach { (folder, group) -> block(params, folder, group) } + } + } + + /** Resolves the full name of an account's folder for [role], refreshing the cache once if needed. */ + private suspend fun resolveRoleFolder(accountId: String, role: FolderRole): String? { + fun pick(folders: List) = + folders.firstOrNull { it.role == role.name && it.selectable }?.fullName + pick(folderDao.getForAccountOnce(accountId))?.let { return it } + // The folder cache can be cold (the user may not have opened the drawer yet); refresh and retry. + runCatching { refreshFolders(accountId) } + return pick(folderDao.getForAccountOnce(accountId)) + } + /** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */ override suspend fun sendMessage(outgoing: OutgoingMessage): Result = runCatching { requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" } @@ -194,13 +323,15 @@ class MailRepositoryImpl @Inject constructor( override suspend fun clearSearchResults() = messageDao.deleteSearchRows() - /** Writes downloaded bytes to a private cache file that the FileProvider can share. */ - private fun saveToCache(attachment: DownloadedAttachment): File { - val dir = File(context.cacheDir, "attachments").apply { mkdirs() } - val safeName = attachment.filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" } - return File(dir, safeName).also { file -> - file.outputStream().use { it.write(attachment.bytes) } - } + /** + * Deterministic cache path for one attachment part, under the FileProvider-shared `attachments/` + * dir. Keying by message id + part index lets prefetch and on-demand download share the same file + * and avoids filename collisions between messages. + */ + private fun attachmentFile(messageId: String, partIndex: Int, filename: String): File { + val safeId = messageId.replace(Regex("[^A-Za-z0-9._-]"), "_") + val safeName = filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" } + return File(context.cacheDir, "attachments/$safeId/$partIndex/$safeName") } } diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt index a14585d..e2c4d80 100644 --- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -6,6 +6,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -16,6 +17,14 @@ import kotlinx.coroutines.flow.map private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "libremail_settings") +/** + * How aggressively the app downloads message content during sync. + * - [ALWAYS]: fetch full bodies and all attachments on any connection (default). + * - [WIFI_ONLY]: fetch full content only on an unmetered network; otherwise behave like [ON_DEMAND]. + * - [ON_DEMAND]: sync headers only; fetch a message's body/attachments lazily when it's opened. + */ +enum class FetchPolicy { ALWAYS, WIFI_ONLY, ON_DEMAND } + /** User preferences. Only [dynamicColor] and [newMailNotifications] are wired to behaviour so far. */ data class AppSettings( val dynamicColor: Boolean = true, @@ -24,6 +33,7 @@ data class AppSettings( val allowStartTls: Boolean = false, val loadRemoteImages: Boolean = false, val encryptCache: Boolean = false, + val fetchPolicy: FetchPolicy = FetchPolicy.ALWAYS, ) @Singleton @@ -38,6 +48,8 @@ class SettingsRepository @Inject constructor( allowStartTls = prefs[ALLOW_STARTTLS] ?: false, loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false, encryptCache = prefs[ENCRYPT_CACHE] ?: false, + fetchPolicy = prefs[FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() } + ?: FetchPolicy.ALWAYS, ) } @@ -45,12 +57,17 @@ class SettingsRepository @Inject constructor( suspend fun isNewMailNotificationsEnabled(): Boolean = settings.first().newMailNotifications + suspend fun fetchPolicy(): FetchPolicy = settings.first().fetchPolicy + suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value) suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value) suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value) suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value) suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value) suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, value) + suspend fun setFetchPolicy(value: FetchPolicy) { + context.settingsDataStore.edit { it[FETCH_POLICY] = value.name } + } private suspend fun put(key: Preferences.Key, value: Boolean) { context.settingsDataStore.edit { it[key] = value } @@ -63,5 +80,6 @@ class SettingsRepository @Inject constructor( val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls") val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images") val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache") + val FETCH_POLICY = stringPreferencesKey("fetch_policy") } } diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt index 86ae9fc..7712015 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -1,9 +1,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.sync +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -11,21 +17,25 @@ import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity +import org.libremail.data.settings.FetchPolicy import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Account +import org.libremail.domain.repository.MailRepository import org.libremail.mail.ImapClient import org.libremail.notifications.MailNotifier /** Fetches account folders' headers into Room and notifies about newly-arrived inbox mail. */ @Singleton class MailSyncer @Inject constructor( + @ApplicationContext private val context: Context, private val accountDao: AccountDao, private val messageDao: MessageDao, private val imapClient: ImapClient, private val connectionFactory: MailConnectionFactory, private val settingsRepository: SettingsRepository, private val notifier: MailNotifier, -) { + private val mailRepository: MailRepository, +) : Syncer { // Serializes all syncing: syncAll/syncAccount/syncFolder are invoked concurrently by the periodic // worker, pull-to-refresh, one-shot syncs, folder opens, and one IDLE watcher per account. Without // this, two runs can both compute the same message as "new" (double-notify) or let a stale @@ -33,38 +43,46 @@ class MailSyncer @Inject constructor( private val syncMutex = Mutex() /** Syncs every account's inbox. Succeeds if at least one account synced (or there are none). */ - suspend fun syncAll(): Result = syncMutex.withLock { - val accounts = accountDao.getAll() - if (accounts.isEmpty()) return@withLock Result.success(0) + override suspend fun syncAll(): Result { + val accounts = accountDao.getAll().map { it.toDomain() } + if (accounts.isEmpty()) return Result.success(0) - var total = 0 - var firstError: Throwable? = null - var anySuccess = false - for (entity in accounts) { - syncFolderInternal(entity.toDomain(), INBOX, notify = true).fold( - onSuccess = { fetched -> - total += fetched - anySuccess = true - }, - onFailure = { error -> if (firstError == null) firstError = error }, - ) + val result = syncMutex.withLock { + var total = 0 + var firstError: Throwable? = null + var anySuccess = false + for (account in accounts) { + syncFolderHeaders(account, INBOX, notify = true).fold( + onSuccess = { fetched -> + total += fetched + anySuccess = true + }, + onFailure = { error -> if (firstError == null) firstError = error }, + ) + } + if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError) } - if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError) + if (result.isSuccess) accounts.forEach { prefetchIfEnabled(it, INBOX) } + return result } /** Syncs one account's inbox — used by the per-account IDLE watcher so a push doesn't re-sync all. */ - suspend fun syncAccount(accountId: String): Result = syncMutex.withLock { - val entity = accountDao.getById(accountId) ?: return@withLock Result.success(0) - syncFolderInternal(entity.toDomain(), INBOX, notify = true) + override suspend fun syncAccount(accountId: String): Result { + val account = accountDao.getById(accountId)?.toDomain() ?: return Result.success(0) + val result = syncMutex.withLock { syncFolderHeaders(account, INBOX, notify = true) } + if (result.isSuccess) prefetchIfEnabled(account, INBOX) + return result } /** Syncs one (account, folder) on demand — used when a folder is opened or refreshed (no notify). */ - suspend fun syncFolder(accountId: String, folder: String): Result = syncMutex.withLock { - val entity = accountDao.getById(accountId) ?: return@withLock Result.success(0) - syncFolderInternal(entity.toDomain(), folder, notify = false) + override suspend fun syncFolder(accountId: String, folder: String): Result { + val account = accountDao.getById(accountId)?.toDomain() ?: return Result.success(0) + val result = syncMutex.withLock { syncFolderHeaders(account, folder, notify = false) } + if (result.isSuccess) prefetchIfEnabled(account, folder) + return result } - private suspend fun syncFolderInternal(account: Account, folder: String, notify: Boolean): Result = + private suspend fun syncFolderHeaders(account: Account, folder: String, notify: Boolean): Result = runCatching { val params = connectionFactory.imapParamsFor(account) val fetched = imapClient.fetchRecent(params, folder, FETCH_LIMIT) // cancellable network I/O @@ -108,6 +126,31 @@ class MailSyncer @Inject constructor( fetched.size } + /** + * Aggressively pre-caches each not-yet-fetched message's full content (body + attachments) per the + * user's [FetchPolicy]. Runs outside [syncMutex] so these downloads don't block pull-to-refresh or + * other syncs, and is cancellable between messages so an IDLE renewal stops it promptly. + */ + private suspend fun prefetchIfEnabled(account: Account, folder: String) { + val shouldPrefetch = when (settingsRepository.fetchPolicy()) { + FetchPolicy.ALWAYS -> true + FetchPolicy.WIFI_ONLY -> isUnmetered() + FetchPolicy.ON_DEMAND -> false + } + if (!shouldPrefetch) return + for (id in messageDao.getUnfetchedIds(account.id, folder)) { + currentCoroutineContext().ensureActive() + mailRepository.prefetchMessage(id) // best-effort; swallows its own per-message failures + } + } + + /** True when the active network is unmetered (e.g. Wi-Fi), used by [FetchPolicy.WIFI_ONLY]. */ + private fun isUnmetered(): Boolean { + val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false + val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } + private companion object { const val INBOX = "INBOX" const val FETCH_LIMIT = 50 diff --git a/app/src/main/kotlin/org/libremail/data/sync/Syncer.kt b/app/src/main/kotlin/org/libremail/data/sync/Syncer.kt new file mode 100644 index 0000000..ae478d5 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/Syncer.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +/** + * Abstraction over mailbox syncing so UI callers (and their tests) depend on this rather than the + * concrete [MailSyncer], whose many collaborators make it impractical to fake. + */ +interface Syncer { + suspend fun syncAll(): Result + suspend fun syncAccount(accountId: String): Result + suspend fun syncFolder(accountId: String, folder: String): Result +} diff --git a/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt b/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt index 96622e6..54fc0eb 100644 --- a/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt +++ b/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt @@ -8,6 +8,8 @@ import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import org.libremail.data.repository.AccountRepositoryImpl import org.libremail.data.repository.MailRepositoryImpl +import org.libremail.data.sync.MailSyncer +import org.libremail.data.sync.Syncer import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @@ -22,4 +24,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindAccountRepository(impl: AccountRepositoryImpl): AccountRepository + + @Binds + @Singleton + abstract fun bindSyncer(impl: MailSyncer): Syncer } diff --git a/app/src/main/kotlin/org/libremail/domain/model/Message.kt b/app/src/main/kotlin/org/libremail/domain/model/Message.kt index e104b1c..d992710 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Message.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Message.kt @@ -17,4 +17,6 @@ data class Message( val folder: String = "INBOX", /** True for messages synced from a folder; false for transient server-search hits. */ val inInbox: Boolean = true, + /** True once the body has been cached locally, i.e. the message can be opened offline. */ + val bodyFetched: Boolean = false, ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/ReplyMode.kt b/app/src/main/kotlin/org/libremail/domain/model/ReplyMode.kt new file mode 100644 index 0000000..eb1318d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/ReplyMode.kt @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Which kind of compose a mailbox action produces from an existing message. */ +enum class ReplyMode { REPLY, REPLY_ALL, FORWARD } diff --git a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt index 5c1b4e6..38edcce 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -9,6 +9,7 @@ import org.libremail.domain.model.Folder import org.libremail.domain.model.Message import org.libremail.domain.model.OutboxMessage import org.libremail.domain.model.OutgoingMessage +import org.libremail.domain.model.ReplyMode /** * Abstraction over the local message cache (and, in later increments, network sync). @@ -34,10 +35,40 @@ interface MailRepository { /** Downloads an attachment's bytes to a local cache file and returns it. */ suspend fun downloadAttachment(messageId: String, partIndex: Int): Result + /** Part indexes of a message whose attachment bytes are already cached on disk (openable offline). */ + suspend fun downloadedAttachmentParts(messageId: String): Set + + /** + * Pre-caches a message's full content (body + every attachment's bytes) without marking it read. + * Used by aggressive sync so opening the message — and its attachments — is instant and works offline. + */ + suspend fun prefetchMessage(messageId: String): Result + suspend fun setStarred(id: String, starred: Boolean): Result suspend fun deleteMessage(id: String): Result + /** Moves messages to each account's Archive folder. Fails if an account has no archive folder. */ + suspend fun archive(ids: List): Result + + /** Moves messages to each account's Spam/Junk folder. Fails if an account has no spam folder. */ + suspend fun reportSpam(ids: List): Result + + /** Moves messages to each account's Trash folder, falling back to a permanent delete if there is none. */ + suspend fun trash(ids: List): Result + + /** Permanently deletes messages from their current folder (IMAP delete + expunge). */ + suspend fun expunge(ids: List): Result + + /** Moves messages to a specific destination folder (used by the explicit "Move" picker). */ + suspend fun moveToFolder(ids: List, destFolderFullName: String): Result + + /** + * Fetches the original message and builds a pre-filled reply/forward draft, returning its id so the + * caller can open the compose screen on that draft. + */ + suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result + suspend fun sendMessage(outgoing: OutgoingMessage): Result /** Drafts: composed-but-unsent messages saved for later. */ diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 87bb773..c0d29f1 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.eclipse.angus.mail.imap.IMAPFolder +import org.eclipse.angus.mail.imap.IMAPMessage import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity @@ -78,6 +79,17 @@ class DownloadedAttachment( val bytes: ByteArray, ) +/** The original message's fields needed to compose a reply, reply-all, or forward. */ +data class ReplyContext( + val fromEmail: String, + val toRecipients: List, + val ccRecipients: List, + val subject: String, + val sentDateMillis: Long, + val body: String, + val isHtml: Boolean, +) + /** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */ @Singleton class ImapClient @Inject constructor() { @@ -181,6 +193,27 @@ class ImapClient @Inject constructor() { } } + /** + * Fetches a message body + attachment metadata by UID **without** marking it \Seen (opens READ_ONLY + * and uses BODY.PEEK). Used to pre-cache content during sync so opening the message is instant. + */ + suspend fun fetchBodyPeek(params: ImapConnectionParams, folder: String, uid: String): MessageContent = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val mailbox = store.getFolder(folder) + mailbox.open(Folder.READ_ONLY) + try { + val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong()) + ?: error("Message $uid not found") + (message as? IMAPMessage)?.setPeek(true) + (extractBody(message) ?: MessageContent("", isHtml = false)) + .copy(attachments = collectAttachments(message)) + } finally { + runCatching { mailbox.close(false) } + } + } + } + /** Downloads the bytes of one attachment part (identified by its [partIndex]) from [folder]. */ suspend fun fetchAttachment( params: ImapConnectionParams, @@ -237,6 +270,65 @@ class ImapClient @Inject constructor() { } } + /** + * Moves messages from [sourceFolder] to [destFolder] by UID. Implemented as copy + \Deleted + + * expunge so it works on every IMAP server (no reliance on the RFC 6851 MOVE extension). + */ + suspend fun moveMessages( + params: ImapConnectionParams, + sourceFolder: String, + uids: List, + destFolder: String, + ) = withContext(Dispatchers.IO) { + if (uids.isEmpty()) return@withContext + withStore(params) { store -> + val mailbox = store.getFolder(sourceFolder) + mailbox.open(Folder.READ_WRITE) + try { + val uidFolder = mailbox as UIDFolder + val messages = uids.mapNotNull { uidFolder.getMessageByUID(it.toLong()) }.toTypedArray() + if (messages.isEmpty()) return@withStore + mailbox.copyMessages(messages, store.getFolder(destFolder)) + mailbox.setFlags(messages, Flags(Flags.Flag.DELETED), true) + mailbox.expunge() + } finally { + runCatching { mailbox.close(false) } + } + } + } + + /** + * Fetches the fields needed to compose a reply/forward (recipients + body) without marking the + * original \Seen (opens READ_ONLY), so building a reply doesn't change the message's read state. + */ + suspend fun fetchForReply(params: ImapConnectionParams, folder: String, uid: String): ReplyContext = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val mailbox = store.getFolder(folder) + mailbox.open(Folder.READ_ONLY) + try { + val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong()) + ?: error("Message $uid not found") + // BODY.PEEK: reading the body to quote it must not set the \Seen flag. + (message as? IMAPMessage)?.setPeek(true) + val from = message.from?.firstOrNull() as? InternetAddress + val content = extractBody(message) ?: MessageContent("", isHtml = false) + ReplyContext( + fromEmail = from?.address.orEmpty(), + toRecipients = message.recipientEmails(Message.RecipientType.TO), + ccRecipients = message.recipientEmails(Message.RecipientType.CC), + subject = message.subject ?: "", + sentDateMillis = (message.sentDate ?: message.receivedDate)?.time + ?: System.currentTimeMillis(), + body = content.body, + isHtml = content.isHtml, + ) + } finally { + runCatching { mailbox.close(false) } + } + } + } + /** * Holds a long-lived IMAP connection and uses IMAP IDLE to wait for server activity. The * server pushes new-mail notifications while [IMAPFolder.idle] blocks; Jakarta dispatches them @@ -352,6 +444,10 @@ class ImapClient @Inject constructor() { private fun baseType(part: Part): String = runCatching { ContentType(part.contentType).baseType }.getOrDefault("application/octet-stream") + /** The email addresses of the given recipient type, skipping any that aren't internet addresses. */ + private fun Message.recipientEmails(type: Message.RecipientType): List = + (getRecipients(type) ?: emptyArray()).mapNotNull { (it as? InternetAddress)?.address } + private fun Message.toFetchedMessage(uidFolder: UIDFolder): FetchedMessage { val from = from?.firstOrNull() as? InternetAddress return FetchedMessage( diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 550bbac..2b5c3c9 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -50,6 +50,7 @@ fun LibreMailApp() { onOpenDrafts = { navController.navigate(Routes.DRAFTS) }, onOpenOutbox = { navController.navigate(Routes.OUTBOX) }, onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) }, + onOpenCompose = { draftId -> navController.navigate(Routes.composeDraft(draftId)) }, onSelectTab = navController::navigateTab, ) } diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index de40b65..b3e8860 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -3,9 +3,12 @@ package org.libremail.ui.mailbox import android.text.format.DateUtils import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,14 +25,22 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -44,19 +55,23 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberDrawerState 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.rememberCoroutineScope +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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -67,7 +82,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.libremail.R import org.libremail.domain.model.Account +import org.libremail.domain.model.Folder +import org.libremail.domain.model.FolderRole import org.libremail.domain.model.Message +import org.libremail.domain.model.ReplyMode @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -77,6 +95,7 @@ fun MailboxScreen( onOpenDrafts: () -> Unit, onOpenOutbox: () -> Unit, onAddAccount: () -> Unit, + onOpenCompose: (String) -> Unit, onSelectTab: (org.libremail.ui.TopDest) -> Unit, viewModel: MailboxViewModel = hiltViewModel(), ) { @@ -93,12 +112,22 @@ fun MailboxScreen( val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() + val selectedIds by viewModel.selectedIds.collectAsStateWithLifecycle() + val pendingConfirm by viewModel.pendingConfirm.collectAsStateWithLifecycle() + val currentFolderRole by viewModel.currentFolderRole.collectAsStateWithLifecycle() + val canMove by viewModel.canMove.collectAsStateWithLifecycle() + val moveTargetFolders by viewModel.moveTargetFolders.collectAsStateWithLifecycle() + val actionInProgress by viewModel.actionInProgress.collectAsStateWithLifecycle() + val selectionMode = selectedIds.isNotEmpty() + var showMovePicker by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() BackHandler(enabled = searchActive) { viewModel.closeSearch() } BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } } + // Registered last so it takes priority: Back exits selection before closing search/drawer. + BackHandler(enabled = selectionMode) { viewModel.clearSelection() } LaunchedEffect(drawerState.isOpen) { if (drawerState.isOpen) viewModel.onDrawerOpened() } LaunchedEffect(error) { @@ -108,9 +137,17 @@ fun MailboxScreen( } } + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is MailboxEvent.OpenCompose -> onOpenCompose(event.draftId) + } + } + } + ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = drawerState.isOpen || (hasAccounts && !searchActive), + gesturesEnabled = drawerState.isOpen || (hasAccounts && !searchActive && !selectionMode), drawerContent = { ModalDrawerSheet { FolderDrawer( @@ -134,37 +171,54 @@ fun MailboxScreen( ) { Scaffold( topBar = { - TopAppBar( - title = { - if (searchActive) { - SearchField(query = searchQuery, onQueryChange = viewModel::onSearchQuery) - } else { - val current = folders.firstOrNull { it.fullName == selectedFolder } - Text( - if (current != null) folderDisplayLabel(current) - else stringResource(R.string.title_mailbox), - ) - } - }, - navigationIcon = { - if (searchActive) { - IconButton(onClick = viewModel::closeSearch) { - Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_close)) + if (selectionMode) { + SelectionTopBar( + count = selectedIds.size, + folderRole = currentFolderRole, + canMove = canMove, + onClose = viewModel::clearSelection, + onArchive = viewModel::archiveSelected, + onDelete = viewModel::requestDelete, + onSpam = viewModel::requestSpam, + onMove = { showMovePicker = true }, + onSelectAll = viewModel::selectAll, + onReply = { viewModel.reply(ReplyMode.REPLY) }, + onReplyAll = viewModel::requestReplyAll, + onForward = { viewModel.reply(ReplyMode.FORWARD) }, + ) + } else { + TopAppBar( + title = { + if (searchActive) { + SearchField(query = searchQuery, onQueryChange = viewModel::onSearchQuery) + } else { + val current = folders.firstOrNull { it.fullName == selectedFolder } + Text( + if (current != null) folderDisplayLabel(current) + else stringResource(R.string.title_mailbox), + ) } - } else if (hasAccounts) { - IconButton(onClick = { scope.launch { drawerState.open() } }) { - Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.drawer_open)) + }, + navigationIcon = { + if (searchActive) { + IconButton(onClick = viewModel::closeSearch) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_close)) + } + } else if (hasAccounts) { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.drawer_open)) + } } - } - }, - actions = { - if (hasAccounts && !searchActive) { - IconButton(onClick = viewModel::openSearch) { - Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search)) + }, + actions = { + if (hasAccounts && !searchActive) { + IconButton(onClick = viewModel::openSearch) { + Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search)) + } } - } - }, - ) + }, + ) + } }, bottomBar = { org.libremail.ui.LibreMailBottomBar( @@ -222,7 +276,12 @@ fun MailboxScreen( MessageRow( message = message, accountLabel = if (showAccount) accountsById[message.accountId]?.email else null, - onClick = { onOpenMessage(message.id) }, + selected = message.id in selectedIds, + onClick = { + if (selectionMode) viewModel.toggleSelection(message.id) + else onOpenMessage(message.id) + }, + onLongClick = { viewModel.startSelection(message.id) }, ) HorizontalDivider() } @@ -233,6 +292,40 @@ fun MailboxScreen( } } } + + pendingConfirm?.let { pending -> + ConfirmActionDialog( + pending = pending, + onConfirm = viewModel::confirmPending, + onDismiss = viewModel::dismissConfirm, + ) + } + if (showMovePicker) { + MoveFolderDialog( + folders = moveTargetFolders.filter { it.selectable && it.fullName != selectedFolder }, + onSelect = { folder -> + showMovePicker = false + viewModel.moveSelected(folder.fullName) + }, + onDismiss = { showMovePicker = false }, + ) + } + // Blocking spinner while a reply/forward fetches the latest message from the server. + if (actionInProgress) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } } } @@ -322,16 +415,24 @@ private fun AccountFilterRow(accounts: List, selectedId: String?, onSel } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun MessageRow(message: Message, accountLabel: String?, onClick: () -> Unit) { +private fun MessageRow( + message: Message, + accountLabel: String?, + selected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Avatar(message.sender) + if (selected) SelectedAvatar() else Avatar(message.sender) Spacer(Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -344,6 +445,15 @@ private fun MessageRow(message: Message, accountLabel: String?, onClick: () -> U modifier = Modifier.weight(1f), ) Spacer(Modifier.width(8.dp)) + if (message.bodyFetched) { + Icon( + Icons.Filled.Check, + contentDescription = stringResource(R.string.message_available_offline), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.width(4.dp)) + } Text( text = formatTimestamp(message.timestampMillis), style = MaterialTheme.typography.labelSmall, @@ -396,6 +506,157 @@ private fun Avatar(name: String) { } } +@Composable +private fun SelectedAvatar() { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary) + } +} + +/** + * The contextual action bar shown while messages are selected. Archive/Delete are the common actions; + * the overflow holds the rest. Reply/Reply All/Forward appear only for a single selected message, and + * Archive/Spam are hidden while already viewing that role's folder. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectionTopBar( + count: Int, + folderRole: FolderRole?, + canMove: Boolean, + onClose: () -> Unit, + onArchive: () -> Unit, + onDelete: () -> Unit, + onSpam: () -> Unit, + onMove: () -> Unit, + onSelectAll: () -> Unit, + onReply: () -> Unit, + onReplyAll: () -> Unit, + onForward: () -> Unit, +) { + TopAppBar( + title = { Text(stringResource(R.string.cab_selected_count, count)) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.cab_close)) + } + }, + actions = { + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) + } + var expanded by remember { mutableStateOf(false) } + IconButton(onClick = { expanded = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (folderRole != FolderRole.ARCHIVE) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_archive)) }, + onClick = { expanded = false; onArchive() }, + ) + } + if (folderRole != FolderRole.SPAM) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_spam)) }, + onClick = { expanded = false; onSpam() }, + ) + } + if (canMove) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_move)) }, + onClick = { expanded = false; onMove() }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.action_select_all)) }, + onClick = { expanded = false; onSelectAll() }, + ) + if (count == 1) { + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.action_reply)) }, + onClick = { expanded = false; onReply() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_reply_all)) }, + onClick = { expanded = false; onReplyAll() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_forward)) }, + onClick = { expanded = false; onForward() }, + ) + } + } + }, + ) +} + +@Composable +private fun ConfirmActionDialog(pending: PendingAction, onConfirm: () -> Unit, onDismiss: () -> Unit) { + val title: String + val text: String + val confirmLabel: String + when (pending) { + is PendingAction.Spam -> { + title = stringResource(R.string.confirm_spam_title) + text = stringResource(R.string.confirm_spam_text, pending.count) + confirmLabel = stringResource(R.string.action_move) + } + is PendingAction.Delete -> if (pending.permanent) { + title = stringResource(R.string.confirm_delete_title) + text = stringResource(R.string.confirm_delete_text, pending.count) + confirmLabel = stringResource(R.string.action_delete) + } else { + title = stringResource(R.string.confirm_trash_title) + text = stringResource(R.string.confirm_trash_text, pending.count) + confirmLabel = stringResource(R.string.action_move) + } + is PendingAction.ReplyAll -> { + title = stringResource(R.string.confirm_reply_all_title) + text = stringResource(R.string.confirm_reply_all_text) + confirmLabel = stringResource(R.string.action_reply_all) + } + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(text) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, + ) +} + +@Composable +private fun MoveFolderDialog(folders: List, onSelect: (Folder) -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.move_picker_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + folders.forEach { folder -> + Text( + text = folderDisplayLabel(folder), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(folder) } + .padding(vertical = 12.dp), + ) + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, + ) +} + @Composable private fun NoAccountState(onAddAccount: () -> Unit) { Column( diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index a84b53a..07da80a 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -18,13 +19,15 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.libremail.data.sync.MailSyncer +import org.libremail.data.sync.Syncer import org.libremail.domain.model.Account 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.repository.AccountRepository import org.libremail.domain.repository.MailRepository @@ -35,7 +38,7 @@ const val INBOX = "INBOX" class MailboxViewModel @Inject constructor( private val mailRepository: MailRepository, accountRepository: AccountRepository, - private val mailSyncer: MailSyncer, + private val mailSyncer: Syncer, ) : ViewModel() { val accounts: StateFlow> = accountRepository.observeAccounts() @@ -107,6 +110,126 @@ class MailboxViewModel @Inject constructor( private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + // --- Multi-select contextual action bar --- + + private val _selectedIds = MutableStateFlow>(emptySet()) + val selectedIds: StateFlow> = _selectedIds.asStateFlow() + + private val _pendingConfirm = MutableStateFlow(null) + val pendingConfirm: StateFlow = _pendingConfirm.asStateFlow() + + /** True while a reply/forward draft is being fetched and built (a brief network round-trip). */ + private val _actionInProgress = MutableStateFlow(false) + val actionInProgress: StateFlow = _actionInProgress.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + + /** The role of the folder currently shown — decides whether "Delete" trashes or permanently expunges. */ + val currentFolderRole: StateFlow = + combine(folders, _selectedFolder) { fs, sel -> fs.firstOrNull { it.fullName == sel }?.role } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** The single account every selected message belongs to, or null if the selection spans accounts. */ + private val selectionAccountId: StateFlow = + combine(_selectedIds, messages) { ids, msgs -> + msgs.filter { it.id in ids }.map { it.accountId }.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 = selectionAccountId + .map { it != null } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + /** Destination folders for the Move picker: the selection account's selectable folders. */ + val moveTargetFolders: StateFlow> = selectionAccountId + .flatMapLatest { acct -> if (acct == null) flowOf(emptyList()) else mailRepository.observeFolders(acct) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun startSelection(id: String) { + _selectedIds.value = setOf(id) + } + + fun toggleSelection(id: String) { + _selectedIds.value = _selectedIds.value.let { if (id in it) it - id else it + id } + } + + fun clearSelection() { + _selectedIds.value = emptySet() + } + + fun selectAll() { + _selectedIds.value = messages.value.map { it.id }.toSet() + } + + fun archiveSelected() = runOnSelection { mailRepository.archive(it) } + + fun moveSelected(destFolderFullName: String) = + runOnSelection { mailRepository.moveToFolder(it, destFolderFullName) } + + /** Reply and Forward open compose directly; Reply All is confirmed first (see [requestReplyAll]). */ + fun reply(mode: ReplyMode) { + val id = _selectedIds.value.singleOrNull() ?: return + buildReply(mode, id) + } + + // Confirmation-gated actions: the screen shows a dialog bound to [pendingConfirm], then [confirmPending]. + + fun requestSpam() { + if (_selectedIds.value.isEmpty()) return + _pendingConfirm.value = PendingAction.Spam(_selectedIds.value.size) + } + + fun requestDelete() { + if (_selectedIds.value.isEmpty()) return + val role = currentFolderRole.value + val permanent = role == FolderRole.TRASH || role == FolderRole.SPAM + _pendingConfirm.value = PendingAction.Delete(_selectedIds.value.size, permanent) + } + + fun requestReplyAll() { + val id = _selectedIds.value.singleOrNull() ?: return + _pendingConfirm.value = PendingAction.ReplyAll(id) + } + + fun confirmPending() { + when (val pending = _pendingConfirm.value) { + is PendingAction.Spam -> runOnSelection { mailRepository.reportSpam(it) } + is PendingAction.Delete -> + if (pending.permanent) runOnSelection { mailRepository.expunge(it) } + else runOnSelection { mailRepository.trash(it) } + is PendingAction.ReplyAll -> buildReply(ReplyMode.REPLY_ALL, pending.messageId) + null -> Unit + } + _pendingConfirm.value = null + } + + fun dismissConfirm() { + _pendingConfirm.value = null + } + + /** Snapshots the selection, exits selection mode, then runs [block]; failures surface via [error]. */ + private fun runOnSelection(block: suspend (List) -> Result) { + val ids = _selectedIds.value.toList() + if (ids.isEmpty()) return + clearSelection() + viewModelScope.launch { + block(ids).onFailure { _error.value = it.message ?: "Action failed" } + } + } + + private fun buildReply(mode: ReplyMode, messageId: String) { + clearSelection() + viewModelScope.launch { + _actionInProgress.value = true + mailRepository.buildReplyDraft(messageId, mode).fold( + onSuccess = { draftId -> _events.send(MailboxEvent.OpenCompose(draftId)) }, + onFailure = { _error.value = it.message ?: "Could not open compose" }, + ) + _actionInProgress.value = false + } + } + init { // Fall back to the unified inbox if the filtered account is removed. viewModelScope.launch { @@ -133,11 +256,13 @@ class MailboxViewModel @Inject constructor( } fun selectAccount(accountId: String?) { + clearSelection() _selectedAccountId.value = accountId } /** Browses a specific account's folder; syncs it from the server in the background. */ fun selectFolder(accountId: String, folderFullName: String) { + clearSelection() _selectedAccountId.value = accountId _drawerAccountId.value = accountId _selectedFolder.value = folderFullName @@ -146,6 +271,7 @@ class MailboxViewModel @Inject constructor( /** Returns to the unified inbox across all accounts. */ fun selectUnifiedInbox() { + clearSelection() _selectedAccountId.value = null _drawerAccountId.value = null _selectedFolder.value = INBOX @@ -164,6 +290,7 @@ class MailboxViewModel @Inject constructor( } fun openSearch() { + clearSelection() _searchActive.value = true } @@ -202,6 +329,18 @@ class MailboxViewModel @Inject constructor( } } +/** A destructive or notable CAB action awaiting user confirmation. */ +sealed interface PendingAction { + data class Spam(val count: Int) : PendingAction + data class Delete(val count: Int, val permanent: Boolean) : PendingAction + data class ReplyAll(val messageId: String) : PendingAction +} + +/** One-shot effects the mailbox screen acts on. */ +sealed interface MailboxEvent { + data class OpenCompose(val draftId: String) : MailboxEvent +} + /** Guarantees an inbox entry so the drawer is usable before the first folder-list refresh completes. */ private fun withInbox(accountId: String, folders: List): List = if (folders.any { it.fullName.equals(INBOX, ignoreCase = true) }) { diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt index 89fbf94..1967617 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll 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.Star import androidx.compose.material3.CircularProgressIndicator @@ -133,6 +134,7 @@ fun ReaderScreen( message = message, attachments = state.attachments, downloading = state.downloading, + downloaded = state.downloaded, onDownloadAttachment = viewModel::downloadAttachment, loadRemoteImages = state.loadRemoteImages, onLoadRemoteImages = viewModel::loadRemoteImages, @@ -151,6 +153,7 @@ private fun MessageBody( message: Message, attachments: List, downloading: Set, + downloaded: Set, onDownloadAttachment: (Attachment) -> Unit, loadRemoteImages: Boolean, onLoadRemoteImages: () -> Unit, @@ -160,7 +163,7 @@ private fun MessageBody( Header(message) HorizontalDivider() if (attachments.isNotEmpty()) { - Attachments(attachments, downloading, onDownloadAttachment) + Attachments(attachments, downloading, downloaded, onDownloadAttachment) HorizontalDivider() } if (message.isHtml && !loadRemoteImages) { @@ -195,6 +198,7 @@ private fun MessageBody( private fun Attachments( attachments: List, downloading: Set, + downloaded: Set, onDownload: (Attachment) -> Unit, ) { Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { @@ -208,6 +212,7 @@ private fun Attachments( AttachmentRow( attachment = attachment, downloading = attachment.partIndex in downloading, + downloaded = attachment.partIndex in downloaded, onClick = { onDownload(attachment) }, ) Spacer(Modifier.height(8.dp)) @@ -216,7 +221,7 @@ private fun Attachments( } @Composable -private fun AttachmentRow(attachment: Attachment, downloading: Boolean, onClick: () -> Unit) { +private fun AttachmentRow(attachment: Attachment, downloading: Boolean, downloaded: Boolean, onClick: () -> Unit) { Surface( shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant, @@ -259,6 +264,14 @@ private fun AttachmentRow(attachment: Attachment, downloading: Boolean, onClick: ) } } + if (downloaded) { + Spacer(Modifier.width(8.dp)) + Icon( + Icons.Filled.Check, + contentDescription = stringResource(R.string.attachment_downloaded), + tint = MaterialTheme.colorScheme.primary, + ) + } } } } diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt index c80efa3..6f3f09e 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt @@ -26,6 +26,8 @@ data class ReaderUiState( val message: Message? = null, val attachments: List = emptyList(), val downloading: Set = emptySet(), + /** Part indexes whose bytes are already cached on disk (openable offline). */ + val downloaded: Set = emptySet(), val loadRemoteImages: Boolean = false, val deleted: Boolean = false, val error: String? = null, @@ -67,7 +69,7 @@ class ReaderViewModel @Inject constructor( } viewModelScope.launch { repository.observeAttachments(messageId).collect { attachments -> - _state.update { it.copy(attachments = attachments) } + _state.update { it.copy(attachments = attachments, downloaded = repository.downloadedAttachmentParts(messageId)) } } } } @@ -78,6 +80,7 @@ class ReaderViewModel @Inject constructor( viewModelScope.launch { repository.downloadAttachment(attachment.messageId, attachment.partIndex).fold( onSuccess = { file -> + _state.update { it.copy(downloaded = it.downloaded + attachment.partIndex) } _events.send(ReaderEvent.OpenFile(file, attachment.mimeType, attachment.filename)) }, onFailure = { _events.send(ReaderEvent.DownloadFailed(attachment.filename)) }, diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index f857a58..77b2d88 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -33,6 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R +import org.libremail.data.settings.FetchPolicy import org.libremail.ui.LibreMailBottomBar import org.libremail.ui.TopDest @@ -91,6 +93,27 @@ fun SettingsScreen( ) HorizontalDivider() + SectionHeader(stringResource(R.string.settings_downloading)) + RadioRow( + title = stringResource(R.string.fetch_always), + subtitle = stringResource(R.string.fetch_always_summary), + selected = settings.fetchPolicy == FetchPolicy.ALWAYS, + onClick = { viewModel.setFetchPolicy(FetchPolicy.ALWAYS) }, + ) + RadioRow( + title = stringResource(R.string.fetch_wifi), + subtitle = stringResource(R.string.fetch_wifi_summary), + selected = settings.fetchPolicy == FetchPolicy.WIFI_ONLY, + onClick = { viewModel.setFetchPolicy(FetchPolicy.WIFI_ONLY) }, + ) + RadioRow( + title = stringResource(R.string.fetch_on_demand), + subtitle = stringResource(R.string.fetch_on_demand_summary), + selected = settings.fetchPolicy == FetchPolicy.ON_DEMAND, + onClick = { viewModel.setFetchPolicy(FetchPolicy.ON_DEMAND) }, + ) + HorizontalDivider() + AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced) AnimatedVisibility(visible = advancedExpanded) { Column { @@ -174,6 +197,30 @@ private fun SwitchRow( } } +@Composable +private fun RadioRow(title: String, subtitle: String?, selected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onClick) + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun ClickRow(title: String, onClick: () -> Unit) { Row( diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt index b629774..341f22c 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.FetchPolicy import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Account import org.libremail.domain.repository.AccountRepository @@ -42,6 +43,7 @@ class SettingsViewModel @Inject constructor( fun setAllowStartTls(value: Boolean) = update { settingsRepository.setAllowStartTls(value) } fun setLoadRemoteImages(value: Boolean) = update { settingsRepository.setLoadRemoteImages(value) } fun setEncryptCache(value: Boolean) = update { settingsRepository.setEncryptCache(value) } + fun setFetchPolicy(value: FetchPolicy) = update { settingsRepository.setFetchPolicy(value) } private inline fun update(crossinline action: suspend () -> Unit) { viewModelScope.launch { action() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d76a29..66dca97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,29 @@ Welcome to LibreMail Add an account to start reading your mail. + + %1$d selected + Clear selection + Archive + Delete + Spam + Move + Reply + Reply all + Forward + Select all + More options + Move to + Cancel + Move to Spam? + Move %1$d message(s) to the Spam folder? + Move to Trash? + Move %1$d message(s) to Trash? + Delete permanently? + This permanently deletes %1$d message(s) and can\'t be undone. + Reply to all? + Reply to everyone included on this message? + Show folders Switch account @@ -74,6 +97,8 @@ Reply Attachments + Downloaded + Available offline Couldn\'t download %1$s No app can open this file @@ -105,6 +130,13 @@ Accounts + Message downloading + Always fetch all + Download full messages and attachments as they sync, on any connection. + Fetch all on Wi-Fi + Download full messages and attachments only on Wi-Fi or other unmetered networks; otherwise fetch them when you open a message. + Always on-demand + Sync only message headers; download a message\'s content when you open it. Notifications New-mail notifications Show a notification when new mail arrives diff --git a/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt new file mode 100644 index 0000000..1b551db --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data + +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test +import org.libremail.domain.model.ReplyMode +import org.libremail.mail.ReplyContext + +class ReplyBuilderTest { + + private fun context( + from: String = "alice@example.org", + to: List = listOf("me@example.org"), + cc: List = emptyList(), + subject: String = "Lunch", + body: String = "Original body", + isHtml: Boolean = false, + ) = ReplyContext( + fromEmail = from, + toRecipients = to, + ccRecipients = cc, + subject = subject, + sentDateMillis = 0L, + body = body, + isHtml = isHtml, + ) + + @Test + fun `reply targets the sender and quotes the original`() { + val result = ReplyBuilder.build(context(), ReplyMode.REPLY, selfEmail = "me@example.org") + + assertEquals("alice@example.org", result.to) + assertEquals("", result.cc) + assertEquals("Re: Lunch", result.subject) + assertTrue(result.body.contains("Original body"), "body=${result.body}") + } + + @Test + fun `reply does not stack a second Re prefix`() { + val result = ReplyBuilder.build(context(subject = "Re: Lunch"), ReplyMode.REPLY, "me@example.org") + + assertEquals("Re: Lunch", result.subject) + } + + @Test + fun `reply all ccs everyone except self and the original sender`() { + val result = ReplyBuilder.build( + context( + from = "alice@example.org", + to = listOf("me@example.org", "bob@example.org"), + cc = listOf("carol@example.org", "alice@example.org"), + ), + ReplyMode.REPLY_ALL, + selfEmail = "me@example.org", + ) + + assertEquals("alice@example.org", result.to) + assertEquals(setOf("bob@example.org", "carol@example.org"), result.cc.split(", ").toSet()) + } + + @Test + fun `reply all excludes self case-insensitively`() { + val result = ReplyBuilder.build( + context(to = listOf("ME@Example.org")), + ReplyMode.REPLY_ALL, + selfEmail = "me@example.org", + ) + + assertEquals("", result.cc) + } + + @Test + fun `forward clears recipients, prefixes Fwd, and includes the original`() { + val result = ReplyBuilder.build(context(), ReplyMode.FORWARD, "me@example.org") + + assertEquals("", result.to) + assertEquals("Fwd: Lunch", result.subject) + assertTrue(result.body.contains("Forwarded message"), "body=${result.body}") + assertTrue(result.body.contains("Original body"), "body=${result.body}") + } + + @Test + fun `forward lists the original recipients in the quoted header`() { + val result = ReplyBuilder.build( + context(to = listOf("ada@example.org", "team@example.org")), + ReplyMode.FORWARD, + selfEmail = "me@example.org", + ) + + assertTrue(result.body.contains("ada@example.org"), "body=${result.body}") + assertTrue(result.body.contains("team@example.org"), "body=${result.body}") + } + + @Test + fun `html bodies are stripped to text when quoting`() { + val result = ReplyBuilder.build( + context(body = "

Hello there

", isHtml = true), + ReplyMode.REPLY, + "me@example.org", + ) + + assertFalse(result.body.contains("<"), "body=${result.body}") + assertTrue(result.body.contains("Hello"), "body=${result.body}") + assertTrue(result.body.contains("there"), "body=${result.body}") + } +} diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index 8a89917..e792ead 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.repository +import android.content.Context import app.cash.turbine.test import io.mockk.Runs import io.mockk.coEvery @@ -8,6 +9,9 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.slot +import java.io.File +import java.nio.file.Files import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.flow.flowOf @@ -15,9 +19,12 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao +import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.FolderDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.entity.AccountEntity +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.MessageEntity import org.libremail.data.local.entity.ServerConfigEmbedded @@ -25,8 +32,12 @@ import org.libremail.data.sync.MailConnectionFactory import org.libremail.domain.model.FolderRole import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ReplyMode +import org.libremail.mail.AttachmentPart +import org.libremail.mail.DownloadedAttachment import org.libremail.mail.ImapClient import org.libremail.mail.MessageContent +import org.libremail.mail.ReplyContext class MailRepositoryImplTest { @@ -34,15 +45,18 @@ class MailRepositoryImplTest { private val accountDao = mockk() private val folderDao = mockk() private val attachmentDao = mockk(relaxed = true) - private val imapClient = mockk() + private val draftDao = mockk() + // Relaxed: move/delete return jakarta Message[] (from Folder.expunge), which we only verify, not stub. + private val imapClient = mockk(relaxed = true) private val connectionFactory = mockk() + private val context = mockk(relaxed = true) private val repository = MailRepositoryImpl( - context = mockk(), + context = context, messageDao = messageDao, accountDao = accountDao, attachmentDao = attachmentDao, outboxDao = mockk(), - draftDao = mockk(), + draftDao = draftDao, folderDao = folderDao, imapClient = imapClient, connectionFactory = connectionFactory, @@ -102,9 +116,252 @@ class MailRepositoryImplTest { coVerify { imapClient.fetchBodyMarkingSeen(any(), "Archive", "5") } } - private fun messageEntity(id: String, folder: String) = MessageEntity( + @Test + fun `archive moves messages to the account's archive folder and drops the local rows`() = runTest { + val id = "acct:INBOX:5" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { messageDao.deleteByIds(listOf(id)) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { folderDao.getForAccountOnce("acct") } returns listOf(folderEntity("Archive", "ARCHIVE")) + + val result = repository.archive(listOf(id)) + + assertTrue(result.isSuccess) + coVerify { messageDao.deleteByIds(listOf(id)) } // optimistic local removal + coVerify { imapClient.moveMessages(any(), "INBOX", listOf("5"), "Archive") } + } + + @Test + fun `trash falls back to a permanent delete when the account has no trash folder`() = runTest { + val id = "acct:INBOX:7" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { messageDao.deleteByIds(any()) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + // No Trash folder, and a forced refresh doesn't add one. + coEvery { folderDao.getForAccountOnce("acct") } returns listOf(folderEntity("INBOX", "INBOX")) + coEvery { folderDao.replaceForAccount(any(), any()) } just Runs + coEvery { imapClient.listFolders(any()) } returns emptyList() + + val result = repository.trash(listOf(id)) + + assertTrue(result.isSuccess) + coVerify { imapClient.deleteMessage(any(), "INBOX", "7") } + } + + @Test + fun `archive fails when the account has no archive folder`() = runTest { + val id = "acct:INBOX:9" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { messageDao.deleteByIds(any()) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { folderDao.getForAccountOnce("acct") } returns listOf(folderEntity("INBOX", "INBOX")) + coEvery { folderDao.replaceForAccount(any(), any()) } just Runs + coEvery { imapClient.listFolders(any()) } returns emptyList() + + assertTrue(repository.archive(listOf(id)).isFailure) + } + + @Test + fun `expunge permanently deletes each message from its own folder`() = runTest { + val id = "acct:Trash:3" + coEvery { messageDao.getById(id) } returns messageEntity(id, "Trash") + coEvery { messageDao.deleteByIds(listOf(id)) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + + repository.expunge(listOf(id)) + + coVerify { imapClient.deleteMessage(any(), "Trash", "3") } + } + + @Test + fun `moveToFolder moves messages to the chosen destination`() = runTest { + val id = "acct:INBOX:11" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { messageDao.deleteByIds(listOf(id)) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + + repository.moveToFolder(listOf(id), "Receipts") + + coVerify { imapClient.moveMessages(any(), "INBOX", listOf("11"), "Receipts") } + } + + @Test + fun `buildReplyDraft fetches the original and saves a prefilled draft`() = runTest { + val id = "acct:INBOX:2" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { imapClient.fetchForReply(any(), "INBOX", "2") } returns ReplyContext( + fromEmail = "boss@example.org", + toRecipients = listOf("ada@example.org"), + ccRecipients = emptyList(), + subject = "Plan", + sentDateMillis = 0L, + body = "Original", + isHtml = false, + ) + val draft = slot() + coEvery { draftDao.upsert(capture(draft)) } just Runs + + val result = repository.buildReplyDraft(id, ReplyMode.REPLY) + + assertTrue(result.isSuccess) + assertEquals("boss@example.org", draft.captured.toAddresses) + assertEquals("Re: Plan", draft.captured.subject) + } + + @Test + fun `downloadAttachment reuses an already cached file without re-downloading`() = runTest { + val cache = Files.createTempDirectory("attach").toFile() + every { context.cacheDir } returns cache + val id = "acct:INBOX:4" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "report.pdf")) + val cached = File(cache, "attachments/acct_INBOX_4/0/report.pdf").apply { + parentFile?.mkdirs() + writeText("cached") + } + + val result = repository.downloadAttachment(id, 0) + + assertEquals(cached, result.getOrNull()) + coVerify(exactly = 0) { imapClient.fetchAttachment(any(), any(), any(), any()) } + } + + @Test + fun `downloadAttachment downloads and caches when no file exists yet`() = runTest { + val cache = Files.createTempDirectory("attach").toFile() + every { context.cacheDir } returns cache + val id = "acct:INBOX:6" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "a.txt")) + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { imapClient.fetchAttachment(any(), "INBOX", "6", 0) } returns + DownloadedAttachment("a.txt", "text/plain", "hello".toByteArray()) + + val result = repository.downloadAttachment(id, 0) + + assertTrue(result.isSuccess) + assertEquals("hello", result.getOrThrow().readText()) + } + + @Test + fun `downloadedAttachmentParts reports only the parts whose bytes exist on disk`() = runTest { + val cache = Files.createTempDirectory("attach").toFile() + every { context.cacheDir } returns cache + val id = "acct:INBOX:8" + coEvery { attachmentDao.getForMessage(id) } returns listOf( + attachmentEntity(id, 0, "have.bin"), + attachmentEntity(id, 1, "missing.bin"), + ) + File(cache, "attachments/acct_INBOX_8/0/have.bin").apply { + parentFile?.mkdirs() + writeText("x") + } + + assertEquals(setOf(0), repository.downloadedAttachmentParts(id)) + } + + @Test + fun `prefetchMessage caches the body and downloads attachments`() = runTest { + val cache = Files.createTempDirectory("attach").toFile() + every { context.cacheDir } returns cache + val id = "acct:INBOX:10" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") // bodyFetched = false + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { imapClient.fetchBodyPeek(any(), "INBOX", "10") } returns + MessageContent("Body", isHtml = false, attachments = listOf(AttachmentPart(0, "f.bin", "application/octet-stream", 3))) + coEvery { messageDao.updateBody(id, "Body", false, any()) } just Runs + coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "f.bin")) + coEvery { imapClient.fetchAttachment(any(), "INBOX", "10", 0) } returns + DownloadedAttachment("f.bin", "application/octet-stream", "abc".toByteArray()) + + val result = repository.prefetchMessage(id) + + assertTrue(result.isSuccess) + coVerify { messageDao.updateBody(id, "Body", false, any()) } + coVerify { imapClient.fetchAttachment(any(), "INBOX", "10", 0) } + } + + @Test + fun `archive resolves a separate destination folder for each account`() = runTest { + val id1 = "acct:INBOX:1" + val id2 = "acct2:INBOX:1" + coEvery { messageDao.getById(id1) } returns messageEntity(id1, "INBOX", accountId = "acct") + coEvery { messageDao.getById(id2) } returns messageEntity(id2, "INBOX", accountId = "acct2") + coEvery { messageDao.deleteByIds(any()) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity(id = "acct") + coEvery { accountDao.getById("acct2") } returns accountEntity(id = "acct2", email = "bob@example.org") + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { folderDao.getForAccountOnce("acct") } returns listOf(folderEntity("Archive", "ARCHIVE")) + coEvery { folderDao.getForAccountOnce("acct2") } returns listOf(folderEntity("All Mail", "ARCHIVE")) + + val result = repository.archive(listOf(id1, id2)) + + assertTrue(result.isSuccess) + coVerify { imapClient.moveMessages(any(), "INBOX", listOf("1"), "Archive") } + coVerify { imapClient.moveMessages(any(), "INBOX", listOf("1"), "All Mail") } + } + + @Test + fun `prefetchMessage skips the body fetch when it is already cached`() = runTest { + val cache = Files.createTempDirectory("attach").toFile() + every { context.cacheDir } returns cache + val id = "acct:INBOX:12" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX", bodyFetched = true) + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "f.bin")) + coEvery { imapClient.fetchAttachment(any(), "INBOX", "12", 0) } returns + DownloadedAttachment("f.bin", "application/octet-stream", "x".toByteArray()) + + repository.prefetchMessage(id) + + coVerify(exactly = 0) { imapClient.fetchBodyPeek(any(), any(), any()) } + coVerify { imapClient.fetchAttachment(any(), "INBOX", "12", 0) } + } + + @Test + fun `archive refreshes folders once and retries when the archive folder is initially missing`() = runTest { + val id = "acct:INBOX:14" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { messageDao.deleteByIds(any()) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { folderDao.getForAccountOnce("acct") } returnsMany listOf( + listOf(folderEntity("INBOX", "INBOX")), // cold cache: no archive yet + listOf(folderEntity("INBOX", "INBOX"), folderEntity("Archive", "ARCHIVE")), // after a refresh + ) + coEvery { folderDao.replaceForAccount(any(), any()) } just Runs + coEvery { imapClient.listFolders(any()) } returns emptyList() + + val result = repository.archive(listOf(id)) + + assertTrue(result.isSuccess) + coVerify { imapClient.moveMessages(any(), "INBOX", listOf("14"), "Archive") } + } + + private fun folderEntity(fullName: String, role: String) = + FolderEntity("acct", fullName, fullName.substringAfterLast('/'), role, selectable = true, sortOrder = 0) + + private fun attachmentEntity(messageId: String, partIndex: Int, filename: String) = + AttachmentEntity(messageId, partIndex, filename, "application/octet-stream", 10L) + + private fun messageEntity( + id: String, + folder: String, + accountId: String = "acct", + bodyFetched: Boolean = false, + ) = MessageEntity( id = id, - accountId = "acct", + accountId = accountId, sender = "Ada", senderEmail = "ada@example.org", subject = "Hi", @@ -114,12 +371,12 @@ class MailRepositoryImplTest { isRead = false, isStarred = false, folder = folder, - bodyFetched = false, + bodyFetched = bodyFetched, ) - private fun accountEntity() = AccountEntity( - id = "acct", - email = "ada@example.org", + private fun accountEntity(id: String = "acct", email: String = "ada@example.org") = AccountEntity( + id = id, + email = email, displayName = "Ada", authType = "PASSWORD_IMAP", imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"), diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt new file mode 100644 index 0000000..0b82440 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.ServerConfigEmbedded +import org.libremail.data.settings.FetchPolicy +import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.repository.MailRepository +import org.libremail.mail.ImapClient +import org.libremail.notifications.MailNotifier + +class MailSyncerTest { + + private val account = AccountEntity( + id = "acct", + email = "a@example.org", + displayName = "A", + authType = "PASSWORD_IMAP", + imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"), + smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"), + ) + + /** A syncer whose header sync is a no-op (no server messages) so tests focus on the prefetch step. */ + private fun syncer( + policy: FetchPolicy, + mailRepository: MailRepository, + context: Context = mockk(relaxed = true), + ): MailSyncer { + val accountDao = mockk() + coEvery { accountDao.getById("acct") } returns account + val messageDao = mockk(relaxed = true) + coEvery { messageDao.getSyncedIds(any(), any()) } returns emptyList() + coEvery { messageDao.getUnfetchedIds("acct", "INBOX") } returns listOf("acct:INBOX:1") + val imapClient = mockk() + coEvery { imapClient.fetchRecent(any(), any(), any()) } returns emptyList() + val connectionFactory = mockk() + coEvery { connectionFactory.imapParamsFor(any()) } returns mockk() + val settingsRepository = mockk() + coEvery { settingsRepository.fetchPolicy() } returns policy + return MailSyncer( + context = context, + accountDao = accountDao, + messageDao = messageDao, + imapClient = imapClient, + connectionFactory = connectionFactory, + settingsRepository = settingsRepository, + notifier = mockk(relaxed = true), + mailRepository = mailRepository, + ) + } + + @Test + fun `ALWAYS policy prefetches unfetched messages after the header sync`() = runTest { + val repo = mockk() + coEvery { repo.prefetchMessage(any()) } returns Result.success(Unit) + + syncer(FetchPolicy.ALWAYS, repo).syncFolder("acct", "INBOX") + + coVerify { repo.prefetchMessage("acct:INBOX:1") } + } + + @Test + fun `ON_DEMAND policy never prefetches`() = runTest { + val repo = mockk(relaxed = true) + + syncer(FetchPolicy.ON_DEMAND, repo).syncFolder("acct", "INBOX") + + coVerify(exactly = 0) { repo.prefetchMessage(any()) } + } + + @Test + fun `WIFI_ONLY prefetches on an unmetered network`() = runTest { + val repo = mockk() + coEvery { repo.prefetchMessage(any()) } returns Result.success(Unit) + + syncer(FetchPolicy.WIFI_ONLY, repo, context = networkContext(unmetered = true)).syncFolder("acct", "INBOX") + + coVerify { repo.prefetchMessage("acct:INBOX:1") } + } + + @Test + fun `WIFI_ONLY does not prefetch on a metered network`() = runTest { + val repo = mockk(relaxed = true) + + syncer(FetchPolicy.WIFI_ONLY, repo, context = networkContext(unmetered = false)).syncFolder("acct", "INBOX") + + coVerify(exactly = 0) { repo.prefetchMessage(any()) } + } + + /** A context whose active network reports the given metered state via ConnectivityManager. */ + private fun networkContext(unmetered: Boolean): Context { + val capabilities = mockk() + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } returns unmetered + val network = mockk() + val manager = mockk() + every { manager.activeNetwork } returns network + every { manager.getNetworkCapabilities(network) } returns capabilities + return mockk(relaxed = true).also { + every { it.getSystemService(ConnectivityManager::class.java) } returns manager + } + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index 6b4c3cd..f4043ec 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -12,6 +12,7 @@ import jakarta.mail.internet.MimeMessage import java.util.Properties import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest import org.junit.After @@ -104,6 +105,47 @@ class ImapClientTest { assertTrue(names.any { it.equals("Archive", ignoreCase = true) }, "folders=$names") } + @Test + fun `moveMessages relocates a message to another folder`() = runTest { + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Move me", "Relocate this") + greenMail.waitForIncomingEmail(1) + appendMessage("Archive", "carol@example.org", "Seed", "Creates the Archive folder") + val uid = client.fetchRecent(params(), "INBOX", limit = 50).first { it.subject == "Move me" }.uid + + client.moveMessages(params(), "INBOX", listOf(uid), "Archive") + + val inbox = client.fetchRecent(params(), "INBOX", limit = 50).map { it.subject } + val archive = client.fetchRecent(params(), "Archive", limit = 50).map { it.subject } + assertFalse(inbox.contains("Move me"), "inbox=$inbox") + assertTrue(archive.contains("Move me"), "archive=$archive") + } + + @Test + fun `fetchForReply returns recipients and body without marking the message read`() = runTest { + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Question", "What time?") + greenMail.waitForIncomingEmail(1) + val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid + + val context = client.fetchForReply(params(), "INBOX", uid) + + assertEquals("bob@example.org", context.fromEmail) + assertTrue(context.toRecipients.contains("alice@example.org"), "to=${context.toRecipients}") + assertTrue(context.body.contains("What time?"), "body=${context.body}") + assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isRead, "should stay unread") + } + + @Test + fun `fetchBodyPeek returns body and attachments without marking the message read`() = runTest { + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Peek", "Peek body text") + greenMail.waitForIncomingEmail(1) + val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid + + val content = client.fetchBodyPeek(params(), "INBOX", uid) + + assertTrue(content.body.contains("Peek body text"), "body=${content.body}") + assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isRead, "should stay unread") + } + @Test fun `fetchRecent reads a non-inbox folder isolated from the inbox`() = runTest { GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Inbox subject", "In the inbox") diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt index 6abf8cd..42e06a5 100644 --- a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.mailbox +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk @@ -10,6 +11,7 @@ import kotlin.test.assertTrue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -26,6 +28,7 @@ import org.libremail.domain.model.Folder import org.libremail.domain.model.FolderRole 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.repository.AccountRepository import org.libremail.domain.repository.MailRepository @@ -147,13 +150,232 @@ class MailboxViewModelTest { assertTrue(vm.folders.value.none { it.fullName == "Archive" }, "alice's folders should no longer show") } + @Test + fun `toggle adds then removes a message from the selection`() = runTest(testDispatcher) { + val vm = createViewModel(accounts = listOf(alice), messages = emptyList()) + + vm.startSelection("a") + assertEquals(setOf("a"), vm.selectedIds.value) + vm.toggleSelection("b") + assertEquals(setOf("a", "b"), vm.selectedIds.value) + vm.toggleSelection("a") + assertEquals(setOf("b"), vm.selectedIds.value) + vm.clearSelection() + assertTrue(vm.selectedIds.value.isEmpty()) + } + + @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 {} } + + vm.selectAll() + + assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), vm.selectedIds.value) + } + + @Test + fun `delete from a normal folder confirms then moves to trash`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.trash(any()) } returns Result.success(Unit) + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")), + repo = repo, + ) + backgroundScope.launch { vm.messages.collect {} } + + vm.startSelection("imap:a:INBOX:1") + vm.requestDelete() + val pending = vm.pendingConfirm.value + assertTrue(pending is PendingAction.Delete && !pending.permanent) + + vm.confirmPending() + + coVerify { repo.trash(listOf("imap:a:INBOX:1")) } + assertTrue(vm.selectedIds.value.isEmpty()) + assertNull(vm.pendingConfirm.value) + } + + @Test + fun `delete from the spam folder warns and expunges permanently`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.expunge(any()) } returns Result.success(Unit) + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf(msg("imap:a:Spam:1", "imap:a", "Spam")), + folders = mapOf( + "imap:a" to listOf( + folder("imap:a", "INBOX", FolderRole.INBOX), + folder("imap:a", "Spam", FolderRole.SPAM), + ), + ), + repo = repo, + ) + backgroundScope.launch { vm.messages.collect {} } + backgroundScope.launch { vm.currentFolderRole.collect {} } + vm.selectFolder("imap:a", "Spam") + + vm.startSelection("imap:a:Spam:1") + vm.requestDelete() + val pending = vm.pendingConfirm.value + assertTrue(pending is PendingAction.Delete && pending.permanent) + + vm.confirmPending() + + coVerify { repo.expunge(listOf("imap:a:Spam:1")) } + } + + @Test + fun `spam acts only after the confirmation is accepted`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.reportSpam(any()) } returns Result.success(Unit) + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")), + repo = repo, + ) + + vm.startSelection("imap:a:INBOX:1") + vm.requestSpam() + assertTrue(vm.pendingConfirm.value is PendingAction.Spam) + coVerify(exactly = 0) { repo.reportSpam(any()) } + + vm.confirmPending() + coVerify(exactly = 1) { repo.reportSpam(listOf("imap:a:INBOX:1")) } + } + + @Test + fun `forward builds a draft and emits OpenCompose`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.FORWARD) } returns Result.success("draft1") + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")), + repo = repo, + ) + + vm.startSelection("imap:a:INBOX:1") + vm.reply(ReplyMode.FORWARD) + + assertEquals(MailboxEvent.OpenCompose("draft1"), vm.events.first()) + coVerify { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.FORWARD) } + } + + @Test + fun `archive delegates the selection to the repository and exits selection`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.archive(any()) } returns Result.success(Unit) + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")), + repo = repo, + ) + + vm.startSelection("imap:a:INBOX:1") + vm.archiveSelected() + + coVerify { repo.archive(listOf("imap:a:INBOX:1")) } + assertTrue(vm.selectedIds.value.isEmpty()) + } + + @Test + fun `reply opens a prefilled draft for the single selection`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.REPLY) } returns Result.success("d2") + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")), + repo = repo, + ) + + vm.startSelection("imap:a:INBOX:1") + vm.reply(ReplyMode.REPLY) + + assertEquals(MailboxEvent.OpenCompose("d2"), vm.events.first()) + } + + @Test + fun `dismissing a pending confirmation cancels the action`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")), + repo = repo, + ) + vm.startSelection("imap:a:INBOX:1") + + vm.requestSpam() + vm.dismissConfirm() + assertNull(vm.pendingConfirm.value) + + vm.confirmPending() // nothing pending — no-op + coVerify(exactly = 0) { repo.reportSpam(any()) } + } + + @Test + fun `move is enabled for a single-account selection and disabled across accounts`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice, bob), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:b:INBOX:1", "imap:b", "INBOX")), + ) + backgroundScope.launch { vm.messages.collect {} } + backgroundScope.launch { vm.canMove.collect {} } + + vm.startSelection("imap:a:INBOX:1") + assertEquals(true, vm.canMove.value) + + vm.toggleSelection("imap:b:INBOX:1") + assertEquals(false, vm.canMove.value) + } + + @Test + fun `move sends the selection to the chosen folder`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.moveToFolder(any(), any()) } returns Result.success(Unit) + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")), + repo = repo, + ) + + vm.startSelection("imap:a:INBOX:1") + vm.moveSelected("Receipts") + + coVerify { repo.moveToFolder(listOf("imap:a:INBOX:1"), "Receipts") } + assertTrue(vm.selectedIds.value.isEmpty()) + } + + @Test + fun `reply all confirms first, then opens a prefilled draft`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.REPLY_ALL) } returns Result.success("d1") + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")), + repo = repo, + ) + vm.startSelection("imap:a:INBOX:1") + + vm.requestReplyAll() + assertTrue(vm.pendingConfirm.value is PendingAction.ReplyAll) + coVerify(exactly = 0) { repo.buildReplyDraft(any(), any()) } + + vm.confirmPending() + assertEquals(MailboxEvent.OpenCompose("d1"), vm.events.first()) + coVerify { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.REPLY_ALL) } + } + private fun createViewModel( accounts: List, messages: List, folders: Map> = emptyMap(), syncer: MailSyncer = mockk(relaxed = true), + repo: MailRepository = mockk(relaxed = true), ): MailboxViewModel { - val repo = mockk(relaxed = true) every { repo.observeMessages() } returns MutableStateFlow(messages) every { repo.observeDrafts() } returns flowOf(emptyList()) every { repo.observeOutbox() } returns flowOf(emptyList()) diff --git a/app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelTest.kt new file mode 100644 index 0000000..873531e --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelTest.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reader + +import androidx.lifecycle.SavedStateHandle +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Message +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes + +@OptIn(ExperimentalCoroutinesApi::class) +class ReaderViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val messageId = "acct:INBOX:1" + + private val message = Message( + id = messageId, accountId = "acct", sender = "A", senderEmail = "a@example.org", + subject = "s", snippet = "", body = "Body", isHtml = false, timestampMillis = 1, + isRead = true, isStarred = false, + ) + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun attachment(partIndex: Int) = + Attachment(messageId, partIndex, "file$partIndex.bin", "application/octet-stream", 10L) + + private fun viewModel(repo: MailRepository): ReaderViewModel { + val settings = mockk() + every { settings.settings } returns flowOf(AppSettings()) + return ReaderViewModel(SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)), repo, settings) + } + + @Test + fun `downloaded set reflects the repository's cached parts on load`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.openMessage(messageId) } returns Result.success(message) + every { repo.observeAttachments(messageId) } returns flowOf(listOf(attachment(0), attachment(1))) + coEvery { repo.downloadedAttachmentParts(messageId) } returns setOf(0) + + val vm = viewModel(repo) + advanceUntilIdle() + + assertEquals(setOf(0), vm.state.value.downloaded) + } + + @Test + fun `downloading an attachment adds its part to the downloaded set`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.openMessage(messageId) } returns Result.success(message) + every { repo.observeAttachments(messageId) } returns flowOf(listOf(attachment(0))) + coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet() + coEvery { repo.downloadAttachment(messageId, 0) } returns Result.success(File("cached.bin")) + + val vm = viewModel(repo) + advanceUntilIdle() + vm.downloadAttachment(attachment(0)) + advanceUntilIdle() + + assertTrue(0 in vm.state.value.downloaded) + } +}