diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c8877f..5ccf7dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,9 @@ env: jobs: debug-build: name: Debug build - runs-on: ubuntu-latest + # arm64 Linux runner so the build toolchain matches real (arm64) Android devices. Emulator jobs + # stay on x86_64 (below) because arm64 emulators need macOS hosts and Linux/KVM is more reliable. + runs-on: ubuntu-24.04-arm steps: - name: Check out source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -54,7 +56,7 @@ jobs: unit-tests: name: Unit tests - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm steps: - name: Check out source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -85,16 +87,21 @@ jobs: path: app/build/reports/tests/testDebugUnitTest/ if-no-files-found: warn - instrumented-tests: - name: Instrumented / UI tests (emulator) + e2e: + name: E2E runs-on: ubuntu-latest strategy: fail-fast: false matrix: - # minSdk is 33; the instrumented suite (SQLCipher, Keystore, Room, Compose UI, - # mail providers) needs no API-37-specific behavior, so it runs on a stable, - # widely-available emulator image rather than the bleeding-edge android-37.0. - api-level: [35] + # Every Android API level across the rolling ~7-year support window: minSdk (29 / Android 10, + # 2019) through the latest stable. Each level boots its own emulator and runs the full + # instrumented + Compose UI (E2E) suite; all of them fan in to the "CI passed" gate. When a + # new Android ships, add it and drop the oldest level that has aged out of ~7 years. API 37 + # (preview) is NOT in this matrix because emulator-runner can't provision its nonstandard + # android-37.0 / google_apis_ps16k image (it would wedge the gate) — it's covered separately + # by the custom-provisioned, non-blocking `e2e-preview` job below. Keep in sync with + # testOptions.managedDevices in app/build.gradle.kts. + api-level: [29, 30, 31, 32, 33, 34, 35, 36] steps: - name: Check out source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -144,7 +151,7 @@ jobs: disable-animations: false script: echo "Generated AVD snapshot for caching." - - name: Run instrumented tests + - name: Run E2E tests uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 with: api-level: ${{ matrix.api-level }} @@ -155,10 +162,103 @@ jobs: disable-animations: true script: ./gradlew connectedDebugAndroidTest --stacktrace - - name: Upload instrumented test report + - name: Upload E2E test report if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: instrumented-test-report-api${{ matrix.api-level }} + name: e2e-test-report-api${{ matrix.api-level }} path: app/build/reports/androidTests/connected/ if-no-files-found: warn + + # API 37 (Android 17, preview) E2E. Its only system image is the nonstandard + # android-37.0 / google_apis_ps16k (16 KB page size), which reactivecircus/android-emulator-runner + # can't provision (it builds android-37 / google_apis, neither of which exists), so this job + # CUSTOM-PROVISIONS the emulator with sdkmanager/avdmanager/emulator directly. Deliberately + # NON-BLOCKING: it is NOT in the "CI passed" gate's needs and not a required status check, so a + # flaky preview can never block merges — it's signal only. When a stable, emulator-runner-friendly + # API 37 image ships, fold 37 into the main `e2e` matrix and delete this job. + e2e-preview: + name: E2E (API 37 preview, non-blocking) + runs-on: ubuntu-latest + timeout-minutes: 35 + env: + API37_IMAGE: "system-images;android-37.0;google_apis_ps16k;x86_64" + steps: + - name: Check out source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up JDK 21 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + # The hardware-accelerated emulator needs KVM, which is gated behind a udev rule. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # Cache the ~1 GB preview system image so only the first run pays the download. + - name: Cache API 37 system image + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + # GitHub-hosted ubuntu runners install the SDK at /usr/local/lib/android/sdk; caching the + # image dir (with its package metadata) lets sdkmanager treat it as installed and skip the + # re-download on a cache hit. + path: /usr/local/lib/android/sdk/system-images/android-37.0 + key: sysimg-android-37.0-google_apis_ps16k-x86_64 + + - name: Install SDK packages + preview system image + run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" "platform-tools" "emulator" "$API37_IMAGE" + + - name: Create API 37 AVD + run: echo "no" | avdmanager create avd -n api37 -k "$API37_IMAGE" -d pixel_2 --force + + - name: Boot emulator and run E2E + run: | + set -euo pipefail + "$ANDROID_SDK_ROOT/emulator/emulator" -avd api37 \ + -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot -camera-back none & + adb wait-for-device + timeout 300 bash -c 'while [ "$(adb shell getprop sys.boot_completed | tr -d "\r")" != "1" ]; do sleep 3; done' + adb shell input keyevent 82 || true + ./gradlew connectedDebugAndroidTest --stacktrace + + - name: Shut down emulator + if: always() + run: adb emu kill || true + + - name: Upload E2E (API 37) report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-test-report-api37-preview + path: app/build/reports/androidTests/connected/ + if-no-files-found: warn + + # Single aggregating gate so branch protection can require ALL CI jobs with one stable status + # check. It depends on every job — including each api-level of the E2E matrix — so adding/removing + # a matrix level needs no change to branch protection (the per-"(api-level)" check names would + # otherwise have to be re-listed each time). + ci-passed: + name: CI passed + if: always() + needs: [debug-build, unit-tests, e2e] + runs-on: ubuntu-latest + steps: + - name: Verify every required job succeeded + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + run: | + echo "Required CI jobs did not all succeed:" + echo " debug-build: ${{ needs.debug-build.result }}" + echo " unit-tests: ${{ needs.unit-tests.result }}" + echo " e2e: ${{ needs.e2e.result }}" + exit 1 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 96346ef..d7c08f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,7 +44,8 @@ android { defaultConfig { applicationId = "org.libremail.app" - minSdk = 33 + // Supports a rolling ~7-year window of Android versions (API 29 / Android 10, 2019 → latest). + minSdk = 29 targetSdk = 37 versionCode = 1 versionName = "0.1.0" @@ -104,6 +105,33 @@ android { ) } } + + testOptions { + // Gradle Managed Devices define the per-API E2E matrix as config-as-code: one virtual + // device per supported Android version (a rolling ~7-year window, API 29 → latest stable). + // Run the whole matrix with `./gradlew e2eGroupDebugAndroidTest`, or one level with e.g. + // `./gradlew api29DebugAndroidTest`. Gradle provisions/boots/tears down the emulators and + // downloads the system images on first use. Keep this list in lockstep with the CI matrix in + // .github/workflows/ci.yml; when a new Android ships, add it and drop the oldest level that + // has fallen outside ~7 years. API 37 (preview) is exercised on the dev emulator until a + // stable managed-device image is published, so it is intentionally not listed here. + managedDevices { + localDevices { + listOf(29, 30, 31, 32, 33, 34, 35, 36).forEach { api -> + create("api$api") { + device = "Pixel 2" + apiLevel = api + systemImageSource = "google_apis" + } + } + } + groups { + create("e2e") { + targetDevices.addAll(localDevices) + } + } + } + } } // Export Room schemas so migrations can be validated by instrumented MigrationTestHelper tests. diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/8.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/8.json new file mode 100644 index 0000000..c9b0eca --- /dev/null +++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/8.json @@ -0,0 +1,460 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "f00f0bf1851fb32d76dacee8558ae87a", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authType", + "columnName": "authType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imap.host", + "columnName": "imap_host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imap.port", + "columnName": "imap_port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imap.security", + "columnName": "imap_security", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smtp.host", + "columnName": "smtp_host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smtp.port", + "columnName": "smtp_port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "smtp.security", + "columnName": "smtp_security", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderEmail", + "columnName": "senderEmail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "snippet", + "columnName": "snippet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isHtml", + "columnName": "isHtml", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestampMillis", + "columnName": "timestampMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'INBOX'" + }, + { + "fieldPath": "inInbox", + "columnName": "inInbox", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bodyFetched", + "columnName": "bodyFetched", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_messages_timestampMillis", + "unique": false, + "columnNames": [ + "timestampMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)" + } + ] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSecret", + "columnName": "encryptedSecret", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + } + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "partIndex", + "columnName": "partIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageId", + "partIndex" + ] + }, + "indices": [ + { + "name": "index_attachments_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "messageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toAddresses", + "columnName": "toAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccAddresses", + "columnName": "ccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "drafts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT" + }, + { + "fieldPath": "toAddresses", + "columnName": "toAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccAddresses", + "columnName": "ccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectable", + "columnName": "selectable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "fullName" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f00f0bf1851fb32d76dacee8558ae87a')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt index 0c106c9..2347c15 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt @@ -14,6 +14,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.libremail.data.local.entity.AttachmentEntity +import org.libremail.data.local.entity.FolderEntity import org.libremail.data.local.entity.MessageEntity /** @@ -70,10 +71,54 @@ class LibreMailDatabaseTest { messageDao.insertNew(listOf(message("acct:1").copy(inInbox = true))) messageDao.insertNew(listOf(message("acct:2").copy(inInbox = false))) - assertEquals(listOf("acct:1"), messageDao.getInboxIdsForAccount("acct")) + assertEquals(listOf("acct:1"), messageDao.getSyncedIds("acct", "INBOX")) messageDao.deleteSearchRows() val remaining = messageDao.observeAll().first().map { it.id } assertEquals(listOf("acct:1"), remaining) } + + @Test + fun foldersAreStoredOrderedAndReplaceablePerAccount() = runBlocking { + val folderDao = db.folderDao() + folderDao.replaceForAccount( + "acct", + listOf( + FolderEntity("acct", "[Gmail]/Sent Mail", "Sent Mail", "SENT", selectable = true, sortOrder = 1), + FolderEntity("acct", "INBOX", "INBOX", "INBOX", selectable = true, sortOrder = 0), + ), + ) + // observeForAccount returns folders ordered by sortOrder. + assertEquals( + listOf("INBOX", "[Gmail]/Sent Mail"), + folderDao.observeForAccount("acct").first().map { it.fullName }, + ) + + // replaceForAccount swaps the whole set (delete + insert). + folderDao.replaceForAccount("acct", listOf(FolderEntity("acct", "Archive", "Archive", "ARCHIVE", true, 0))) + assertEquals(listOf("Archive"), folderDao.observeForAccount("acct").first().map { it.fullName }) + } + + @Test + fun syncReconcileIsScopedToASingleFolder() = runBlocking { + val messageDao = db.messageDao() + messageDao.insertNew( + listOf( + message("acct:INBOX:1").copy(folder = "INBOX"), + message("acct:INBOX:2").copy(folder = "INBOX"), + message("acct:Archive:1").copy(folder = "Archive"), + ), + ) + + // getSyncedIds is scoped to one folder. + assertEquals(setOf("acct:INBOX:1", "acct:INBOX:2"), messageDao.getSyncedIds("acct", "INBOX").toSet()) + assertEquals(listOf("acct:Archive:1"), messageDao.getSyncedIds("acct", "Archive")) + + // Reconciling the inbox must not touch other folders' rows. + messageDao.deleteSyncedNotIn("acct", "INBOX", listOf("acct:INBOX:1")) + assertEquals( + setOf("acct:INBOX:1", "acct:Archive:1"), + messageDao.observeAll().first().map { it.id }.toSet(), + ) + } } diff --git a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt index 98ff1f0..7f8c824 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.flowOf import org.libremail.domain.model.Account import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft +import org.libremail.domain.model.Folder import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.Message import org.libremail.domain.model.OutboxMessage @@ -66,6 +67,10 @@ class FakeMailRepository( override fun observeMessages(): Flow> = flowOf(emptyList()) + override fun observeFolders(accountId: String): Flow> = flowOf(emptyList()) + + override suspend fun refreshFolders(accountId: String): Result = Result.success(Unit) + override suspend fun getMessage(id: String): Message? = null override suspend fun openMessage(id: String): Result = @@ -103,7 +108,7 @@ class FakeMailRepository( override suspend fun retryOutbox() {} - override suspend fun searchServer(query: String) {} + override suspend fun searchServer(query: String, accountId: String?, folder: String) {} override suspend fun clearSearchResults() {} } diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt new file mode 100644 index 0000000..87516f6 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.mailbox + +import androidx.activity.ComponentActivity +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +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.ServerConfig +import org.libremail.ui.theme.LibreMailTheme + +/** UI test for the navigation drawer: folder rendering, friendly names, the account switcher, and taps. */ +@RunWith(AndroidJUnit4::class) +class FolderDrawerTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private val alice = account("imap:a", "alice@example.org") + private val bob = account("imap:b", "bob@example.org") + + @Test + fun singleAccount_rendersStandardFoldersWithFriendlyNames() { + setContent( + accounts = listOf(alice), + drawerAccount = alice, + folders = listOf( + folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX), + folder("imap:a", "[Gmail]/Sent Mail", "Sent Mail", FolderRole.SENT), + folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE), + folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL), + ), + ) + + composeTestRule.onNodeWithText(string(R.string.folder_inbox)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed() + // Standard folders use the friendly role name ("Sent"), not the raw server name ("Sent Mail"). + composeTestRule.onNodeWithText(string(R.string.folder_sent)).assertIsDisplayed() + composeTestRule.onNodeWithText("Sent Mail").assertDoesNotExist() + // Normal folders keep their server name. + composeTestRule.onNodeWithText("Receipts").assertIsDisplayed() + // A single account shows no account switcher / "All Inboxes" entry. + composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).assertDoesNotExist() + } + + @Test + fun tappingAFolder_reportsItsAccountAndFullName() { + var picked: Pair? = null + setContent( + accounts = listOf(alice), + drawerAccount = alice, + folders = listOf( + folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX), + folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE), + ), + onSelectFolder = { accountId, fullName -> picked = accountId to fullName }, + ) + + composeTestRule.onNodeWithText(string(R.string.folder_archive)).performClick() + + assertEquals("imap:a" to "Archive", picked) + } + + @Test + fun multipleAccounts_showSwitcherAndAllInboxesEntry() { + var unifiedTapped = false + setContent( + accounts = listOf(alice, bob), + drawerAccount = alice, + folders = listOf(folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX)), + onSelectUnifiedInbox = { unifiedTapped = true }, + ) + + // The switcher shows the active drawer account, and the unified entry is available. + composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).assertIsDisplayed() + + composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).performClick() + assertTrue(unifiedTapped) + } + + private fun setContent( + accounts: List, + drawerAccount: Account?, + folders: List, + selectedAccountId: String? = null, + selectedFolder: String = "INBOX", + onSelectUnifiedInbox: () -> Unit = {}, + onSelectFolder: (String, String) -> Unit = { _, _ -> }, + onSelectDrawerAccount: (String) -> Unit = {}, + ) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ModalDrawerSheet { + FolderDrawer( + accounts = accounts, + drawerAccount = drawerAccount, + folders = folders, + selectedAccountId = selectedAccountId, + selectedFolder = selectedFolder, + onSelectUnifiedInbox = onSelectUnifiedInbox, + onSelectFolder = onSelectFolder, + onSelectDrawerAccount = onSelectDrawerAccount, + ) + } + } + } + } + + private fun account(id: String, email: String) = Account( + id = id, + email = email, + displayName = email, + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) + + private fun folder(accountId: String, fullName: String, displayName: String, role: FolderRole) = + Folder(accountId, fullName, displayName, role, selectable = true) +} diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index 069fd6a..8c027f4 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -3,6 +3,7 @@ package org.libremail import android.Manifest import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult @@ -52,6 +53,9 @@ private fun NotificationPermissionEffect() { val context = LocalContext.current val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} LaunchedEffect(Unit) { + // POST_NOTIFICATIONS is a runtime permission only on Android 13 (API 33)+. On older + // versions notifications are enabled by default, so there's nothing to request. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS) diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt index 42dc24d..d538969 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -7,12 +7,14 @@ import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao 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.AccountEntity import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.CredentialEntity 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.OutboxEntity @@ -24,8 +26,9 @@ import org.libremail.data.local.entity.OutboxEntity AttachmentEntity::class, OutboxEntity::class, DraftEntity::class, + FolderEntity::class, ], - version = 7, + version = 8, exportSchema = true, ) abstract class LibreMailDatabase : RoomDatabase() { @@ -35,4 +38,5 @@ abstract class LibreMailDatabase : RoomDatabase() { abstract fun attachmentDao(): AttachmentDao abstract fun outboxDao(): OutboxDao abstract fun draftDao(): DraftDao + abstract fun folderDao(): FolderDao } 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 9dcb167..54e7ded 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -4,6 +4,7 @@ package org.libremail.data.local 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.OutboxEntity import org.libremail.data.local.entity.ServerConfigEmbedded @@ -14,6 +15,8 @@ import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft import org.libremail.domain.model.OutboxMessage import org.libremail.domain.model.AuthType +import org.libremail.domain.model.Folder +import org.libremail.domain.model.FolderRole import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity import org.libremail.domain.model.Message @@ -21,6 +24,7 @@ import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.ServerConfig import org.libremail.domain.model.SmtpParams import org.libremail.mail.AttachmentPart +import org.libremail.mail.FetchedFolder import org.libremail.mail.FetchedMessage internal fun AccountEntity.toDomain(): Account = Account( @@ -83,11 +87,16 @@ internal fun MessageEntity.toDomain(): Message = Message( timestampMillis = timestampMillis, isRead = isRead, isStarred = isStarred, + folder = folder, inInbox = inInbox, ) -internal fun FetchedMessage.toEntity(accountId: String, inInbox: Boolean = true): MessageEntity = MessageEntity( - id = "$accountId:$uid", +internal fun FetchedMessage.toEntity( + accountId: String, + folder: String, + inInbox: Boolean = true, +): MessageEntity = MessageEntity( + id = "$accountId:$folder:$uid", accountId = accountId, sender = sender, senderEmail = senderEmail, @@ -98,10 +107,28 @@ internal fun FetchedMessage.toEntity(accountId: String, inInbox: Boolean = true) timestampMillis = timestampMillis, isRead = isRead, isStarred = isFlagged, + folder = folder, inInbox = inInbox, bodyFetched = false, ) +internal fun FolderEntity.toDomain(): Folder = Folder( + accountId = accountId, + fullName = fullName, + displayName = displayName, + role = runCatching { FolderRole.valueOf(role) }.getOrDefault(FolderRole.NORMAL), + selectable = selectable, +) + +internal fun FetchedFolder.toEntity(accountId: String, sortOrder: Int): FolderEntity = FolderEntity( + accountId = accountId, + fullName = fullName, + displayName = displayName, + role = FolderRole.roleOf(fullName, displayName, attributes).name, + selectable = selectable, + sortOrder = sortOrder, +) + internal fun AttachmentEntity.toDomain(): Attachment = Attachment( messageId = messageId, partIndex = partIndex, diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index 6f1dcb7..1435a42 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -127,3 +127,21 @@ val MIGRATION_6_7 = object : Migration(6, 7) { db.execSQL("ALTER TABLE `drafts_new` RENAME TO `drafts`") } } + +/** + * v7 -> v8: folder-aware mail (preserves existing data). + * - `messages`: add a `folder` column (existing rows are inbox rows). The first post-upgrade INBOX + * sync reconciles ids, which now embed the folder ("accountId:folder:uid"). + * - add the `folders` table caching each account's IMAP folder list for the navigation drawer. + */ +val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `messages` ADD COLUMN `folder` TEXT NOT NULL DEFAULT 'INBOX'") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `folders` (" + + "`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, " + + "`role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, " + + "PRIMARY KEY(`accountId`, `fullName`))", + ) + } +} 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 new file mode 100644 index 0000000..6b4ee0a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/FolderDao.kt @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.libremail.data.local.entity.FolderEntity + +@Dao +interface FolderDao { + @Query("SELECT * FROM folders WHERE accountId = :accountId ORDER BY sortOrder ASC") + fun observeForAccount(accountId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(folders: List) + + @Query("DELETE FROM folders WHERE accountId = :accountId") + suspend fun deleteForAccount(accountId: String) + + /** Replaces an account's folder set with the freshly-listed one (delete-then-insert). */ + @Transaction + suspend fun replaceForAccount(accountId: String, folders: List) { + deleteForAccount(accountId) + insertAll(folders) + } +} 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 bf2fc39..48c4d5b 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 @@ -16,9 +16,9 @@ interface MessageDao { @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") suspend fun getById(id: String): MessageEntity? - /** Ids of an account's inbox rows (excludes transient server-search hits). */ - @Query("SELECT id FROM messages WHERE accountId = :accountId AND inInbox = 1") - suspend fun getInboxIdsForAccount(accountId: String): List + /** Ids of an account's synced rows in [folder] (excludes transient server-search hits). */ + @Query("SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1") + suspend fun getSyncedIds(accountId: String, folder: String): List /** Inserts only new messages, leaving existing rows (and their cached bodies/flags) intact. */ @Insert(onConflict = OnConflictStrategy.IGNORE) @@ -41,9 +41,9 @@ interface MessageDao { timestampMillis: Long, ) - /** Marks rows as belonging to the inbox (e.g. a former search-only row that the sync now returns). */ + /** Marks rows as folder-synced (e.g. a former search-only row that the sync now returns). */ @Query("UPDATE messages SET inInbox = 1 WHERE id IN (:ids)") - suspend fun markInInbox(ids: List) + suspend fun markSynced(ids: List) @Query("UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet, bodyFetched = 1 WHERE id = :id") suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String) @@ -60,13 +60,16 @@ interface MessageDao { @Query("DELETE FROM messages WHERE accountId = :accountId") suspend fun deleteByAccount(accountId: String) - /** Clears only an account's inbox rows (leaves any in-flight search-only rows). */ - @Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1") - suspend fun deleteInboxByAccount(accountId: String) + /** Clears an account's synced rows in [folder] (leaves any in-flight search-only rows). */ + @Query("DELETE FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1") + suspend fun deleteSyncedByAccountFolder(accountId: String, folder: String) - /** Drops inbox rows for an account that are no longer present on the server. */ - @Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1 AND id NOT IN (:keepIds)") - suspend fun deleteInboxNotIn(accountId: String, keepIds: List) + /** Drops synced rows in [folder] for an account that are no longer present on the server. */ + @Query( + "DELETE FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 " + + "AND id NOT IN (:keepIds)", + ) + suspend fun deleteSyncedNotIn(accountId: String, folder: String, keepIds: List) /** Removes transient server-search hits (called when search closes). */ @Query("DELETE FROM messages WHERE inInbox = 0") diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/FolderEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/FolderEntity.kt new file mode 100644 index 0000000..d0523cb --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/FolderEntity.kt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity + +/** A cached IMAP folder for an account. [sortOrder] preserves the server's listing order. */ +@Entity(tableName = "folders", primaryKeys = ["accountId", "fullName"]) +data class FolderEntity( + val accountId: String, + val fullName: String, + val displayName: String, + /** The [org.libremail.domain.model.FolderRole] name. */ + val role: String, + val selectable: Boolean, + val sortOrder: Int, +) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt index c157e38..e59e110 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.local.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -21,7 +22,9 @@ data class MessageEntity( val timestampMillis: Long, val isRead: Boolean, val isStarred: Boolean, - /** True for inbox-synced rows; false for transient server-search hits (purged on search close). */ + /** The folder this message belongs to, e.g. "INBOX" or "[Gmail]/Sent Mail". */ + @ColumnInfo(defaultValue = "INBOX") val folder: String = "INBOX", + /** True for folder-synced rows; false for transient server-search hits (purged on search close). */ val inInbox: Boolean = true, /** True once the body has been fetched from the server (distinguishes "not fetched" from "empty body"). */ val bodyFetched: Boolean = false, diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt index 785958a..b03b5e0 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -6,6 +6,7 @@ import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.FolderDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity @@ -21,6 +22,7 @@ import org.libremail.mail.ImapClient class AccountRepositoryImpl @Inject constructor( private val accountDao: AccountDao, private val messageDao: MessageDao, + private val folderDao: FolderDao, private val credentialStore: CredentialStore, private val imapClient: ImapClient, private val syncScheduler: SyncScheduler, @@ -30,14 +32,14 @@ class AccountRepositoryImpl @Inject constructor( accountDao.observeAll().map { rows -> rows.map { it.toDomain() } } override suspend fun testConnection(params: ImapConnectionParams): Result> = - runCatching { imapClient.listFolders(params) } + runCatching { imapClient.listFolders(params).map { it.fullName } } override suspend fun addImapAccount(account: Account, password: String): Result> = runCatching { val folders = imapClient.listFolders(account.toImapParams(secret = password, useXoauth2 = false)) accountDao.upsert(account.toEntity()) credentialStore.saveSecret(account.id, password) syncScheduler.syncNow() - folders + folders.map { it.fullName } } override suspend fun addOutlookAccount( @@ -50,13 +52,14 @@ class AccountRepositoryImpl @Inject constructor( accountDao.upsert(account.toEntity()) credentialStore.saveSecret(account.id, authStateJson) syncScheduler.syncNow() - folders + folders.map { it.fullName } } override suspend fun deleteAccount(id: String) { accountDao.deleteById(id) credentialStore.delete(id) - // Remove the account's cached mail (attachment rows cascade via the foreign key). + // Remove the account's cached mail (attachment rows cascade via the foreign key) and folders. messageDao.deleteByAccount(id) + folderDao.deleteForAccount(id) } } 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 9e531fd..de3680e 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.map 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.OutboxEntity @@ -21,9 +22,9 @@ import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity import org.libremail.data.sync.MailConnectionFactory import org.libremail.data.sync.SendScheduler -import org.libremail.domain.model.Account import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft +import org.libremail.domain.model.Folder import org.libremail.domain.model.Message import org.libremail.domain.model.OutboxMessage import org.libremail.domain.model.OutgoingAttachment @@ -40,6 +41,7 @@ class MailRepositoryImpl @Inject constructor( private val attachmentDao: AttachmentDao, private val outboxDao: OutboxDao, private val draftDao: DraftDao, + private val folderDao: FolderDao, private val imapClient: ImapClient, private val connectionFactory: MailConnectionFactory, private val sendScheduler: SendScheduler, @@ -48,6 +50,17 @@ class MailRepositoryImpl @Inject constructor( override fun observeMessages(): Flow> = messageDao.observeAll().map { rows -> rows.map { it.toDomain() } } + override fun observeFolders(accountId: String): Flow> = + folderDao.observeForAccount(accountId).map { rows -> rows.map { it.toDomain() } } + + override suspend fun refreshFolders(accountId: String): Result = runCatching { + val account = accountDao.getById(accountId)?.toDomain() ?: error("Account not found") + val params = connectionFactory.imapParamsFor(account) + val entities = imapClient.listFolders(params) + .mapIndexed { index, folder -> folder.toEntity(accountId, index) } + folderDao.replaceForAccount(accountId, entities) + } + override suspend fun getMessage(id: String): Message? = messageDao.getById(id)?.toDomain() override suspend fun openMessage(id: String): Result = runCatching { @@ -56,12 +69,12 @@ class MailRepositoryImpl @Inject constructor( if (account != null) { val params = connectionFactory.imapParamsFor(account) if (!entity.bodyFetched) { - val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id)) + val content = imapClient.fetchBodyMarkingSeen(params, entity.folder, uidOf(id)) messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body)) attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) }) messageDao.setRead(id, true) } else if (!entity.isRead) { - runCatching { imapClient.setFlag(params, uidOf(id), Flags.Flag.SEEN, true) } + runCatching { imapClient.setFlag(params, entity.folder, uidOf(id), Flags.Flag.SEEN, true) } messageDao.setRead(id, true) } } @@ -75,22 +88,27 @@ class MailRepositoryImpl @Inject constructor( 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) - saveToCache(imapClient.fetchAttachment(params, uidOf(messageId), partIndex)) + saveToCache(imapClient.fetchAttachment(params, entity.folder, uidOf(messageId), partIndex)) } override suspend fun setStarred(id: String, starred: Boolean): Result = runCatching { messageDao.setStarred(id, starred) // optimistic; next sync reconciles on failure - accountFor(id)?.let { account -> - imapClient.setFlag(connectionFactory.imapParamsFor(account), uidOf(id), Flags.Flag.FLAGGED, starred) + val entity = messageDao.getById(id) + val account = entity?.let { accountDao.getById(it.accountId)?.toDomain() } + if (entity != null && account != null) { + imapClient.setFlag( + connectionFactory.imapParamsFor(account), entity.folder, uidOf(id), Flags.Flag.FLAGGED, starred, + ) } - Unit } override suspend fun deleteMessage(id: String): Result = runCatching { - val account = accountFor(id) + val entity = messageDao.getById(id) + val account = entity?.let { accountDao.getById(it.accountId)?.toDomain() } messageDao.deleteById(id) // optimistic; reappears on next sync if the server delete failed - account?.let { imapClient.deleteMessage(connectionFactory.imapParamsFor(it), uidOf(id)) } - Unit + if (entity != null && account != null) { + imapClient.deleteMessage(connectionFactory.imapParamsFor(account), entity.folder, uidOf(id)) + } } /** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */ @@ -149,26 +167,29 @@ class MailRepositoryImpl @Inject constructor( override suspend fun retryOutbox() = sendScheduler.sendNow() - override suspend fun searchServer(query: String) { - accountDao.getAll().forEach { entity -> - val account = entity.toDomain() - runCatching { - val results = imapClient.search(connectionFactory.imapParamsFor(account), query, SEARCH_LIMIT) - // Mark hits as non-inbox so they show only while searching (and never overwrite the - // inbox membership of a row that is genuinely in the inbox). - val entities = results.map { it.toEntity(account.id, inInbox = false) } - messageDao.insertNew(entities) - entities.forEach { - messageDao.updateHeaderContent( - id = it.id, - sender = it.sender, - senderEmail = it.senderEmail, - subject = it.subject, - timestampMillis = it.timestampMillis, - ) + override suspend fun searchServer(query: String, accountId: String?, folder: String) { + accountDao.getAll() + .filter { accountId == null || it.id == accountId } + .forEach { entity -> + val account = entity.toDomain() + runCatching { + val params = connectionFactory.imapParamsFor(account) + val results = imapClient.search(params, folder, query, SEARCH_LIMIT) + // Mark hits as search-only (inInbox = false) so they show only while searching, and + // never overwrite the synced membership of a row that is genuinely in the folder. + val entities = results.map { it.toEntity(account.id, folder, inInbox = false) } + messageDao.insertNew(entities) + entities.forEach { + messageDao.updateHeaderContent( + id = it.id, + sender = it.sender, + senderEmail = it.senderEmail, + subject = it.subject, + timestampMillis = it.timestampMillis, + ) + } } } - } } override suspend fun clearSearchResults() = messageDao.deleteSearchRows() @@ -181,11 +202,6 @@ class MailRepositoryImpl @Inject constructor( file.outputStream().use { it.write(attachment.bytes) } } } - - private suspend fun accountFor(id: String): Account? { - val entity = messageDao.getById(id) ?: return null - return accountDao.getById(entity.accountId)?.toDomain() - } } private const val SEARCH_LIMIT = 50 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 4e8a193..86ae9fc 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -16,7 +16,7 @@ import org.libremail.domain.model.Account import org.libremail.mail.ImapClient import org.libremail.notifications.MailNotifier -/** Fetches each account's recent INBOX headers into Room and notifies about newly-arrived mail. */ +/** Fetches account folders' headers into Room and notifies about newly-arrived inbox mail. */ @Singleton class MailSyncer @Inject constructor( private val accountDao: AccountDao, @@ -26,13 +26,13 @@ class MailSyncer @Inject constructor( private val settingsRepository: SettingsRepository, private val notifier: MailNotifier, ) { - // Serializes all syncing: syncAll/syncAccount are invoked concurrently by the periodic worker, - // pull-to-refresh, one-shot syncs, and one IDLE watcher per account. Without this, two runs can - // both compute the same message as "new" (double-notify) or let a stale deleteInboxNotIn snapshot - // delete a row another run just inserted. + // 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 + // deleteSyncedNotIn snapshot delete a row another run just inserted. private val syncMutex = Mutex() - /** Syncs every account. Succeeds if at least one account synced (or there are none). */ + /** 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) @@ -41,7 +41,7 @@ class MailSyncer @Inject constructor( var firstError: Throwable? = null var anySuccess = false for (entity in accounts) { - syncAccountInternal(entity.toDomain()).fold( + syncFolderInternal(entity.toDomain(), INBOX, notify = true).fold( onSuccess = { fetched -> total += fetched anySuccess = true @@ -52,56 +52,64 @@ class MailSyncer @Inject constructor( if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError) } - /** Syncs one account — used by the per-account IDLE watcher so a single push doesn't re-sync all. */ + /** 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) - syncAccountInternal(entity.toDomain()) + syncFolderInternal(entity.toDomain(), INBOX, notify = true) } - private suspend fun syncAccountInternal(account: Account): Result = runCatching { - val params = connectionFactory.imapParamsFor(account) - val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT) // cancellable network I/O - val entities = fetched.map { it.toEntity(account.id) } + /** 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) + } - // Persist and notify atomically with respect to cancellation: an IDLE renewal that cancels - // mid-sync must not drop a notification (the rows would then look "already seen" next time). - withContext(NonCancellable) { - val existingIds = messageDao.getInboxIdsForAccount(account.id).toHashSet() - // Don't notify on the very first sync of an account (would announce the whole inbox). - val newMessages = if (existingIds.isEmpty()) { - emptyList() - } else { - entities.filter { it.id !in existingIds && !it.isRead } - } + private suspend fun syncFolderInternal(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 + val entities = fetched.map { it.toEntity(account.id, folder) } - if (entities.isEmpty()) { - messageDao.deleteInboxByAccount(account.id) - } else { - val ids = entities.map { it.id } - messageDao.insertNew(entities) - // Mark every fetched message as inbox (upgrades any former search-only row) and refresh - // its display fields — without touching cached bodies or optimistic read/star flags. - messageDao.markInInbox(ids) - entities.forEach { - messageDao.updateHeaderContent( - id = it.id, - sender = it.sender, - senderEmail = it.senderEmail, - subject = it.subject, - timestampMillis = it.timestampMillis, - ) + // Persist and notify atomically with respect to cancellation: an IDLE renewal that cancels + // mid-sync must not drop a notification (the rows would then look "already seen" next time). + withContext(NonCancellable) { + val existingIds = messageDao.getSyncedIds(account.id, folder).toHashSet() + // Don't notify on the very first sync of a folder (would announce everything in it). + val newMessages = if (existingIds.isEmpty()) { + emptyList() + } else { + entities.filter { it.id !in existingIds && !it.isRead } } - messageDao.deleteInboxNotIn(account.id, ids) - } - if (newMessages.isNotEmpty() && settingsRepository.isNewMailNotificationsEnabled()) { - notifier.notifyNewMail(newMessages.sortedByDescending { it.timestampMillis }) + if (entities.isEmpty()) { + messageDao.deleteSyncedByAccountFolder(account.id, folder) + } else { + val ids = entities.map { it.id } + messageDao.insertNew(entities) + // Mark every fetched message as synced (upgrades any former search-only row) and refresh + // its display fields — without touching cached bodies or optimistic read/star flags. + messageDao.markSynced(ids) + entities.forEach { + messageDao.updateHeaderContent( + id = it.id, + sender = it.sender, + senderEmail = it.senderEmail, + subject = it.subject, + timestampMillis = it.timestampMillis, + ) + } + messageDao.deleteSyncedNotIn(account.id, folder, ids) + } + + if (notify && newMessages.isNotEmpty() && settingsRepository.isNewMailNotificationsEnabled()) { + notifier.notifyNewMail(newMessages.sortedByDescending { it.timestampMillis }) + } } + fetched.size } - fetched.size - } private companion object { - const val INBOX_LIMIT = 50 + const val INBOX = "INBOX" + const val FETCH_LIMIT = 50 } } diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index e237c63..70a68b7 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -20,10 +20,12 @@ import org.libremail.data.local.MIGRATION_3_4 import org.libremail.data.local.MIGRATION_4_5 import org.libremail.data.local.MIGRATION_5_6 import org.libremail.data.local.MIGRATION_6_7 +import org.libremail.data.local.MIGRATION_7_8 import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao 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.security.DatabaseKeyStore @@ -48,6 +50,7 @@ object DatabaseModule { MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, + MIGRATION_7_8, ) // No destructive fallback: the migration chain is complete, and silently dropping the // accounts/credentials/mail tables would lose stored secrets. A missing migration should @@ -88,5 +91,8 @@ object DatabaseModule { @Provides fun provideDraftDao(database: LibreMailDatabase): DraftDao = database.draftDao() + @Provides + fun provideFolderDao(database: LibreMailDatabase): FolderDao = database.folderDao() + private const val DB_NAME = "libremail.db" } diff --git a/app/src/main/kotlin/org/libremail/domain/model/Folder.kt b/app/src/main/kotlin/org/libremail/domain/model/Folder.kt new file mode 100644 index 0000000..323ff34 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/Folder.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** A mail folder (IMAP mailbox) belonging to an account. */ +data class Folder( + val accountId: String, + /** Server path including hierarchy, e.g. "INBOX" or "[Gmail]/Sent Mail". Used to open the folder. */ + val fullName: String, + /** The leaf name shown to the user, e.g. "Sent Mail". */ + val displayName: String, + val role: FolderRole, + /** False for \Noselect containers (e.g. Gmail's "[Gmail]" parent) that hold no messages. */ + val selectable: Boolean, +) + +/** + * The well-known role of a folder, used to surface standard folders with friendly names and icons. + * Enum order is the drawer display order (standard roles first, [NORMAL] last). + */ +enum class FolderRole { + INBOX, + SENT, + DRAFTS, + ARCHIVE, + SPAM, + TRASH, + NORMAL, + ; + + companion object { + /** + * Classifies a folder from its name and IMAP SPECIAL-USE attributes (RFC 6154). Prefers the + * server-advertised attribute; falls back to a case-insensitive name match because many + * servers (and the GreenMail test server) don't advertise SPECIAL-USE. + */ + fun roleOf(fullName: String, displayName: String, attributes: List): FolderRole { + if (fullName.equals("INBOX", ignoreCase = true)) return INBOX + val attrs = attributes.map { it.lowercase() } + when { + "\\sent" in attrs -> return SENT + "\\drafts" in attrs -> return DRAFTS + "\\junk" in attrs -> return SPAM + "\\trash" in attrs -> return TRASH + "\\archive" in attrs -> return ARCHIVE + } + return when (displayName.lowercase().trim()) { + "sent", "sent mail", "sent items", "sent messages" -> SENT + "drafts", "draft" -> DRAFTS + "junk", "spam", "junk e-mail", "junk email", "bulk mail" -> SPAM + "trash", "deleted", "deleted items", "deleted messages", "bin" -> TRASH + "archive", "archives", "all mail" -> ARCHIVE + else -> NORMAL + } + } + } +} 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 0d9becc..e104b1c 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Message.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Message.kt @@ -13,6 +13,8 @@ data class Message( val timestampMillis: Long, val isRead: Boolean, val isStarred: Boolean, - /** True for messages synced as part of the inbox; false for transient server-search hits. */ + /** The folder this message belongs to, e.g. "INBOX" or "[Gmail]/Sent Mail". */ + val folder: String = "INBOX", + /** True for messages synced from a folder; false for transient server-search hits. */ val inInbox: Boolean = true, ) 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 bee54b6..5c1b4e6 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -5,6 +5,7 @@ import java.io.File import kotlinx.coroutines.flow.Flow import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft +import org.libremail.domain.model.Folder import org.libremail.domain.model.Message import org.libremail.domain.model.OutboxMessage import org.libremail.domain.model.OutgoingMessage @@ -16,6 +17,12 @@ import org.libremail.domain.model.OutgoingMessage interface MailRepository { fun observeMessages(): Flow> + /** The account's cached IMAP folders for the navigation drawer. */ + fun observeFolders(accountId: String): Flow> + + /** Refreshes the account's folder list from the server into the cache. */ + suspend fun refreshFolders(accountId: String): Result + suspend fun getMessage(id: String): Message? /** Loads a message for reading: fetches+caches the body if missing, and marks it read. */ @@ -44,8 +51,11 @@ interface MailRepository { suspend fun cancelOutboxMessage(id: String) suspend fun retryOutbox() - /** Fetches server-side search matches into the cache so the message list can surface them. */ - suspend fun searchServer(query: String) + /** + * Fetches server-side search matches into the cache so the message list can surface them. + * Scoped to [folder]; [accountId] null searches every account (e.g. the unified inbox). + */ + suspend fun searchServer(query: String, accountId: String?, folder: String) /** Drops transient server-search hits from the cache (called when search is dismissed). */ suspend fun clearSearchResults() diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index b60d623..87bb773 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -35,6 +35,16 @@ import org.eclipse.angus.mail.imap.IMAPFolder import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity +/** A folder (mailbox) listed from the server, with the metadata needed to classify and display it. */ +data class FetchedFolder( + val fullName: String, + val displayName: String, + /** Raw IMAP attributes from the LIST response (RFC 6154 SPECIAL-USE flags, \Noselect, …). */ + val attributes: List, + /** False for \Noselect containers (e.g. Gmail's "[Gmail]" parent) that can't be opened. */ + val selectable: Boolean, +) + /** A message header fetched from the server (no body — that arrives with the reader). */ data class FetchedMessage( val uid: String, @@ -72,25 +82,44 @@ class DownloadedAttachment( @Singleton class ImapClient @Inject constructor() { - /** Connects and returns the account's folder names. Throws on failure. */ - suspend fun listFolders(params: ImapConnectionParams): List = withContext(Dispatchers.IO) { + /** Connects and returns the account's folders with their SPECIAL-USE attributes. Throws on failure. */ + suspend fun listFolders(params: ImapConnectionParams): List = withContext(Dispatchers.IO) { withStore(params) { store -> - store.defaultFolder.list("*").map { it.fullName } + val folders = store.defaultFolder.list("*").map { folder -> + val attributes = if (folder is IMAPFolder) { + runCatching { folder.attributes.toList() }.getOrDefault(emptyList()) + } else { + emptyList() + } + val separator = runCatching { folder.separator }.getOrDefault('/') + FetchedFolder( + fullName = folder.fullName, + displayName = folder.fullName.substringAfterLast(separator), + attributes = attributes, + selectable = attributes.none { it.equals("\\Noselect", ignoreCase = true) }, + ) + } + // Some servers don't return INBOX from a wildcard LIST; guarantee it's always present. + if (folders.none { it.fullName.equals("INBOX", ignoreCase = true) }) { + listOf(FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true)) + folders + } else { + folders + } } } - /** Fetches the most recent [limit] INBOX headers, newest first. */ - suspend fun fetchRecentInbox(params: ImapConnectionParams, limit: Int): List = + /** Fetches the most recent [limit] headers of [folder], newest first. */ + suspend fun fetchRecent(params: ImapConnectionParams, folder: String, limit: Int): List = withContext(Dispatchers.IO) { withStore(params) { store -> - val inbox = store.getFolder("INBOX") - inbox.open(Folder.READ_ONLY) + val mailbox = store.getFolder(folder) + mailbox.open(Folder.READ_ONLY) try { - val total = inbox.messageCount + val total = mailbox.messageCount if (total == 0) return@withStore emptyList() - val messages = inbox.getMessages(maxOf(1, total - limit + 1), total) - inbox.fetch( + val messages = mailbox.getMessages(maxOf(1, total - limit + 1), total) + mailbox.fetch( messages, FetchProfile().apply { add(FetchProfile.Item.ENVELOPE) @@ -98,26 +127,26 @@ class ImapClient @Inject constructor() { add(UIDFolder.FetchProfileItem.UID) }, ) - val uidFolder = inbox as UIDFolder + val uidFolder = mailbox as UIDFolder messages.reversed().map { it.toFetchedMessage(uidFolder) } } finally { - runCatching { inbox.close(false) } + runCatching { mailbox.close(false) } } } } - /** Runs an IMAP SEARCH over the whole INBOX (subject/from/body) and returns matching headers. */ - suspend fun search(params: ImapConnectionParams, query: String, limit: Int): List = + /** Runs an IMAP SEARCH over [folder] (subject/from/body) and returns matching headers. */ + suspend fun search(params: ImapConnectionParams, folder: String, query: String, limit: Int): List = withContext(Dispatchers.IO) { withStore(params) { store -> - val inbox = store.getFolder("INBOX") - inbox.open(Folder.READ_ONLY) + val mailbox = store.getFolder(folder) + mailbox.open(Folder.READ_ONLY) try { val term = OrTerm(arrayOf(SubjectTerm(query), FromStringTerm(query), BodyTerm(query))) - val matches = inbox.search(term).toList() + val matches = mailbox.search(term).toList() if (matches.isEmpty()) return@withStore emptyList() val recent = if (matches.size > limit) matches.takeLast(limit) else matches - inbox.fetch( + mailbox.fetch( recent.toTypedArray(), FetchProfile().apply { add(FetchProfile.Item.ENVELOPE) @@ -125,41 +154,46 @@ class ImapClient @Inject constructor() { add(UIDFolder.FetchProfileItem.UID) }, ) - val uidFolder = inbox as UIDFolder + val uidFolder = mailbox as UIDFolder recent.reversed().map { it.toFetchedMessage(uidFolder) } } finally { - runCatching { inbox.close(false) } + runCatching { mailbox.close(false) } } } } - /** Fetches a message body by UID and marks it \Seen on the server. */ - suspend fun fetchBodyMarkingSeen(params: ImapConnectionParams, uid: String): MessageContent = + /** Fetches a message body by UID from [folder] and marks it \Seen on the server. */ + suspend fun fetchBodyMarkingSeen(params: ImapConnectionParams, folder: String, uid: String): MessageContent = withContext(Dispatchers.IO) { withStore(params) { store -> - val inbox = store.getFolder("INBOX") - inbox.open(Folder.READ_WRITE) + val mailbox = store.getFolder(folder) + mailbox.open(Folder.READ_WRITE) try { - val message = (inbox as UIDFolder).getMessageByUID(uid.toLong()) + val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong()) ?: error("Message $uid not found") val content = (extractBody(message) ?: MessageContent("", isHtml = false)) .copy(attachments = collectAttachments(message)) message.setFlag(Flags.Flag.SEEN, true) content } finally { - runCatching { inbox.close(false) } + runCatching { mailbox.close(false) } } } } - /** Downloads the bytes of one attachment part (identified by its [partIndex]). */ - suspend fun fetchAttachment(params: ImapConnectionParams, uid: String, partIndex: Int): DownloadedAttachment = + /** Downloads the bytes of one attachment part (identified by its [partIndex]) from [folder]. */ + suspend fun fetchAttachment( + params: ImapConnectionParams, + folder: String, + uid: String, + partIndex: Int, + ): DownloadedAttachment = withContext(Dispatchers.IO) { withStore(params) { store -> - val inbox = store.getFolder("INBOX") - inbox.open(Folder.READ_ONLY) + val mailbox = store.getFolder(folder) + mailbox.open(Folder.READ_ONLY) try { - val message = (inbox as UIDFolder).getMessageByUID(uid.toLong()) + val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong()) ?: error("Message $uid not found") val parts = mutableListOf() collectAttachmentParts(message, parts) @@ -171,34 +205,34 @@ class ImapClient @Inject constructor() { bytes = bytes, ) } finally { - runCatching { inbox.close(false) } + runCatching { mailbox.close(false) } } } } - suspend fun setFlag(params: ImapConnectionParams, uid: String, flag: Flags.Flag, value: Boolean) = + suspend fun setFlag(params: ImapConnectionParams, folder: String, uid: String, flag: Flags.Flag, value: Boolean) = withContext(Dispatchers.IO) { withStore(params) { store -> - val inbox = store.getFolder("INBOX") - inbox.open(Folder.READ_WRITE) + val mailbox = store.getFolder(folder) + mailbox.open(Folder.READ_WRITE) try { - (inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(flag, value) + (mailbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(flag, value) } finally { - runCatching { inbox.close(false) } + runCatching { mailbox.close(false) } } } } - suspend fun deleteMessage(params: ImapConnectionParams, uid: String) = + suspend fun deleteMessage(params: ImapConnectionParams, folder: String, uid: String) = withContext(Dispatchers.IO) { withStore(params) { store -> - val inbox = store.getFolder("INBOX") - inbox.open(Folder.READ_WRITE) + val mailbox = store.getFolder(folder) + mailbox.open(Folder.READ_WRITE) try { - (inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(Flags.Flag.DELETED, true) - inbox.expunge() + (mailbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(Flags.Flag.DELETED, true) + mailbox.expunge() } finally { - runCatching { inbox.close(false) } + runCatching { mailbox.close(false) } } } } diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/FolderDrawer.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/FolderDrawer.kt new file mode 100644 index 0000000..a6b0a55 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/FolderDrawer.kt @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.mailbox + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +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.ArrowDropDown +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.libremail.R +import org.libremail.domain.model.Account +import org.libremail.domain.model.Folder +import org.libremail.domain.model.FolderRole + +/** + * The navigation drawer's contents: an optional account switcher and "All Inboxes" entry (only with + * 2+ accounts), then the drawer account's folders — standard folders (Inbox, Sent, …) first. + */ +@Composable +fun FolderDrawer( + accounts: List, + drawerAccount: Account?, + folders: List, + selectedAccountId: String?, + selectedFolder: String, + onSelectUnifiedInbox: () -> Unit, + onSelectFolder: (accountId: String, folderFullName: String) -> Unit, + onSelectDrawerAccount: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(vertical = 12.dp), + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp), + ) + + if (accounts.size >= 2 && drawerAccount != null) { + AccountSwitcher(accounts, drawerAccount, onSelectDrawerAccount) + NavigationDrawerItem( + label = { Text(stringResource(R.string.folder_all_inboxes)) }, + icon = { Icon(Icons.Filled.Email, contentDescription = null) }, + selected = selectedAccountId == null && selectedFolder == INBOX, + onClick = onSelectUnifiedInbox, + modifier = Modifier.padding(horizontal = 12.dp), + ) + HorizontalDivider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + } + + val sorted = remember(folders) { + folders.sortedWith(compareBy({ it.role.ordinal }, { it.displayName.lowercase() })) + } + sorted.forEach { folder -> + val isSelected = selectedAccountId != null && + selectedAccountId == drawerAccount?.id && + folder.fullName == selectedFolder + val iconContent: (@Composable () -> Unit)? = folderIcon(folder.role)?.let { vector -> + { Icon(vector, contentDescription = null) } + } + NavigationDrawerItem( + label = { Text(folderDisplayLabel(folder)) }, + icon = iconContent, + selected = isSelected, + onClick = { + if (folder.selectable && drawerAccount != null) { + onSelectFolder(drawerAccount.id, folder.fullName) + } + }, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } + } +} + +@Composable +private fun AccountSwitcher(accounts: List, current: Account, onSelect: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + TextButton( + onClick = { expanded = true }, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text(current.email, maxLines = 1, overflow = TextOverflow.Ellipsis) + Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.drawer_switch_account)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + accounts.forEach { account -> + DropdownMenuItem( + text = { Text(account.email) }, + onClick = { + onSelect(account.id) + expanded = false + }, + ) + } + } +} + +/** The user-facing label for a folder: a friendly name for standard roles, else the server name. */ +@Composable +fun folderDisplayLabel(folder: Folder): String = when (folder.role) { + FolderRole.INBOX -> stringResource(R.string.folder_inbox) + FolderRole.SENT -> stringResource(R.string.folder_sent) + FolderRole.DRAFTS -> stringResource(R.string.folder_drafts) + FolderRole.ARCHIVE -> stringResource(R.string.folder_archive) + FolderRole.SPAM -> stringResource(R.string.folder_spam) + FolderRole.TRASH -> stringResource(R.string.folder_trash) + FolderRole.NORMAL -> folder.displayName +} + +/** A leading icon for standard folders, limited to the material-icons-core set (null = no icon). */ +private fun folderIcon(role: FolderRole): ImageVector? = when (role) { + FolderRole.INBOX -> Icons.Filled.Email + FolderRole.SENT -> Icons.AutoMirrored.Filled.Send + FolderRole.DRAFTS -> Icons.Filled.Edit + FolderRole.SPAM -> Icons.Filled.Delete + FolderRole.TRASH -> Icons.Filled.Delete + FolderRole.ARCHIVE -> null + FolderRole.NORMAL -> null +} 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 8e5be4b..de40b65 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -27,8 +27,10 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close 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.Search import androidx.compose.material3.Button +import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip @@ -36,16 +38,20 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text 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.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -58,6 +64,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import org.libremail.R import org.libremail.domain.model.Account import org.libremail.domain.model.Message @@ -76,6 +83,9 @@ fun MailboxScreen( val messages by viewModel.messages.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle() val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle() + val selectedFolder by viewModel.selectedFolder.collectAsStateWithLifecycle() + val folders by viewModel.folders.collectAsStateWithLifecycle() + val drawerAccount by viewModel.drawerAccount.collectAsStateWithLifecycle() val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() val draftCount by viewModel.draftCount.collectAsStateWithLifecycle() val outboxCount by viewModel.outboxCount.collectAsStateWithLifecycle() @@ -84,8 +94,12 @@ fun MailboxScreen( val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() 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() } } + LaunchedEffect(drawerState.isOpen) { if (drawerState.isOpen) viewModel.onDrawerOpened() } LaunchedEffect(error) { error?.let { @@ -94,91 +108,124 @@ fun MailboxScreen( } } - Scaffold( - topBar = { - TopAppBar( - title = { - if (searchActive) { - SearchField(query = searchQuery, onQueryChange = viewModel::onSearchQuery) - } else { - Text(stringResource(R.string.title_mailbox)) - } - }, - navigationIcon = { - if (searchActive) { - IconButton(onClick = viewModel::closeSearch) { - Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_close)) + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = drawerState.isOpen || (hasAccounts && !searchActive), + drawerContent = { + ModalDrawerSheet { + FolderDrawer( + accounts = accounts, + drawerAccount = drawerAccount, + folders = folders, + selectedAccountId = selectedAccountId, + selectedFolder = selectedFolder, + onSelectUnifiedInbox = { + viewModel.selectUnifiedInbox() + scope.launch { drawerState.close() } + }, + onSelectFolder = { accountId, folder -> + viewModel.selectFolder(accountId, folder) + scope.launch { drawerState.close() } + }, + onSelectDrawerAccount = viewModel::setDrawerAccount, + ) + } + }, + ) { + 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), + ) } - } - }, - actions = { - if (hasAccounts && !searchActive) { - IconButton(onClick = viewModel::openSearch) { - Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search)) + }, + 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)) + } } - } - }, - ) - }, - bottomBar = { - org.libremail.ui.LibreMailBottomBar( - current = org.libremail.ui.TopDest.MAILBOX, - onSelect = onSelectTab, - ) - }, - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = onCompose, - icon = { Icon(Icons.Filled.Edit, contentDescription = null) }, - text = { Text(stringResource(R.string.action_compose)) }, - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { padding -> - Box(modifier = Modifier.fillMaxSize().padding(padding)) { - if (!hasAccounts) { - NoAccountState(onAddAccount = onAddAccount) - } else { - val accountsById = remember(accounts) { accounts.associateBy { it.id } } - val showAccount = selectedAccountId == null && accounts.size >= 2 - Column(Modifier.fillMaxSize()) { - if (accounts.size >= 2) { - AccountFilterRow( - accounts = accounts, - selectedId = selectedAccountId, - onSelect = viewModel::selectAccount, - ) - } - if (draftCount > 0 && !searchActive) { - DraftsEntry(count = draftCount, onClick = onOpenDrafts) - HorizontalDivider() - } - if (outboxCount > 0 && !searchActive) { - OutboxEntry(count = outboxCount, onClick = onOpenOutbox) - HorizontalDivider() - } - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier.fillMaxSize(), - ) { - LazyColumn(modifier = Modifier.fillMaxSize()) { - if (messages.isEmpty()) { - item { - if (searchActive && searchQuery.isNotBlank()) { - NoResultsState(Modifier.fillParentMaxSize()) - } else { - NoMessagesState(Modifier.fillParentMaxSize()) + }, + actions = { + if (hasAccounts && !searchActive) { + IconButton(onClick = viewModel::openSearch) { + Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search)) + } + } + }, + ) + }, + bottomBar = { + org.libremail.ui.LibreMailBottomBar( + current = org.libremail.ui.TopDest.MAILBOX, + onSelect = onSelectTab, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onCompose, + icon = { Icon(Icons.Filled.Edit, contentDescription = null) }, + text = { Text(stringResource(R.string.action_compose)) }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + if (!hasAccounts) { + NoAccountState(onAddAccount = onAddAccount) + } else { + val accountsById = remember(accounts) { accounts.associateBy { it.id } } + val showAccount = selectedAccountId == null && accounts.size >= 2 + Column(Modifier.fillMaxSize()) { + if (accounts.size >= 2 && selectedFolder == INBOX) { + AccountFilterRow( + accounts = accounts, + selectedId = selectedAccountId, + onSelect = viewModel::selectAccount, + ) + } + if (draftCount > 0 && !searchActive && selectedFolder == INBOX) { + DraftsEntry(count = draftCount, onClick = onOpenDrafts) + HorizontalDivider() + } + if (outboxCount > 0 && !searchActive && selectedFolder == INBOX) { + OutboxEntry(count = outboxCount, onClick = onOpenOutbox) + HorizontalDivider() + } + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (messages.isEmpty()) { + item { + if (searchActive && searchQuery.isNotBlank()) { + NoResultsState(Modifier.fillParentMaxSize()) + } else { + NoMessagesState(Modifier.fillParentMaxSize()) + } + } + } else { + items(messages, key = { it.id }) { message -> + MessageRow( + message = message, + accountLabel = if (showAccount) accountsById[message.accountId]?.email else null, + onClick = { onOpenMessage(message.id) }, + ) + HorizontalDivider() } - } - } else { - items(messages, key = { it.id }) { message -> - MessageRow( - message = message, - accountLabel = if (showAccount) accountsById[message.accountId]?.email else null, - onClick = { onOpenMessage(message.id) }, - ) - HorizontalDivider() } } } 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 6f18500..a84b53a 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -13,15 +15,22 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.libremail.data.sync.MailSyncer 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.repository.AccountRepository import org.libremail.domain.repository.MailRepository +const val INBOX = "INBOX" + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @HiltViewModel class MailboxViewModel @Inject constructor( private val mailRepository: MailRepository, @@ -40,6 +49,27 @@ class MailboxViewModel @Inject constructor( private val _selectedAccountId = MutableStateFlow(null) val selectedAccountId: StateFlow = _selectedAccountId.asStateFlow() + /** The folder whose mail is shown (always a concrete folder; defaults to the inbox). */ + private val _selectedFolder = MutableStateFlow(INBOX) + val selectedFolder: StateFlow = _selectedFolder.asStateFlow() + + /** Which account's folders the drawer lists. null follows the mailbox selection / first account. */ + private val _drawerAccountId = MutableStateFlow(null) + + /** The account the drawer is browsing: explicit drawer pick, else the filtered account, else the first. */ + val drawerAccount: StateFlow = + combine(accounts, _drawerAccountId, _selectedAccountId) { accts, drawerId, selId -> + accts.firstOrNull { it.id == (drawerId ?: selId) } ?: accts.firstOrNull() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** The drawer account's cached folders, always including an inbox entry even before the first refresh. */ + val folders: StateFlow> = drawerAccount + .flatMapLatest { account -> + if (account == null) flowOf(emptyList()) + else mailRepository.observeFolders(account.id).map { withInbox(account.id, it) } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + private val _searchActive = MutableStateFlow(false) val searchActive: StateFlow = _searchActive.asStateFlow() @@ -47,12 +77,18 @@ class MailboxViewModel @Inject constructor( val searchQuery: StateFlow = _searchQuery.asStateFlow() val messages: StateFlow> = - combine(mailRepository.observeMessages(), _selectedAccountId, _searchQuery) { all, accountId, query -> + combine( + mailRepository.observeMessages(), + _selectedAccountId, + _selectedFolder, + _searchQuery, + ) { all, accountId, folder, query -> val q = query.trim() all.filter { message -> (accountId == null || message.accountId == accountId) && - // Outside of search show only inbox rows; while searching show every match, - // including transient server-search hits that aren't in the inbox. + message.folder == folder && + // Outside of search show only synced rows; while searching show every match in this + // folder, including transient server-search hits that aren't synced. (if (q.isEmpty()) message.inInbox else message.matchesSearch(q)) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) @@ -72,21 +108,27 @@ class MailboxViewModel @Inject constructor( val error: StateFlow = _error.asStateFlow() init { - // Fall back to the unified view if the filtered account is removed. + // Fall back to the unified inbox if the filtered account is removed. viewModelScope.launch { accounts.collect { list -> val selected = _selectedAccountId.value - if (selected != null && list.none { it.id == selected }) _selectedAccountId.value = null + if (selected != null && list.none { it.id == selected }) { + _selectedAccountId.value = null + _selectedFolder.value = INBOX + } } } - // Server-side search: fetch matches into the cache; the list filter then surfaces them. + // Server-side search: fetch matches for the current folder into the cache; the list filter + // then surfaces them. viewModelScope.launch { _searchQuery .debounce(400L) .map { it.trim() } .filter { it.length >= 2 } .distinctUntilChanged() - .collect { query -> mailRepository.searchServer(query) } + .collect { query -> + mailRepository.searchServer(query, _selectedAccountId.value, _selectedFolder.value) + } } } @@ -94,6 +136,33 @@ class MailboxViewModel @Inject constructor( _selectedAccountId.value = accountId } + /** Browses a specific account's folder; syncs it from the server in the background. */ + fun selectFolder(accountId: String, folderFullName: String) { + _selectedAccountId.value = accountId + _drawerAccountId.value = accountId + _selectedFolder.value = folderFullName + viewModelScope.launch { mailSyncer.syncFolder(accountId, folderFullName) } + } + + /** Returns to the unified inbox across all accounts. */ + fun selectUnifiedInbox() { + _selectedAccountId.value = null + _drawerAccountId.value = null + _selectedFolder.value = INBOX + } + + /** Points the drawer at another account's folders (without changing the shown mail yet). */ + fun setDrawerAccount(accountId: String) { + _drawerAccountId.value = accountId + viewModelScope.launch { mailRepository.refreshFolders(accountId) } + } + + /** Refreshes the current drawer account's folder list (called when the drawer opens). */ + fun onDrawerOpened() { + val accountId = drawerAccount.value?.id ?: return + viewModelScope.launch { mailRepository.refreshFolders(accountId) } + } + fun openSearch() { _searchActive.value = true } @@ -101,7 +170,7 @@ class MailboxViewModel @Inject constructor( fun closeSearch() { _searchActive.value = false _searchQuery.value = "" - // Drop the transient server-search hits so they don't linger in the inbox. + // Drop the transient server-search hits so they don't linger in the folder. viewModelScope.launch { mailRepository.clearSearchResults() } } @@ -113,7 +182,17 @@ class MailboxViewModel @Inject constructor( if (_isRefreshing.value) return viewModelScope.launch { _isRefreshing.value = true - mailSyncer.syncAll().onFailure { _error.value = it.message ?: "Sync failed" } + val accountId = _selectedAccountId.value + val folder = _selectedFolder.value + // The unified inbox refreshes every account; a specific folder refreshes just that folder. + val result = if (accountId == null && folder == INBOX) { + mailSyncer.syncAll() + } else if (accountId != null) { + mailSyncer.syncFolder(accountId, folder) + } else { + mailSyncer.syncAll() + } + result.onFailure { _error.value = it.message ?: "Sync failed" } _isRefreshing.value = false } } @@ -123,6 +202,14 @@ class MailboxViewModel @Inject constructor( } } +/** 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) }) { + folders + } else { + listOf(Folder(accountId, INBOX, INBOX, FolderRole.INBOX, selectable = true)) + folders + } + /** Local match over the always-populated header fields (and snippet, once a body is cached). */ private fun Message.matchesSearch(query: String): Boolean = sender.contains(query, ignoreCase = true) || diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cac4329..3d76a29 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,17 @@ Welcome to LibreMail Add an account to start reading your mail. + + Show folders + Switch account + All Inboxes + Inbox + Sent + Drafts + Archive + Spam + Trash + From To 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 8f7abd5..8a89917 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -2,28 +2,50 @@ package org.libremail.data.repository import app.cash.turbine.test +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.flow.flowOf 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.FolderDao import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.FolderEntity import org.libremail.data.local.entity.MessageEntity +import org.libremail.data.local.entity.ServerConfigEmbedded +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.mail.ImapClient +import org.libremail.mail.MessageContent class MailRepositoryImplTest { private val messageDao = mockk() + private val accountDao = mockk() + private val folderDao = mockk() + private val attachmentDao = mockk(relaxed = true) + private val imapClient = mockk() + private val connectionFactory = mockk() private val repository = MailRepositoryImpl( context = mockk(), messageDao = messageDao, - accountDao = mockk(), - attachmentDao = mockk(), + accountDao = accountDao, + attachmentDao = attachmentDao, outboxDao = mockk(), draftDao = mockk(), - imapClient = mockk(), - connectionFactory = mockk(), + folderDao = folderDao, + imapClient = imapClient, + connectionFactory = connectionFactory, sendScheduler = mockk(), ) @@ -38,25 +60,78 @@ class MailRepositoryImplTest { @Test fun `observeMessages maps cached entities`() = runTest { - val entity = MessageEntity( - id = "1", - accountId = "a", - sender = "Ada", - senderEmail = "ada@example.org", - subject = "Hi", - snippet = "snippet", - body = "body", - timestampMillis = 1_000L, - isRead = true, - isStarred = false, - ) + val entity = messageEntity("1", "INBOX") every { messageDao.observeAll() } returns flowOf(listOf(entity)) repository.observeMessages().test { val items = awaitItem() assertEquals(1, items.size) assertEquals("Ada", items.first().sender) - assertTrue(items.first().isRead) + assertEquals("INBOX", items.first().folder) awaitComplete() } } + + @Test + fun `observeFolders maps cached folders with their roles`() = runTest { + every { folderDao.observeForAccount("acct") } returns flowOf( + listOf(FolderEntity("acct", "[Gmail]/Sent Mail", "Sent Mail", "SENT", selectable = true, sortOrder = 1)), + ) + repository.observeFolders("acct").test { + val folders = awaitItem() + assertEquals(1, folders.size) + assertEquals(FolderRole.SENT, folders.first().role) + assertEquals("Sent Mail", folders.first().displayName) + awaitComplete() + } + } + + @Test + fun `openMessage fetches the body from the message's own folder, not the inbox`() = runTest { + val id = "acct:Archive:5" + coEvery { messageDao.getById(id) } returns messageEntity(id, "Archive") + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { imapClient.fetchBodyMarkingSeen(any(), "Archive", "5") } returns MessageContent("Body text", isHtml = false) + coEvery { messageDao.updateBody(id, any(), any(), any()) } just Runs + coEvery { messageDao.setRead(id, true) } just Runs + + repository.openMessage(id) + + // The UID (5) must be resolved against the message's folder (Archive), since IMAP UIDs are + // unique only within a folder. + coVerify { imapClient.fetchBodyMarkingSeen(any(), "Archive", "5") } + } + + private fun messageEntity(id: String, folder: String) = MessageEntity( + id = id, + accountId = "acct", + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "snippet", + body = "", + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + folder = folder, + bodyFetched = false, + ) + + private fun accountEntity() = AccountEntity( + id = "acct", + email = "ada@example.org", + displayName = "Ada", + authType = "PASSWORD_IMAP", + imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"), + smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"), + ) + + private fun imapParams() = ImapConnectionParams( + host = "imap.example.org", + port = 993, + security = MailSecurity.SSL_TLS, + username = "ada@example.org", + secret = "secret", + useXoauth2 = false, + ) } diff --git a/app/src/test/kotlin/org/libremail/domain/model/FolderRoleTest.kt b/app/src/test/kotlin/org/libremail/domain/model/FolderRoleTest.kt new file mode 100644 index 0000000..046e44e --- /dev/null +++ b/app/src/test/kotlin/org/libremail/domain/model/FolderRoleTest.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +import kotlin.test.assertEquals +import org.junit.Test + +class FolderRoleTest { + + @Test + fun `INBOX is detected by name regardless of case or attributes`() { + assertEquals(FolderRole.INBOX, FolderRole.roleOf("INBOX", "INBOX", emptyList())) + assertEquals(FolderRole.INBOX, FolderRole.roleOf("inbox", "inbox", emptyList())) + } + + @Test + fun `special-use attributes take precedence over the name`() { + assertEquals(FolderRole.SENT, FolderRole.roleOf("X", "Whatever", listOf("\\Sent"))) + assertEquals(FolderRole.DRAFTS, FolderRole.roleOf("X", "Whatever", listOf("\\Drafts"))) + assertEquals(FolderRole.SPAM, FolderRole.roleOf("X", "Whatever", listOf("\\Junk"))) + assertEquals(FolderRole.TRASH, FolderRole.roleOf("X", "Whatever", listOf("\\Trash"))) + assertEquals(FolderRole.ARCHIVE, FolderRole.roleOf("X", "Whatever", listOf("\\Archive"))) + } + + @Test + fun `falls back to a case-insensitive name match when no attributes are advertised`() { + assertEquals(FolderRole.SENT, FolderRole.roleOf("[Gmail]/Sent Mail", "Sent Mail", emptyList())) + assertEquals(FolderRole.DRAFTS, FolderRole.roleOf("Drafts", "Drafts", emptyList())) + assertEquals(FolderRole.SPAM, FolderRole.roleOf("Junk", "Junk", emptyList())) + assertEquals(FolderRole.TRASH, FolderRole.roleOf("Deleted Items", "Deleted Items", emptyList())) + assertEquals(FolderRole.ARCHIVE, FolderRole.roleOf("Archive", "Archive", emptyList())) + assertEquals(FolderRole.NORMAL, FolderRole.roleOf("Receipts", "Receipts", emptyList())) + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index 01dbe9f..6b4c3cd 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -4,6 +4,12 @@ package org.libremail.mail import com.icegreen.greenmail.util.GreenMail import com.icegreen.greenmail.util.GreenMailUtil import com.icegreen.greenmail.util.ServerSetupTest +import jakarta.mail.Folder +import jakarta.mail.Message +import jakarta.mail.Session +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeMessage +import java.util.Properties import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue @@ -42,7 +48,7 @@ class ImapClientTest { @Test fun `listFolders returns INBOX for a valid login`() = runTest { - assertTrue(client.listFolders(params()).any { it.equals("INBOX", ignoreCase = true) }) + assertTrue(client.listFolders(params()).any { it.fullName.equals("INBOX", ignoreCase = true) }) } @Test @@ -56,7 +62,7 @@ class ImapClientTest { GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Second subject", "Body two") greenMail.waitForIncomingEmail(2) - val messages = client.fetchRecentInbox(params(), limit = 50) + val messages = client.fetchRecent(params(), "INBOX", limit = 50) assertEquals(2, messages.size) assertEquals("Second subject", messages.first().subject) @@ -68,12 +74,12 @@ class ImapClientTest { fun `fetchBodyMarkingSeen returns the body and marks the message read`() = runTest { GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Hello", "The quick brown fox.") greenMail.waitForIncomingEmail(1) - val uid = client.fetchRecentInbox(params(), limit = 50).first().uid + val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid - val content = client.fetchBodyMarkingSeen(params(), uid) + val content = client.fetchBodyMarkingSeen(params(), "INBOX", uid) assertTrue(content.body.contains("quick brown fox"), "body=${content.body}") - assertTrue(client.fetchRecentInbox(params(), limit = 50).first().isRead, "should be marked read") + assertTrue(client.fetchRecent(params(), "INBOX", limit = 50).first().isRead, "should be marked read") } @Test @@ -82,9 +88,61 @@ class ImapClientTest { GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Invoice 42", "Payment due") greenMail.waitForIncomingEmail(2) - val results = client.search(params(), query = "Vacation", limit = 50) + val results = client.search(params(), "INBOX", query = "Vacation", limit = 50) assertEquals(1, results.size) assertEquals("Vacation plans", results.first().subject) } + + @Test + fun `listFolders includes a created non-inbox folder`() = runTest { + appendMessage("Archive", "bob@example.org", "Archived", "Stored away") + + val names = client.listFolders(params()).map { it.fullName } + + assertTrue(names.any { it.equals("INBOX", ignoreCase = true) }) + assertTrue(names.any { it.equals("Archive", ignoreCase = true) }, "folders=$names") + } + + @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") + greenMail.waitForIncomingEmail(1) + appendMessage("Archive", "carol@example.org", "Archived subject", "In the archive") + + val archive = client.fetchRecent(params(), "Archive", limit = 50) + assertEquals(1, archive.size) + assertEquals("Archived subject", archive.first().subject) + + // The archived message must not leak into the inbox (UIDs are per-folder). + val inbox = client.fetchRecent(params(), "INBOX", limit = 50) + assertEquals(setOf("Inbox subject"), inbox.map { it.subject }.toSet()) + } + + /** Creates [folderName] if needed and appends a message to it, via Jakarta Mail directly. */ + private fun appendMessage(folderName: String, from: String, subject: String, body: String) { + val props = Properties().apply { + put("mail.store.protocol", "imap") + put("mail.imap.host", "127.0.0.1") + put("mail.imap.port", greenMail.imap.port.toString()) + } + val session = Session.getInstance(props) + val store = session.getStore("imap") + store.connect("127.0.0.1", greenMail.imap.port, "alice@example.org", "secret") + try { + val folder = store.getFolder(folderName) + if (!folder.exists()) folder.create(Folder.HOLDS_MESSAGES) + folder.open(Folder.READ_WRITE) + val message = MimeMessage(session).apply { + setFrom(InternetAddress(from)) + setRecipient(Message.RecipientType.TO, InternetAddress("alice@example.org")) + this.subject = subject + setText(body) + } + folder.appendMessages(arrayOf(message)) + folder.close(false) + } finally { + store.close() + } + } } diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt new file mode 100644 index 0000000..6abf8cd --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.mailbox + +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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.sync.MailSyncer +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.domain.repository.AccountRepository +import org.libremail.domain.repository.MailRepository + +@OptIn(ExperimentalCoroutinesApi::class) +class MailboxViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(testDispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private val alice = account("imap:a", "alice@example.org") + private val bob = account("imap:b", "bob@example.org") + + @Test + fun `default view shows only inbox messages across all accounts`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice, bob), + messages = listOf( + msg("imap:a:INBOX:1", "imap:a", "INBOX"), + msg("imap:a:Archive:1", "imap:a", "Archive"), + msg("imap:b:INBOX:1", "imap:b", "INBOX"), + ), + ) + backgroundScope.launch { vm.messages.collect {} } + + assertEquals("INBOX", vm.selectedFolder.value) + assertNull(vm.selectedAccountId.value) + assertEquals(setOf("imap:a:INBOX:1", "imap:b:INBOX:1"), vm.messages.value.map { it.id }.toSet()) + } + + @Test + fun `selecting a folder scopes the view to that account and folder and syncs it`() = runTest(testDispatcher) { + val syncer = mockk(relaxed = true) + val vm = createViewModel( + accounts = listOf(alice), + messages = listOf( + msg("imap:a:INBOX:1", "imap:a", "INBOX"), + msg("imap:a:Archive:1", "imap:a", "Archive"), + ), + syncer = syncer, + ) + backgroundScope.launch { vm.messages.collect {} } + + vm.selectFolder("imap:a", "Archive") + + assertEquals("Archive", vm.selectedFolder.value) + assertEquals("imap:a", vm.selectedAccountId.value) + assertEquals(listOf("imap:a:Archive:1"), vm.messages.value.map { it.id }) + coVerify { syncer.syncFolder("imap:a", "Archive") } + } + + @Test + fun `folders expose the drawer account's folders in order`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice), + messages = emptyList(), + folders = mapOf( + "imap:a" to listOf( + folder("imap:a", "INBOX", FolderRole.INBOX), + folder("imap:a", "[Gmail]/Sent Mail", FolderRole.SENT), + ), + ), + ) + backgroundScope.launch { vm.folders.collect {} } + + assertEquals(listOf("INBOX", "[Gmail]/Sent Mail"), vm.folders.value.map { it.fullName }) + } + + @Test + fun `folders always include an inbox entry even before a folder refresh`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice), + messages = emptyList(), + folders = mapOf("imap:a" to listOf(folder("imap:a", "Receipts", FolderRole.NORMAL))), + ) + backgroundScope.launch { vm.folders.collect {} } + + assertTrue(vm.folders.value.any { it.fullName == "INBOX" }, "drawer must always offer an inbox") + } + + @Test + fun `selectUnifiedInbox returns to the unified inbox`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice, bob), + messages = listOf(msg("imap:a:Archive:1", "imap:a", "Archive")), + ) + backgroundScope.launch { vm.messages.collect {} } + vm.selectFolder("imap:a", "Archive") + assertEquals("Archive", vm.selectedFolder.value) + + vm.selectUnifiedInbox() + + assertEquals("INBOX", vm.selectedFolder.value) + assertNull(vm.selectedAccountId.value) + } + + @Test + fun `setDrawerAccount switches which account's folders the drawer shows`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice, bob), + messages = emptyList(), + folders = mapOf( + "imap:a" to listOf(folder("imap:a", "INBOX", FolderRole.INBOX), folder("imap:a", "Archive", FolderRole.ARCHIVE)), + "imap:b" to listOf(folder("imap:b", "INBOX", FolderRole.INBOX), folder("imap:b", "Work", FolderRole.NORMAL)), + ), + ) + backgroundScope.launch { vm.folders.collect {} } + // The drawer defaults to the first account. + assertTrue(vm.folders.value.any { it.fullName == "Archive" }) + + vm.setDrawerAccount("imap:b") + + assertTrue(vm.folders.value.any { it.fullName == "Work" }, "drawer should now show bob's folders") + assertTrue(vm.folders.value.none { it.fullName == "Archive" }, "alice's folders should no longer show") + } + + private fun createViewModel( + accounts: List, + messages: List, + folders: Map> = emptyMap(), + syncer: MailSyncer = 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()) + accounts.forEach { account -> + every { repo.observeFolders(account.id) } returns MutableStateFlow(folders[account.id] ?: emptyList()) + } + val accountRepository = mockk(relaxed = true) + every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts) + return MailboxViewModel(repo, accountRepository, syncer) + } + + private fun account(id: String, email: String) = Account( + id = id, + email = email, + displayName = email, + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) + + private fun folder(accountId: String, fullName: String, role: FolderRole) = + Folder(accountId, fullName, fullName.substringAfterLast('/'), role, selectable = true) + + private fun msg(id: String, accountId: String, folder: String) = Message( + id = id, + accountId = accountId, + sender = "Sender", + senderEmail = "sender@example.org", + subject = "Subject", + snippet = "", + body = "", + isHtml = false, + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + folder = folder, + inInbox = true, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc252c4..1e099d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ appauth = "0.11.1" angusMail = "2.0.5" junit = "4.13.2" androidxJunit = "1.2.1" -espresso = "3.6.1" +espresso = "3.7.0" turbine = "1.2.1" mockk = "1.14.11" greenmail = "2.1.9"