diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6c8877f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +name: CI + +on: + pull_request: + branches: [main] + +# A new push to a PR cancels any in-flight run for that PR. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # SDK packages this project builds against (compileSdk 37 / build-tools 37.0.0). + # Quote the package ids when passed to sdkmanager — the ';' is a shell separator. + ANDROID_PLATFORM: "platforms;android-37.0" + ANDROID_BUILD_TOOLS: "build-tools;37.0.0" + +jobs: + debug-build: + name: Debug build + runs-on: ubuntu-latest + 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: Install SDK platform and build-tools + run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Assemble debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: debug-apk + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + 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: Install SDK platform and build-tools + run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Run unit tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Upload unit test report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unit-test-report + path: app/build/reports/tests/testDebugUnitTest/ + if-no-files-found: warn + + instrumented-tests: + name: Instrumented / UI tests (emulator) + 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] + 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: Install SDK platform and build-tools + run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + - 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 + + - name: Cache AVD snapshot + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }}-google_apis-x86_64 + + # On a cache miss, cold-boot the emulator once so its snapshot can be cached, + # making subsequent runs start from a warm snapshot. + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 + with: + api-level: ${{ matrix.api-level }} + target: google_apis + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run instrumented tests + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 + with: + api-level: ${{ matrix.api-level }} + target: google_apis + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew connectedDebugAndroidTest --stacktrace + + - name: Upload instrumented test report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: instrumented-test-report-api${{ matrix.api-level }} + path: app/build/reports/androidTests/connected/ + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4248315 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +name: Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to create (e.g. v0.1.0)" + required: true + type: string + prerelease: + description: "Mark this GitHub release as a pre-release" + required: false + type: boolean + default: true + +# Needed to create the tag, the GitHub release, and upload its assets. +permissions: + contents: write + +env: + ANDROID_PLATFORM: "platforms;android-37.0" + ANDROID_BUILD_TOOLS: "build-tools;37.0.0" + +jobs: + release: + name: Build APK and publish release + runs-on: ubuntu-latest + 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: Install SDK platform and build-tools + run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + # No release keystore is configured in CI, so the build falls back to the debug + # signing key (installable for testing, not for store publication). + - name: Assemble release APK + run: ./gradlew assembleRelease --stacktrace + + - name: Stage release artifacts (APK + source archives) + run: | + set -euo pipefail + tag='${{ inputs.tag }}' + mkdir -p dist + apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)" + cp "$apk" "dist/LibreMail-${tag}.apk" + # Source archives contain only git-tracked files at the released commit. + git archive --format=zip --prefix="LibreMail-${tag}/" -o "dist/LibreMail-${tag}-src.zip" HEAD + git archive --format=tar --prefix="LibreMail-${tag}/" HEAD | gzip > "dist/LibreMail-${tag}-src.tar.gz" + ls -l dist + + - name: Create GitHub release + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + tag_name: ${{ inputs.tag }} + name: ${{ inputs.tag }} + target_commitish: ${{ github.sha }} + prerelease: ${{ inputs.prerelease }} + generate_release_notes: true + fail_on_unmatched_files: true + files: | + dist/LibreMail-${{ inputs.tag }}.apk + dist/LibreMail-${{ inputs.tag }}-src.zip + dist/LibreMail-${{ inputs.tag }}-src.tar.gz diff --git a/README.md b/README.md index aa1e4eb..6cc4ab4 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,25 @@ A free and open-source email client for Android, built with Kotlin, Jetpack Compose and Material 3 (Material You). LibreMail aims for a friendly default experience with power-user features tucked under an **Advanced Settings** group. -> Status: **early scaffold.** The app builds and runs with a themed, navigable -> shell and the full architecture skeleton in place. Account sign-in, IMAP/SMTP -> sync and sending are implemented in subsequent increments. +> Status: **in development.** Material You shell; **account setup** (Gmail OAuth via +> AppAuth/PKCE and generic IMAP/SMTP, with a live connection test and Keystore- +> encrypted credentials); **IMAP receive** — background sync (WorkManager) into a local +> Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and +> rendered in a hardened WebView (JavaScript off, remote images blocked by default), +> with mark-read, star, and delete; and **composing** — a compose screen with device- +> contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued +> and retried, with a viewable outbox folder), plus reply and **drafts** saved for +> later; **on-device new-mail +> notifications** (no push service) with persisted settings; **instant push** via a +> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a +> system viewer, and attach files when composing; **multiple accounts** — a unified inbox +> with per-account filtering; and +> **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft** +> accounts (OAuth 2.0 sign-in, IMAP receive + Microsoft Graph send, SMTP/XOAUTH2 fallback). ## Features (target MVP) -- Send and receive email with **Gmail** (OAuth 2.0) and **any IMAP/SMTP** provider. +- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider. - Material You dynamic theming, light/dark, edge-to-edge. - Clean compose screen with phone/account contacts integration. - Modern security: OAuth 2.0 Authorization Code + PKCE, no stored passwords for Gmail. @@ -77,6 +89,25 @@ assessment for the restricted scope. 5. Copy `secrets.properties.example` to `secrets.properties` (git-ignored) and set `GMAIL_OAUTH_CLIENT_ID` to your client ID. The build injects it via `BuildConfig`. +## Outlook / Microsoft account setup (OAuth client) + +Outlook uses the Microsoft identity platform with OAuth 2.0 + PKCE (no client secret). Send +goes through Microsoft **Graph** (`sendMail`, their preferred API) with SMTP/XOAUTH2 as a +fallback; receive is **IMAP**. Graph and Exchange Online are separate resources, so one +consent grants every scope and per-resource access tokens are minted from the one refresh +token. A working client ID ships with the build; to use your own Azure app registration: + +1. [Azure portal](https://portal.azure.com/) → **App registrations → New registration.** + Supported account types: *Accounts in any organizational directory and personal Microsoft + accounts*. +2. **Authentication → Add a platform → Mobile and desktop applications**; add the redirect + URI `org.libremail.outlook://oauth2redirect` and enable **Allow public client flows**. +3. **API permissions** (delegated): **Microsoft Graph → `Mail.Send`** (primary send), plus + **Office 365 Exchange Online → `IMAP.AccessAsUser.All` and `SMTP.Send`** (receive + SMTP + fallback). `openid`/`email`/`offline_access` come from OIDC. +4. Copy the **Application (client) ID** into `secrets.properties` as + `OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default). + ## Architecture Offline-first, unidirectional, layered: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d4a9acc..96346ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,26 @@ val secrets = Properties().apply { } val gmailOAuthClientId: String = secrets.getProperty("GMAIL_OAUTH_CLIENT_ID", "") +// For a Google installed-app OAuth client, AppAuth's redirect is the reversed client +// id as a custom URI scheme. Fall back to a placeholder so the manifest stays valid +// until a real client id is set in secrets.properties. +val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleusercontent.com")) { + "com.googleusercontent.apps." + gmailOAuthClientId.removeSuffix(".apps.googleusercontent.com") +} else { + "org.libremail.oauth" +} + +// Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via +// secrets.properties; defaults to the app's registered client id. +val outlookOAuthClientId: String = secrets.getProperty( + "OUTLOOK_OAUTH_CLIENT_ID", + "04e4aa5e-ed1f-47f9-b567-b99a0b29b3df", +) + +// Optional release signing, configured via git-ignored secrets.properties. When absent, release +// builds fall back to the debug key (installable for testing, but not publishable). +val releaseStoreFile: String? = secrets.getProperty("RELEASE_STORE_FILE") + android { namespace = "org.libremail" compileSdk = 37 @@ -32,17 +52,38 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"") - // AppAuth redirect scheme (consumed when OAuth is wired up in a later increment). - manifestPlaceholders["appAuthRedirectScheme"] = "org.libremail.app" + buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"") + buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"") + buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"") + // AppAuth captures the OAuth redirect via this custom scheme. + manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme + } + + signingConfigs { + if (releaseStoreFile != null) { + create("release") { + storeFile = file(releaseStoreFile) + storePassword = secrets.getProperty("RELEASE_STORE_PASSWORD") + keyAlias = secrets.getProperty("RELEASE_KEY_ALIAS") + keyPassword = secrets.getProperty("RELEASE_KEY_PASSWORD") + } + } } buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + // Use a dedicated release keystore when configured in secrets.properties; otherwise fall + // back to the debug key so the build is still installable for local testing. + signingConfig = if (releaseStoreFile != null) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } @@ -65,6 +106,11 @@ android { } } +// Export Room schemas so migrations can be validated by instrumented MigrationTestHelper tests. +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -74,6 +120,10 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.kotlinx.coroutines.android) + // Email transport (IMAP/SMTP) + OAuth + implementation(libs.angus.mail) + implementation(libs.appauth) + implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) @@ -85,17 +135,27 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) + compileOnly(libs.error.prone.annotations) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) + implementation(libs.sqlcipher.android) testImplementation(libs.junit) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.turbine) testImplementation(libs.mockk) + testImplementation(libs.greenmail) + // The real org.json for unit tests (android.jar ships a stubbed, no-op version). + testImplementation("org.json:json:20231013") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index dc533d7..5b4301b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,14 +1,32 @@ # SPDX-License-Identifier: GPL-3.0-or-later -# LibreMail ProGuard/R8 rules. -# -# Release builds are currently not minified (see app/build.gradle.kts). When -# minification is enabled for release, add keep rules for the email/auth stack: -# -# Jakarta / Angus Mail (reflection-based provider registration): -# -keep class jakarta.mail.** { *; } -# -keep class org.eclipse.angus.mail.** { *; } -# -keep class com.sun.mail.** { *; } -# -dontwarn jakarta.activation.** -# -# AppAuth: -# -keep class net.openid.appauth.** { *; } +# R8/ProGuard keep rules for LibreMail's release build. + +# --- Jakarta / Angus Mail (IMAP/SMTP) --- +# Protocol providers (IMAP/SMTP stores and transports) are discovered via reflection and +# META-INF service files, so keep the mail and activation classes and their members intact. +-keep class jakarta.mail.** { *; } +-keep class jakarta.activation.** { *; } +-keep class org.eclipse.angus.mail.** { *; } +-keep class org.eclipse.angus.activation.** { *; } +-dontwarn jakarta.mail.** +-dontwarn jakarta.activation.** +-dontwarn org.eclipse.angus.** +-dontwarn com.sun.activation.** + +# --- AppAuth --- +# AppAuth (de)serializes its models (AuthState, token responses) reflectively. +-keep class net.openid.appauth.** { *; } +-dontwarn net.openid.appauth.** + +# --- Strip debug/verbose logging from release builds --- +# Drops Log.d/Log.v calls (and the evaluation of their arguments) so nothing like an +# account address is ever written to logcat in a release build. +-assumenosideeffects class android.util.Log { + public static *** d(...); + public static *** v(...); +} + +# --- SQLCipher (opt-in encrypted cache) --- +# JNI-bound classes referenced by the native library; keep them intact. +-keep class net.zetetic.database.** { *; } +-dontwarn net.zetetic.database.** diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/7.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/7.json new file mode 100644 index 0000000..1da2740 --- /dev/null +++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/7.json @@ -0,0 +1,404 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "05d29b502db9e50c01ddcdf986be1660", + "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, `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": "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" + ] + } + } + ], + "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, '05d29b502db9e50c01ddcdf986be1660')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt new file mode 100644 index 0000000..bf0cb7a --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.entity.MessageEntity + +/** + * Round-trips the cache database through [DatabaseEncryption] (plaintext → encrypted → plaintext), + * asserting the data and Room's schema version survive and the on-disk file is no longer readable + * as plaintext once encrypted. Requires a device/emulator — it loads SQLCipher's native library. + */ +@RunWith(AndroidJUnit4::class) +class DatabaseEncryptionTest { + + private val context = ApplicationProvider.getApplicationContext() + private val dbName = "enc_roundtrip_test.db" + private val dbFile: File get() = context.getDatabasePath(dbName) + + // 64 hex chars == a 32-byte SQLCipher passphrase. + private val passphrase = "0123456789abcdef".repeat(4) + + @Before + @After + fun clean() { + context.deleteDatabase(dbName) + dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() } + } + + @Test + fun encryptsDecryptsAndPreservesData() = runBlocking { + // Start with a plaintext Room database holding one row. + openPlaintext().apply { + messageDao().insertNew(listOf(message("acct:1"))) + close() + } + assertFalse("freshly created DB is plaintext", DatabaseEncryption.isEncrypted(dbFile)) + + // Encrypt in place, then open through SQLCipher and confirm the row + schema survived. + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile)) + openEncrypted().apply { + assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id }) + close() + } + + // Decrypt back to plaintext and confirm the row is still there. + DatabaseEncryption.ensurePlaintext(dbFile, passphrase) + assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile)) + openPlaintext().apply { + assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id }) + close() + } + } + + private fun openPlaintext(): LibreMailDatabase = + Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build() + + private fun openEncrypted(): LibreMailDatabase = + Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName) + .openHelperFactory(SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false)) + .build() + + private fun message(id: String) = MessageEntity( + id = id, + accountId = "acct", + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "", + body = "", + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + ) +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt new file mode 100644 index 0000000..0c106c9 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +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.MessageEntity + +/** + * Schema-behavior tests on the real (v7) Room database. (Migrations from versions before + * exportSchema was enabled can't be replayed with MigrationTestHelper, since their schema JSONs + * were never exported; exportSchema is now on so future migrations can be tested.) + */ +@RunWith(AndroidJUnit4::class) +class LibreMailDatabaseTest { + + private lateinit var db: LibreMailDatabase + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build() + } + + @After + fun tearDown() = db.close() + + private fun message(id: String, body: String = "") = MessageEntity( + id = id, + accountId = "acct", + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "", + body = body, + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + ) + + @Test + fun deletingMessageCascadesToItsAttachments() = runBlocking { + val messageDao = db.messageDao() + val attachmentDao = db.attachmentDao() + messageDao.insertNew(listOf(message("acct:1"))) + attachmentDao.insert(listOf(AttachmentEntity("acct:1", 0, "report.pdf", "application/pdf", 10))) + assertEquals(1, attachmentDao.observeForMessage("acct:1").first().size) + + messageDao.deleteById("acct:1") + + assertTrue( + "attachment rows must cascade-delete with their message", + attachmentDao.observeForMessage("acct:1").first().isEmpty(), + ) + } + + @Test + fun searchRowsAreNotInboxAndAreCleared() = runBlocking { + val messageDao = db.messageDao() + 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")) + + messageDao.deleteSearchRows() + val remaining = messageDao.observeAll().first().map { it.id } + assertEquals(listOf("acct:1"), remaining) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/security/KeystoreCryptoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/security/KeystoreCryptoTest.kt new file mode 100644 index 0000000..b2c0685 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/security/KeystoreCryptoTest.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KeystoreCryptoTest { + + private val crypto = KeystoreCrypto() + + @Test + fun encrypt_then_decrypt_returns_original() { + val secret = "imap:app-password 🔒 {\"json\":true}" + val encrypted = crypto.encrypt(secret) + assertNotEquals(secret, encrypted) + assertEquals(secret, crypto.decrypt(encrypted)) + } + + @Test + fun encrypt_uses_a_fresh_iv_each_time() { + // Same plaintext must not produce identical ciphertext (random GCM IV). + assertNotEquals(crypto.encrypt("same"), crypto.encrypt("same")) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/mail/ImapProviderAndroidTest.kt b/app/src/androidTest/kotlin/org/libremail/mail/ImapProviderAndroidTest.kt new file mode 100644 index 0000000..2b8e5f4 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/mail/ImapProviderAndroidTest.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import jakarta.mail.Session +import java.util.Properties +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Guards the main "Jakarta/Angus Mail on Android" risk: that provider registration + * (META-INF/javamail.*) survives packaging, so IMAP/SMTP providers resolve at runtime. + */ +@RunWith(AndroidJUnit4::class) +class ImapProviderAndroidTest { + + @Test + fun imap_and_imaps_store_providers_resolve() { + val session = Session.getInstance(Properties()) + assertNotNull(session.getStore("imaps")) + assertNotNull(session.getStore("imap")) + } + + @Test + fun smtp_transport_provider_resolves() { + val session = Session.getInstance(Properties()) + assertNotNull(session.getTransport("smtp")) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/AppSmokeTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/AppSmokeTest.kt deleted file mode 100644 index f165773..0000000 --- a/app/src/androidTest/kotlin/org/libremail/ui/AppSmokeTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.ui - -import androidx.compose.material3.Text -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.libremail.ui.theme.LibreMailTheme - -@RunWith(AndroidJUnit4::class) -class AppSmokeTest { - - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun theme_rendersContent() { - composeTestRule.setContent { - LibreMailTheme { - Text("LibreMail") - } - } - composeTestRule.onNodeWithText("LibreMail").assertExists() - } -} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt new file mode 100644 index 0000000..98ff1f0 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui + +import java.io.File +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +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.ImapConnectionParams +import org.libremail.domain.model.Message +import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.model.OutgoingMessage +import org.libremail.domain.repository.AccountRepository +import org.libremail.domain.repository.MailRepository + +/** + * In-memory [AccountRepository] for Compose UI tests: serves a fixed account list, records the + * account/password handed to [addImapAccount], and returns a configurable connection-test result. + */ +class FakeAccountRepository( + accounts: List = emptyList(), + private var result: Result> = Result.success(listOf("INBOX")), +) : AccountRepository { + + private val accountsFlow = MutableStateFlow(accounts) + + var addedAccount: Account? = null + private set + var addedPassword: String? = null + private set + + override fun observeAccounts(): Flow> = accountsFlow + + override suspend fun testConnection(params: ImapConnectionParams): Result> = result + + override suspend fun addImapAccount(account: Account, password: String): Result> { + addedAccount = account + addedPassword = password + return result + } + + override suspend fun addOutlookAccount( + email: String, + accessToken: String, + authStateJson: String, + ): Result> = result + + override suspend fun deleteAccount(id: String) { + accountsFlow.value = accountsFlow.value.filterNot { it.id == id } + } +} + +/** + * In-memory [MailRepository] for Compose UI tests: records sent messages / saved drafts and returns + * a configurable [sendResult]. Read paths emit empty so screens render their empty states. + */ +class FakeMailRepository( + var sendResult: Result = Result.success(Unit), +) : MailRepository { + + val sentMessages = mutableListOf() + val savedDrafts = mutableListOf() + val deletedDraftIds = mutableListOf() + + override fun observeMessages(): Flow> = flowOf(emptyList()) + + override suspend fun getMessage(id: String): Message? = null + + override suspend fun openMessage(id: String): Result = + Result.failure(UnsupportedOperationException("not used in UI tests")) + + override fun observeAttachments(messageId: String): Flow> = flowOf(emptyList()) + + override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result = + Result.failure(UnsupportedOperationException("not used in UI tests")) + + override suspend fun setStarred(id: String, starred: Boolean): Result = Result.success(Unit) + + override suspend fun deleteMessage(id: String): Result = Result.success(Unit) + + override suspend fun sendMessage(outgoing: OutgoingMessage): Result { + sentMessages += outgoing + return sendResult + } + + override fun observeDrafts(): Flow> = flowOf(emptyList()) + + override suspend fun getDraft(id: String): Draft? = null + + override suspend fun saveDraft(draft: Draft) { + savedDrafts += draft + } + + override suspend fun deleteDraft(id: String) { + deletedDraftIds += id + } + + override fun observeOutbox(): Flow> = flowOf(emptyList()) + + override suspend fun cancelOutboxMessage(id: String) {} + + override suspend fun retryOutbox() {} + + override suspend fun searchServer(query: String) {} + + override suspend fun clearSearchResults() {} +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/LibreMailBottomBarTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/LibreMailBottomBarTest.kt new file mode 100644 index 0000000..7144ed9 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/LibreMailBottomBarTest.kt @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui + +import androidx.activity.ComponentActivity +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.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme + +/** UI test for the shared bottom navigation bar: it renders both tabs and reports the tapped tab. */ +@RunWith(AndroidJUnit4::class) +class LibreMailBottomBarTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + @Test + fun rendersBothTabs_andEmitsSelectionOnTap() { + var selected: TopDest? = null + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = { selected = it }) + } + } + + composeTestRule.onNodeWithText(string(R.string.nav_mailbox)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.nav_settings)).assertIsDisplayed() + + composeTestRule.onNodeWithText(string(R.string.nav_settings)).performClick() + assertEquals(TopDest.SETTINGS, selected) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt new file mode 100644 index 0000000..41780d3 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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.ui.FakeAccountRepository +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for manual IMAP/SMTP account setup. Drives the real [ManualSetupScreen] and a + * real [ManualSetupViewModel] backed by an in-memory [FakeAccountRepository]. + */ +@RunWith(AndroidJUnit4::class) +class ManualSetupScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + // Build the view model once and capture it, so recomposition doesn't recreate it. + private fun setContent( + repository: FakeAccountRepository = FakeAccountRepository(), + onAccountAdded: () -> Unit = {}, + ) { + val viewModel = ManualSetupViewModel(repository) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ManualSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel) + } + } + } + + private fun fillValidForm() { + composeTestRule.onNodeWithText(string(R.string.manual_email)).performTextInput("user@example.com") + composeTestRule.onNodeWithText(string(R.string.manual_password)).performTextInput("app-password") + composeTestRule.onNodeWithText(string(R.string.manual_imap_server)).performTextInput("imap.example.com") + composeTestRule.onNodeWithText(string(R.string.manual_smtp_server)).performTextInput("smtp.example.com") + } + + @Test + fun testAndAddButton_isDisabledUntilRequiredFieldsAreFilled() { + setContent() + composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsNotEnabled() + fillValidForm() + composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsEnabled() + } + + @Test + fun advancedToggle_revealsPortField() { + setContent() + composeTestRule.onNodeWithText(string(R.string.manual_imap_port)).assertDoesNotExist() + composeTestRule.onNodeWithText(string(R.string.settings_advanced)).performClick() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithText(string(R.string.manual_imap_port)).fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText(string(R.string.manual_imap_port)).assertExists() + } + + @Test + fun submit_onSuccess_invokesOnAccountAddedWithEnteredCredentials() { + val repository = FakeAccountRepository(result = Result.success(listOf("INBOX"))) + var added = false + setContent(repository) { added = true } + + fillValidForm() + composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).performClick() + + composeTestRule.waitUntil(timeoutMillis = 5_000) { added } + assertTrue(added) + assertEquals("user@example.com", repository.addedAccount?.email) + assertEquals("app-password", repository.addedPassword) + } + + @Test + fun submit_onFailure_showsErrorAndStaysOnScreen() { + val repository = FakeAccountRepository(result = Result.failure(RuntimeException("Login failed"))) + var added = false + setContent(repository) { added = true } + + fillValidForm() + composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).performClick() + + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithText("Login failed").fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText("Login failed").assertExists() + assertFalse(added) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt new file mode 100644 index 0000000..063150f --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose + +import android.Manifest +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.SavedStateHandle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.contacts.ContactsRepository +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.ui.FakeAccountRepository +import org.libremail.ui.FakeMailRepository +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for composing and sending a message. Drives the real [ComposeScreen] and a + * real [ComposeViewModel] backed by in-memory fakes plus the real (empty) device contacts provider. + */ +@RunWith(AndroidJUnit4::class) +class ComposeScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val account = Account( + id = "imap:me@example.com", + email = "me@example.com", + displayName = "Me", + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS), + ) + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + @Before + fun grantContactsPermission() { + // ComposeScreen requests READ_CONTACTS on first composition; pre-grant it (before the test + // calls setContent) so no system permission dialog appears to block the headless run. + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.uiAutomation.grantRuntimePermission( + instrumentation.targetContext.packageName, + Manifest.permission.READ_CONTACTS, + ) + } + + // Build the view model once and capture it, so recomposition doesn't recreate it. + private fun setContent( + mailRepository: FakeMailRepository = FakeMailRepository(), + onBack: () -> Unit = {}, + ) { + val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + val viewModel = ComposeViewModel( + savedStateHandle = SavedStateHandle(), + mailRepository = mailRepository, + accountRepository = FakeAccountRepository(accounts = listOf(account)), + contactsRepository = ContactsRepository(context), + ) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ComposeScreen(onBack = onBack, viewModel = viewModel) + } + } + } + + @Test + fun sendButton_isDisabledUntilRecipientEntered() { + setContent() + composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsNotEnabled() + composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com") + composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsEnabled() + } + + @Test + fun send_withRecipient_sendsMessageAndClosesScreen() { + val mailRepository = FakeMailRepository() + var closed = false + setContent(mailRepository) { closed = true } + + composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com") + composeTestRule.onNodeWithText(string(R.string.compose_subject)).performTextInput("Hello") + composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Body text") + composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick() + + composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() } + val sent = mailRepository.sentMessages.single() + assertEquals("you@example.com", sent.to) + assertEquals("Hello", sent.subject) + assertEquals(account.id, sent.accountId) + + composeTestRule.waitUntil(timeoutMillis = 5_000) { closed } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9a46f81..28a6672 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,12 +1,18 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index 6f12a9b..2afb832 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -2,7 +2,72 @@ package org.libremail import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.libremail.data.settings.SettingsRepository +import org.libremail.data.sync.SyncScheduler +import org.libremail.domain.repository.AccountRepository +import org.libremail.push.IdlePushManager @HiltAndroidApp -class LibreMailApplication : Application() +class LibreMailApplication : Application(), Configuration.Provider { + + @Inject lateinit var workerFactory: HiltWorkerFactory + + @Inject lateinit var syncScheduler: SyncScheduler + + @Inject lateinit var settingsRepository: SettingsRepository + + @Inject lateinit var accountRepository: AccountRepository + + @Inject lateinit var idlePushManager: IdlePushManager + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** Whether the IDLE push service should currently be running (push enabled AND an account exists). */ + @Volatile + private var pushShouldBeActive = false + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + override fun onCreate() { + super.onCreate() + syncScheduler.schedulePeriodicSync() + // Run the IMAP IDLE push service only while it has something to do: the push setting is on + // AND at least one account exists. This starts it when the first account is added and stops + // it when the last is removed, reactively. + appScope.launch { + combine( + settingsRepository.settings.map { it.pushIdle }, + accountRepository.observeAccounts().map { it.isNotEmpty() }, + ) { pushEnabled, hasAccounts -> pushEnabled && hasAccounts } + .distinctUntilChanged() + .collect { active -> + pushShouldBeActive = active + if (active) idlePushManager.start() else idlePushManager.stop() + } + } + } + + /** + * Re-attempts starting the IDLE push service. A start from the background can be blocked + * (ForegroundServiceStartNotAllowedException) and is swallowed; because the active/inactive + * state hasn't changed, the collector above won't retry, so the foreground (MainActivity) calls + * this to recover. Safe to call repeatedly — starting an already-running service is a no-op. + */ + fun ensurePushStarted() { + if (pushShouldBeActive) idlePushManager.start() + } +} diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index 377fdc9..069fd6a 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -1,23 +1,59 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail +import android.Manifest +import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import org.libremail.data.settings.SettingsRepository import org.libremail.ui.LibreMailApp import org.libremail.ui.theme.LibreMailTheme @AndroidEntryPoint class MainActivity : ComponentActivity() { + + @Inject + lateinit var settingsRepository: SettingsRepository + + override fun onStart() { + super.onStart() + // Foreground: recover IDLE push if a background start was previously blocked. + (application as? LibreMailApplication)?.ensurePushStarted() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - LibreMailTheme { + val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true) + LibreMailTheme(dynamicColor = dynamicColor) { + NotificationPermissionEffect() LibreMailApp() } } } } + +/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */ +@Composable +private fun NotificationPermissionEffect() { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} + LaunchedEffect(Unit) { + 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/auth/GmailAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt new file mode 100644 index 0000000..3a32dba --- /dev/null +++ b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.auth + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenResponse +import org.json.JSONObject +import org.libremail.BuildConfig + +/** + * Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted + * `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2. + */ +@Singleton +class GmailAuthManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val serviceConfig = AuthorizationServiceConfiguration( + Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"), + Uri.parse("https://oauth2.googleapis.com/token"), + ) + + /** False until a Google OAuth client id is provided in secrets.properties (see README). */ + val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank() + + fun createAuthIntent(): Intent { + val request = AuthorizationRequest.Builder( + serviceConfig, + BuildConfig.GMAIL_OAUTH_CLIENT_ID, + ResponseTypeValues.CODE, + Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI), + ) + .setScope("openid email profile https://mail.google.com/") + .build() + return AuthorizationService(context).getAuthorizationRequestIntent(request) + } + + suspend fun exchangeToken(responseIntent: Intent): OAuthResult { + val response = AuthorizationResponse.fromIntent(responseIntent) + val exception = AuthorizationException.fromIntent(responseIntent) + if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled") + + val service = AuthorizationService(context) + try { + val tokenResponse = suspendCancellableCoroutine { continuation -> + service.performTokenRequest(response.createTokenExchangeRequest()) { token, error -> + if (token != null) { + continuation.resume(token) + } else { + continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed")) + } + } + } + val authState = AuthState(response, exception).apply { update(tokenResponse, null) } + val email = emailFromIdToken(tokenResponse.idToken) + ?: throw IllegalStateException("Could not read the account email from the token") + return OAuthResult( + email = email, + accessToken = tokenResponse.accessToken.orEmpty(), + authStateJson = authState.jsonSerializeString(), + ) + } finally { + service.dispose() + } + } + + /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */ + suspend fun freshAccessToken(authStateJson: String): FreshToken { + val authState = AuthState.jsonDeserialize(authStateJson) + val service = AuthorizationService(context) + try { + val accessToken = suspendCancellableCoroutine { continuation -> + authState.performActionWithFreshTokens(service) { token, _, error -> + if (token != null) { + continuation.resume(token) + } else { + continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed")) + } + } + } + return FreshToken( + accessToken = accessToken, + authStateJson = authState.jsonSerializeString(), + accessTokenExpiry = authState.accessTokenExpirationTime, + ) + } finally { + service.dispose() + } + } + + private fun emailFromIdToken(idToken: String?): String? { + if (idToken.isNullOrBlank()) return null + return runCatching { + val payload = idToken.split(".").getOrNull(1) ?: return null + val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + JSONObject(json).optString("email").ifBlank { null } + }.getOrNull() + } +} diff --git a/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt new file mode 100644 index 0000000..08cae16 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.auth + +/** Result of a successful OAuth token exchange. */ +data class OAuthResult( + val email: String, + val accessToken: String, + /** Serialized [net.openid.appauth.AuthState], stored encrypted for later token refresh. */ + val authStateJson: String, +) + +/** A freshly-refreshed access token plus the (possibly updated) AuthState to persist. */ +data class FreshToken( + val accessToken: String, + val authStateJson: String, + /** Epoch-millis expiry of [accessToken], when the provider reported one (for caching). */ + val accessTokenExpiry: Long? = null, +) diff --git a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt new file mode 100644 index 0000000..ed2cafd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.auth + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.GrantTypeValues +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenRequest +import org.json.JSONObject +import org.libremail.BuildConfig + +/** + * Outlook / Microsoft OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. + * + * Send goes through Microsoft **Graph** (`sendMail`, their first-class/preferred API); IMAP + * receive — and SMTP send as a fallback — go through **Exchange Online**. Those are two distinct + * resources (`graph.microsoft.com` vs `outlook.office.com`), and Microsoft's token endpoint issues + * an access token for one resource per request, so a single consent grants every scope and we mint + * resource-specific access tokens from the one refresh token on demand. The "common" tenant accepts + * both personal Microsoft accounts and work/school (Microsoft 365). + */ +@Singleton +class OutlookAuthManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val serviceConfig = AuthorizationServiceConfiguration( + Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"), + Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/token"), + ) + + /** Outlook is always available: the Microsoft client id ships with the build (it is not a secret). */ + val isConfigured: Boolean get() = BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank() + + fun createAuthIntent(): Intent { + val request = AuthorizationRequest.Builder( + serviceConfig, + BuildConfig.OUTLOOK_OAUTH_CLIENT_ID, + ResponseTypeValues.CODE, + Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI), + ) + // One consent covering both resources; per-resource access tokens are minted later. + .setScope("openid email $OFFLINE $GRAPH_SCOPE $OUTLOOK_SCOPE") + .build() + return AuthorizationService(context).getAuthorizationRequestIntent(request) + } + + suspend fun exchangeToken(responseIntent: Intent): OAuthResult { + val response = AuthorizationResponse.fromIntent(responseIntent) + val exception = AuthorizationException.fromIntent(responseIntent) + if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled") + + val service = AuthorizationService(context) + try { + val tokenResponse = suspendCancellableCoroutine { continuation -> + service.performTokenRequest(response.createTokenExchangeRequest()) { token, error -> + if (token != null) { + continuation.resume(token) + } else { + continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed")) + } + } + } + val authState = AuthState(response, exception).apply { update(tokenResponse, null) } + val email = emailFromIdToken(tokenResponse.idToken) + ?: throw IllegalStateException("Could not read the account email from the token") + // Mint an Exchange Online token so the caller can verify the account over IMAP. + val outlook = refreshForScope(authState, OUTLOOK_SCOPE) + return OAuthResult( + email = email, + accessToken = outlook.accessToken, + authStateJson = outlook.authStateJson, + ) + } finally { + service.dispose() + } + } + + /** A fresh Exchange Online (outlook.office.com) token for IMAP receive and SMTP-fallback send. */ + suspend fun freshOutlookToken(authStateJson: String): FreshToken = + refreshForScope(AuthState.jsonDeserialize(authStateJson), OUTLOOK_SCOPE) + + /** A fresh Microsoft Graph token for the primary `sendMail` send path. */ + suspend fun freshGraphToken(authStateJson: String): FreshToken = + refreshForScope(AuthState.jsonDeserialize(authStateJson), GRAPH_SCOPE) + + /** Redeems the stored refresh token for an access token scoped to a single resource. */ + private suspend fun refreshForScope(authState: AuthState, scope: String): FreshToken { + val refreshToken = authState.refreshToken + ?: throw IllegalStateException("No refresh token available; please sign in again") + val service = AuthorizationService(context) + try { + val request = TokenRequest.Builder(serviceConfig, BuildConfig.OUTLOOK_OAUTH_CLIENT_ID) + .setGrantType(GrantTypeValues.REFRESH_TOKEN) + .setRefreshToken(refreshToken) + .setScope("$OFFLINE $scope") + .build() + val tokenResponse = suspendCancellableCoroutine { continuation -> + service.performTokenRequest(request) { token, error -> + if (token != null) { + continuation.resume(token) + } else { + continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed")) + } + } + } + authState.update(tokenResponse, null) + return FreshToken( + accessToken = tokenResponse.accessToken.orEmpty(), + authStateJson = authState.jsonSerializeString(), + accessTokenExpiry = tokenResponse.accessTokenExpirationTime, + ) + } finally { + service.dispose() + } + } + + /** Microsoft id tokens carry the address in `email`, falling back to `preferred_username`. */ + private fun emailFromIdToken(idToken: String?): String? { + if (idToken.isNullOrBlank()) return null + return runCatching { + val payload = idToken.split(".").getOrNull(1) ?: return null + val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + val claims = JSONObject(json) + claims.optString("email").ifBlank { claims.optString("preferred_username") }.ifBlank { null } + }.getOrNull() + } + + private companion object { + const val OFFLINE = "offline_access" + const val GRAPH_SCOPE = "https://graph.microsoft.com/Mail.Send" + const val OUTLOOK_SCOPE = + "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send" + } +} diff --git a/app/src/main/kotlin/org/libremail/contacts/ContactsRepository.kt b/app/src/main/kotlin/org/libremail/contacts/ContactsRepository.kt new file mode 100644 index 0000000..ec39aa0 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/contacts/ContactsRepository.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.contacts + +import android.content.Context +import android.provider.ContactsContract.CommonDataKinds.Email +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** A device contact match for recipient autocomplete. */ +data class ContactSuggestion( + val name: String, + val email: String, +) + +/** Looks up device contacts (ContactsContract) for recipient autocomplete. */ +@Singleton +class ContactsRepository @Inject constructor( + @ApplicationContext private val context: Context, +) { + /** Returns up to [LIMIT] contacts whose name or email matches [query]. Empty if no permission. */ + suspend fun search(query: String): List = withContext(Dispatchers.IO) { + if (query.length < 2) return@withContext emptyList() + val projection = arrayOf(Email.ADDRESS, Email.DISPLAY_NAME_PRIMARY) + val selection = "${Email.ADDRESS} LIKE ? OR ${Email.DISPLAY_NAME_PRIMARY} LIKE ?" + val pattern = "%$query%" + + val results = mutableListOf() + runCatching { + context.contentResolver.query( + Email.CONTENT_URI, + projection, + selection, + arrayOf(pattern, pattern), + "${Email.DISPLAY_NAME_PRIMARY} ASC", + )?.use { cursor -> + val addressIndex = cursor.getColumnIndexOrThrow(Email.ADDRESS) + val nameIndex = cursor.getColumnIndexOrThrow(Email.DISPLAY_NAME_PRIMARY) + val seen = HashSet() + while (cursor.moveToNext() && results.size < LIMIT) { + val email = cursor.getString(addressIndex)?.trim().orEmpty() + if (email.isEmpty() || !seen.add(email.lowercase())) continue + val name = cursor.getString(nameIndex)?.takeIf { it.isNotBlank() } ?: email + results.add(ContactSuggestion(name, email)) + } + } + } + results + } + + private companion object { + const val LIMIT = 8 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt b/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt new file mode 100644 index 0000000..eefdbae --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.util.Log +import java.io.File +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Converts the Room database file between plaintext and SQLCipher-encrypted form, in place and + * idempotently. It is meant to run at startup — before Room opens the file — so there is never an + * open connection to race with. The on-disk form is detected from the file header (a plaintext + * SQLite file starts with the 16-byte magic "SQLite format 3"; an encrypted one does not), + * so the conversion self-heals: it re-runs after an interrupted attempt and no-ops when already in + * the desired form. + */ +object DatabaseEncryption { + + /** True when the file exists and is NOT a plaintext SQLite database (i.e. it is encrypted). */ + fun isEncrypted(dbFile: File): Boolean = + dbFile.exists() && dbFile.length() >= SQLITE_HEADER.size && !startsWithSqliteHeader(dbFile) + + /** Ensures [dbFile] is SQLCipher-encrypted with [passphrase]; converts an existing plaintext DB. */ + fun ensureEncrypted(dbFile: File, passphrase: String) { + if (!dbFile.exists() || dbFile.length() == 0L) return // fresh DB: the factory creates it encrypted + if (!startsWithSqliteHeader(dbFile)) return // already encrypted + migrate(dbFile, sourcePassphrase = "", targetPassphrase = passphrase) + } + + /** Ensures [dbFile] is plaintext; decrypts an existing SQLCipher DB using [passphrase]. */ + fun ensurePlaintext(dbFile: File, passphrase: String) { + if (!dbFile.exists() || dbFile.length() == 0L) return + if (startsWithSqliteHeader(dbFile)) return // already plaintext + migrate(dbFile, sourcePassphrase = passphrase, targetPassphrase = "") + } + + /** + * Copies the database into a sibling temp file in the target form via `sqlcipher_export`, then + * atomically swaps it into place. An empty passphrase means "no encryption" on that side. Room's + * schema version (`PRAGMA user_version`) is carried across manually — `sqlcipher_export` copies + * tables but not that pragma, and a reset version would make Room attempt a bogus migration. + */ + private fun migrate(dbFile: File, sourcePassphrase: String, targetPassphrase: String) { + ensureLibraryLoaded() + val dir = dbFile.parentFile ?: error("database file has no parent directory") + val tmp = File(dir, dbFile.name + ".migrate").apply { delete() } + + val userVersion: Int + val source = SQLiteDatabase.openOrCreateDatabase( + dbFile.absolutePath, + sourcePassphrase.toByteArray(Charsets.US_ASCII), + null, // no CursorFactory + null, // no DatabaseErrorHandler + ) + try { + userVersion = source.version // PRAGMA user_version — Room's schema version + source.rawExecSQL("PRAGMA journal_mode = DELETE;") // fold any WAL back into the main file + val keyLiteral = targetPassphrase.replace("'", "''") + source.rawExecSQL("ATTACH DATABASE '${tmp.absolutePath}' AS target KEY '$keyLiteral';") + source.rawExecSQL("SELECT sqlcipher_export('target');") + source.rawExecSQL("DETACH DATABASE target;") + } finally { + source.close() + } + + // `sqlcipher_export` copies tables but not PRAGMA user_version; carry Room's schema version + // onto the exported file (on its own main schema — a schema-qualified PRAGMA is rejected) so + // Room doesn't see version 0 and attempt a bogus migration. + check(tmp.length() > 0L) { "sqlcipher_export produced no output database" } + val target = SQLiteDatabase.openOrCreateDatabase( + tmp.absolutePath, + targetPassphrase.toByteArray(Charsets.US_ASCII), + null, + null, + ) + try { + target.rawExecSQL("PRAGMA journal_mode = DELETE;") // no WAL sidecars to orphan on swap + target.version = userVersion + } finally { + target.close() + } + + // Swap the converted file into place; drop any stale WAL/SHM sidecars from either file first. + listOf(dbFile.name, tmp.name).forEach { base -> + File(dir, "$base-wal").delete() + File(dir, "$base-shm").delete() + } + if (!tmp.renameTo(dbFile)) { + tmp.copyTo(dbFile, overwrite = true) + tmp.delete() + } + Log.d(TAG, "local cache database converted") + } + + private fun startsWithSqliteHeader(dbFile: File): Boolean { + val head = ByteArray(SQLITE_HEADER.size) + val read = dbFile.inputStream().use { it.read(head) } + return read == SQLITE_HEADER.size && head.contentEquals(SQLITE_HEADER) + } + + @Volatile private var libraryLoaded = false + private fun ensureLibraryLoaded() { + if (libraryLoaded) return + synchronized(this) { + if (!libraryLoaded) { + System.loadLibrary("sqlcipher") + libraryLoaded = true + } + } + } + + private const val TAG = "LibreMailDbCrypto" + + // The 16-byte magic that opens every plaintext SQLite file: "SQLite format 3" + a NUL terminator. + // Spelled out as bytes to keep the trailing NUL unambiguous. + private val SQLITE_HEADER = byteArrayOf( + 0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, + 0x6F, 0x72, 0x6D, 0x61, 0x74, 0x20, 0x33, 0x00, + ) +} 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 0466af7..42dc24d 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -4,16 +4,35 @@ package org.libremail.data.local import androidx.room.Database import androidx.room.RoomDatabase 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.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.MessageEntity +import org.libremail.data.local.entity.OutboxEntity @Database( - entities = [AccountEntity::class, MessageEntity::class], - version = 1, - exportSchema = false, + entities = [ + AccountEntity::class, + MessageEntity::class, + CredentialEntity::class, + AttachmentEntity::class, + OutboxEntity::class, + DraftEntity::class, + ], + version = 7, + exportSchema = true, ) abstract class LibreMailDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun accountDao(): AccountDao + abstract fun credentialDao(): CredentialDao + abstract fun attachmentDao(): AttachmentDao + abstract fun outboxDao(): OutboxDao + abstract fun draftDao(): DraftDao } diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt new file mode 100644 index 0000000..9dcb167 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +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.MessageEntity +import org.libremail.data.local.entity.OutboxEntity +import org.libremail.data.local.entity.ServerConfigEmbedded +import org.json.JSONArray +import org.json.JSONObject +import org.libremail.domain.model.Account +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.ImapConnectionParams +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.Message +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.FetchedMessage + +internal fun AccountEntity.toDomain(): Account = Account( + id = id, + email = email, + displayName = displayName, + authType = runCatching { AuthType.valueOf(authType) }.getOrDefault(AuthType.PASSWORD_IMAP), + imap = ServerConfig(imap.host, imap.port, imap.security.toMailSecurity()), + smtp = ServerConfig(smtp.host, smtp.port, smtp.security.toMailSecurity()), +) + +internal fun Account.toEntity(): AccountEntity = AccountEntity( + id = id, + email = email, + displayName = displayName, + authType = authType.name, + imap = ServerConfigEmbedded(imap.host, imap.port, imap.security.name), + smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name), +) + +internal fun Account.toImapParams( + secret: String, + useXoauth2: Boolean, + strictStartTls: Boolean = true, +): ImapConnectionParams = + ImapConnectionParams( + host = imap.host, + port = imap.port, + security = imap.security, + username = email, + secret = secret, + useXoauth2 = useXoauth2, + strictStartTls = strictStartTls, + ) + +internal fun Account.toSmtpParams( + secret: String, + useXoauth2: Boolean, + strictStartTls: Boolean = true, +): SmtpParams = + SmtpParams( + host = smtp.host, + port = smtp.port, + security = smtp.security, + username = email, + secret = secret, + useXoauth2 = useXoauth2, + strictStartTls = strictStartTls, + ) + +internal fun MessageEntity.toDomain(): Message = Message( + id = id, + accountId = accountId, + sender = sender, + senderEmail = senderEmail, + subject = subject, + snippet = snippet, + body = body, + isHtml = isHtml, + timestampMillis = timestampMillis, + isRead = isRead, + isStarred = isStarred, + inInbox = inInbox, +) + +internal fun FetchedMessage.toEntity(accountId: String, inInbox: Boolean = true): MessageEntity = MessageEntity( + id = "$accountId:$uid", + accountId = accountId, + sender = sender, + senderEmail = senderEmail, + subject = subject, + snippet = "", + body = "", + isHtml = false, + timestampMillis = timestampMillis, + isRead = isRead, + isStarred = isFlagged, + inInbox = inInbox, + bodyFetched = false, +) + +internal fun AttachmentEntity.toDomain(): Attachment = Attachment( + messageId = messageId, + partIndex = partIndex, + filename = filename, + mimeType = mimeType, + sizeBytes = sizeBytes, +) + +internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = AttachmentEntity( + messageId = messageId, + partIndex = partIndex, + filename = filename, + mimeType = mimeType, + sizeBytes = sizeBytes, +) + +internal fun DraftEntity.toDomain(): Draft = Draft( + id = id, + accountId = accountId, + to = toAddresses, + cc = ccAddresses, + subject = subject, + body = body, + updatedAt = updatedAt, + attachments = attachments.toOutgoingAttachments(), +) + +internal fun Draft.toEntity(): DraftEntity = DraftEntity( + id = id, + accountId = accountId, + toAddresses = to, + ccAddresses = cc, + subject = subject, + body = body, + updatedAt = updatedAt, + attachments = attachments.toJson(), +) + +/** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */ +private fun List.toJson(): String { + if (isEmpty()) return "" + val array = JSONArray() + forEach { array.put(JSONObject().put("uri", it.uri).put("name", it.name)) } + return array.toString() +} + +private fun String.toOutgoingAttachments(): List { + if (isBlank()) return emptyList() + return runCatching { + val array = JSONArray(this) + (0 until array.length()).map { i -> + val obj = array.getJSONObject(i) + OutgoingAttachment(obj.getString("uri"), obj.optString("name")) + } + }.getOrDefault(emptyList()) +} + +internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage( + id = id, + to = toAddresses, + subject = subject, + body = body, + createdAt = createdAt, + lastError = lastError, +) + +private fun String.toMailSecurity(): MailSecurity = + runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS) diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt new file mode 100644 index 0000000..6f1dcb7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** v1 -> v2: add the encrypted-credentials table (preserves existing accounts/messages). */ +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `credentials` (" + + "`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, " + + "PRIMARY KEY(`accountId`))", + ) + } +} + +/** v2 -> v3: add the [isHtml] flag to cached messages (preserves existing data). */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE messages ADD COLUMN isHtml INTEGER NOT NULL DEFAULT 0") + } +} + +/** v3 -> v4: add the attachments metadata table (preserves existing data). */ +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `attachments` (" + + "`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, " + + "`mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, " + + "PRIMARY KEY(`messageId`, `partIndex`))", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `attachments` (`messageId`)", + ) + } +} + +/** v4 -> v5: add the outbox table for queued outgoing mail (preserves existing data). */ +val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `outbox` (" + + "`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`))", + ) + } +} + +/** v5 -> v6: add the drafts table for saved unsent mail (preserves existing data). */ +val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `drafts` (" + + "`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, PRIMARY KEY(`id`))", + ) + } +} + +/** + * v6 -> v7 (preserves existing data). Rebuilds three tables to converge on Room's canonical + * schema and add new columns: + * - `messages`: drop the stray `DEFAULT 0` that MIGRATION_2_3 left on `isHtml` (so upgraded and + * fresh installs validate identically), and add `inInbox` (server-search hits are kept out of + * the inbox) and `bodyFetched` (distinguishes "not fetched yet" from "fetched, empty body"). + * - `attachments`: add an ON DELETE CASCADE foreign key to `messages` so attachment rows can no + * longer be orphaned, dropping any pre-existing orphans in the process. + * - `drafts`: add the `attachments` column so a draft round-trips its attachments. + */ +val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + // messages + db.execSQL( + "CREATE TABLE `messages_new` (" + + "`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, `inInbox` INTEGER NOT NULL, " + + "`bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL( + "INSERT INTO `messages_new` " + + "SELECT id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " + + "timestampMillis, isRead, isStarred, 1, (CASE WHEN body <> '' THEN 1 ELSE 0 END) " + + "FROM `messages`", + ) + db.execSQL("DROP TABLE `messages`") + db.execSQL("ALTER TABLE `messages_new` RENAME TO `messages`") + db.execSQL("CREATE INDEX `index_messages_accountId` ON `messages` (`accountId`)") + db.execSQL("CREATE INDEX `index_messages_timestampMillis` ON `messages` (`timestampMillis`)") + + // attachments (now with an ON DELETE CASCADE FK; orphans are dropped by the WHERE filter) + db.execSQL( + "CREATE TABLE `attachments_new` (" + + "`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)", + ) + db.execSQL( + "INSERT INTO `attachments_new` " + + "SELECT messageId, partIndex, filename, mimeType, sizeBytes FROM `attachments` " + + "WHERE messageId IN (SELECT id FROM `messages`)", + ) + db.execSQL("DROP TABLE `attachments`") + db.execSQL("ALTER TABLE `attachments_new` RENAME TO `attachments`") + db.execSQL("CREATE INDEX `index_attachments_messageId` ON `attachments` (`messageId`)") + + // drafts + db.execSQL( + "CREATE TABLE `drafts_new` (" + + "`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`))", + ) + db.execSQL( + "INSERT INTO `drafts_new` " + + "SELECT id, accountId, toAddresses, ccAddresses, subject, body, updatedAt, '' " + + "FROM `drafts`", + ) + db.execSQL("DROP TABLE `drafts`") + db.execSQL("ALTER TABLE `drafts_new` RENAME TO `drafts`") + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt index afedc8f..45ff38d 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt @@ -13,6 +13,15 @@ interface AccountDao { @Query("SELECT * FROM accounts ORDER BY email") fun observeAll(): Flow> + @Query("SELECT * FROM accounts ORDER BY email") + suspend fun getAll(): List + + @Query("SELECT * FROM accounts WHERE id = :id LIMIT 1") + suspend fun getById(id: String): AccountEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(account: AccountEntity) + + @Query("DELETE FROM accounts WHERE id = :id") + suspend fun deleteById(id: String) } diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt new file mode 100644 index 0000000..b016c6e --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.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.AttachmentEntity + +@Dao +interface AttachmentDao { + @Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex") + fun observeForMessage(messageId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(attachments: List) + + @Query("DELETE FROM attachments WHERE messageId = :messageId") + suspend fun deleteForMessage(messageId: String) + + /** Replaces the cached attachment list for a message in one transaction. */ + @Transaction + suspend fun replaceForMessage(messageId: String, attachments: List) { + deleteForMessage(messageId) + insert(attachments) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/CredentialDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/CredentialDao.kt new file mode 100644 index 0000000..b2f9db7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/CredentialDao.kt @@ -0,0 +1,20 @@ +// 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 org.libremail.data.local.entity.CredentialEntity + +@Dao +interface CredentialDao { + @Query("SELECT * FROM credentials WHERE accountId = :accountId LIMIT 1") + suspend fun getById(accountId: String): CredentialEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(credential: CredentialEntity) + + @Query("DELETE FROM credentials WHERE accountId = :accountId") + suspend fun deleteById(accountId: String) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt new file mode 100644 index 0000000..1ad234b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt @@ -0,0 +1,27 @@ +// 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 kotlinx.coroutines.flow.Flow +import org.libremail.data.local.entity.DraftEntity + +@Dao +interface DraftDao { + @Query("SELECT * FROM drafts ORDER BY updatedAt DESC") + fun observeAll(): Flow> + + @Query("SELECT COUNT(*) FROM drafts") + fun observeCount(): Flow + + @Query("SELECT * FROM drafts WHERE id = :id LIMIT 1") + suspend fun getById(id: String): DraftEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(draft: DraftEntity) + + @Query("DELETE FROM drafts WHERE id = :id") + suspend fun delete(id: String) +} 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 eca8f49..bf2fc39 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,6 +16,59 @@ interface MessageDao { @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") suspend fun getById(id: String): MessageEntity? - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertAll(messages: List) + /** 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 + + /** Inserts only new messages, leaving existing rows (and their cached bodies/flags) intact. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertNew(messages: List) + + /** + * Refreshes the display fields from the server without touching the cached body, the local + * read/star flags (which may hold an optimistic change the server hasn't reflected yet), or the + * inbox membership. + */ + @Query( + "UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " + + "timestampMillis = :timestampMillis WHERE id = :id", + ) + suspend fun updateHeaderContent( + id: String, + sender: String, + senderEmail: String, + subject: String, + timestampMillis: Long, + ) + + /** Marks rows as belonging to the inbox (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) + + @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) + + @Query("UPDATE messages SET isRead = :isRead WHERE id = :id") + suspend fun setRead(id: String, isRead: Boolean) + + @Query("UPDATE messages SET isStarred = :isStarred WHERE id = :id") + suspend fun setStarred(id: String, isStarred: Boolean) + + @Query("DELETE FROM messages WHERE id = :id") + suspend fun deleteById(id: String) + + @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) + + /** 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) + + /** Removes transient server-search hits (called when search closes). */ + @Query("DELETE FROM messages WHERE inInbox = 0") + suspend fun deleteSearchRows() } diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt new file mode 100644 index 0000000..31e4e6f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.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.Query +import kotlinx.coroutines.flow.Flow +import org.libremail.data.local.entity.OutboxEntity + +@Dao +interface OutboxDao { + @Insert + suspend fun insert(message: OutboxEntity) + + @Query("SELECT * FROM outbox ORDER BY createdAt") + suspend fun getAll(): List + + @Query("SELECT * FROM outbox ORDER BY createdAt") + fun observeAll(): Flow> + + @Query("SELECT COUNT(*) FROM outbox") + fun observeCount(): Flow + + @Query("UPDATE outbox SET lastError = :error WHERE id = :id") + suspend fun setError(id: String, error: String?) + + @Query("DELETE FROM outbox WHERE id = :id") + suspend fun delete(id: String) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/AccountEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/AccountEntity.kt index 196a792..ae7efc5 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/AccountEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/AccountEntity.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.local.entity +import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey @@ -11,4 +12,14 @@ data class AccountEntity( val displayName: String, /** Persisted name of [org.libremail.domain.model.AuthType]. */ val authType: String, + @Embedded(prefix = "imap_") val imap: ServerConfigEmbedded, + @Embedded(prefix = "smtp_") val smtp: ServerConfigEmbedded, +) + +/** Embedded host/port/security columns (prefixed per server in [AccountEntity]). */ +data class ServerConfigEmbedded( + val host: String, + val port: Int, + /** Persisted name of [org.libremail.domain.model.MailSecurity]. */ + val security: String, ) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt new file mode 100644 index 0000000..ecbc181 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** Cached metadata for one attachment part of a message (the bytes are fetched on demand). */ +@Entity( + tableName = "attachments", + primaryKeys = ["messageId", "partIndex"], + foreignKeys = [ + ForeignKey( + entity = MessageEntity::class, + parentColumns = ["id"], + childColumns = ["messageId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("messageId")], +) +data class AttachmentEntity( + val messageId: String, + val partIndex: Int, + val filename: String, + val mimeType: String, + val sizeBytes: Long, +) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/CredentialEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/CredentialEntity.kt new file mode 100644 index 0000000..f92ded4 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/CredentialEntity.kt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Encrypted secret for an account: either an OAuth [net.openid.appauth.AuthState] JSON + * blob or an IMAP password. The value is AES-GCM ciphertext (see KeystoreCrypto), never + * plaintext. + */ +@Entity(tableName = "credentials") +data class CredentialEntity( + @PrimaryKey val accountId: String, + val encryptedSecret: String, +) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt new file mode 100644 index 0000000..ed6dd3b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** A composed-but-unsent message saved for later editing. */ +@Entity(tableName = "drafts") +data class DraftEntity( + @PrimaryKey val id: String, + val accountId: String?, + val toAddresses: String, + val ccAddresses: String, + val subject: String, + val body: String, + val updatedAt: Long, + /** JSON array of the draft's attachments ([uri, name] pairs); empty string when there are none. */ + val attachments: String = "", +) 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 e53a34a..c157e38 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 @@ -17,7 +17,12 @@ data class MessageEntity( val subject: String, val snippet: String, val body: String, + val isHtml: Boolean = false, val timestampMillis: Long, val isRead: Boolean, val isStarred: Boolean, + /** True for inbox-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/local/entity/OutboxEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt new file mode 100644 index 0000000..96d5f44 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** A message queued for sending. The send worker drains this table and deletes rows on success. */ +@Entity(tableName = "outbox") +data class OutboxEntity( + @PrimaryKey val id: String, + val accountId: String, + val toAddresses: String, + val ccAddresses: String, + val subject: String, + val body: String, + val createdAt: Long, + val lastError: String? = null, +) diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt new file mode 100644 index 0000000..785958a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.repository + +import javax.inject.Inject +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.MessageDao +import org.libremail.data.local.toDomain +import org.libremail.data.local.toEntity +import org.libremail.data.local.toImapParams +import org.libremail.data.security.CredentialStore +import org.libremail.data.sync.SyncScheduler +import org.libremail.domain.model.Account +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.repository.AccountRepository +import org.libremail.mail.ImapClient + +@Singleton +class AccountRepositoryImpl @Inject constructor( + private val accountDao: AccountDao, + private val messageDao: MessageDao, + private val credentialStore: CredentialStore, + private val imapClient: ImapClient, + private val syncScheduler: SyncScheduler, +) : AccountRepository { + + override fun observeAccounts(): Flow> = + accountDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + override suspend fun testConnection(params: ImapConnectionParams): Result> = + runCatching { imapClient.listFolders(params) } + + 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 + } + + override suspend fun addOutlookAccount( + email: String, + accessToken: String, + authStateJson: String, + ): Result> = runCatching { + val account = Account.outlook(email) + val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true)) + accountDao.upsert(account.toEntity()) + credentialStore.saveSecret(account.id, authStateJson) + syncScheduler.syncNow() + folders + } + + 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). + messageDao.deleteByAccount(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 17f493d..9e531fd 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -1,58 +1,197 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.repository +import android.content.Context +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import jakarta.mail.Flags +import java.io.File +import java.util.UUID import javax.inject.Inject 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.AttachmentDao +import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.MessageDao -import org.libremail.data.local.entity.MessageEntity -import org.libremail.data.sample.SampleData +import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.entity.OutboxEntity +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.AuthType +import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Draft import org.libremail.domain.model.Message +import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.model.OutgoingAttachment +import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.MailRepository +import org.libremail.mail.DownloadedAttachment +import org.libremail.mail.ImapClient @Singleton class MailRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, private val messageDao: MessageDao, private val accountDao: AccountDao, + private val attachmentDao: AttachmentDao, + private val outboxDao: OutboxDao, + private val draftDao: DraftDao, + private val imapClient: ImapClient, + private val connectionFactory: MailConnectionFactory, + private val sendScheduler: SendScheduler, ) : MailRepository { - // Room is the single source of truth. Until sync lands the cache is empty, so - // we fall back to bundled sample data to keep the UI populated. override fun observeMessages(): Flow> = - messageDao.observeAll().map { cached -> - if (cached.isEmpty()) SampleData.messages else cached.map { it.toDomain() } - } + messageDao.observeAll().map { rows -> rows.map { it.toDomain() } } - override fun observeAccounts(): Flow> = - accountDao.observeAll().map { rows -> - rows.map { row -> - Account( - id = row.id, - email = row.email, - displayName = row.displayName, - authType = runCatching { AuthType.valueOf(row.authType) } - .getOrDefault(AuthType.PASSWORD_IMAP), - ) + override suspend fun getMessage(id: String): Message? = messageDao.getById(id)?.toDomain() + + override suspend fun openMessage(id: String): Result = runCatching { + val entity = messageDao.getById(id) ?: error("Message not found") + val account = accountDao.getById(entity.accountId)?.toDomain() + if (account != null) { + val params = connectionFactory.imapParamsFor(account) + if (!entity.bodyFetched) { + val content = imapClient.fetchBodyMarkingSeen(params, 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) } + messageDao.setRead(id, true) } } + messageDao.getById(id)?.toDomain() ?: error("Message not found") + } - override suspend fun getMessage(id: String): Message? = - messageDao.getById(id)?.toDomain() ?: SampleData.byId(id) + override fun observeAttachments(messageId: String): Flow> = + attachmentDao.observeForMessage(messageId).map { rows -> rows.map { it.toDomain() } } + + override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result = runCatching { + val entity = messageDao.getById(messageId) ?: error("Message not found") + val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found") + val params = connectionFactory.imapParamsFor(account) + saveToCache(imapClient.fetchAttachment(params, 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) + } + Unit + } + + override suspend fun deleteMessage(id: String): Result = runCatching { + val account = accountFor(id) + messageDao.deleteById(id) // optimistic; reappears on next sync if the server delete failed + account?.let { imapClient.deleteMessage(connectionFactory.imapParamsFor(it), uidOf(id)) } + Unit + } + + /** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */ + override suspend fun sendMessage(outgoing: OutgoingMessage): Result = runCatching { + requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" } + val outboxId = UUID.randomUUID().toString() + copyAttachments(outboxId, outgoing.attachments) + outboxDao.insert( + OutboxEntity( + id = outboxId, + accountId = outgoing.accountId, + toAddresses = outgoing.to, + ccAddresses = outgoing.cc, + subject = outgoing.subject, + body = outgoing.body, + createdAt = System.currentTimeMillis(), + ), + ) + sendScheduler.sendNow() + } + + /** + * Copies the picked attachment URIs into the outbox message's own directory for the worker. + * Each attachment goes in its own index-named subdirectory so the send worker can restore the + * original order (a flat listing's order is unspecified), keeping the file's real name intact. + */ + private fun copyAttachments(outboxId: String, attachments: List) { + if (attachments.isEmpty()) return + attachments.forEachIndexed { index, attachment -> + val safeName = attachment.name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" } + val dir = File(context.cacheDir, "outbox/$outboxId/$index").apply { mkdirs() } + runCatching { + context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> + File(dir, safeName).outputStream().use { output -> input.copyTo(output) } + } + } + } + } + + override fun observeDrafts(): Flow> = + draftDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + override suspend fun getDraft(id: String): Draft? = draftDao.getById(id)?.toDomain() + + override suspend fun saveDraft(draft: Draft) = draftDao.upsert(draft.toEntity()) + + override suspend fun deleteDraft(id: String) = draftDao.delete(id) + + override fun observeOutbox(): Flow> = + outboxDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + override suspend fun cancelOutboxMessage(id: String) { + outboxDao.delete(id) + File(context.cacheDir, "outbox/$id").deleteRecursively() + } + + 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 clearSearchResults() = messageDao.deleteSearchRows() + + /** Writes downloaded bytes to a private cache file that the FileProvider can share. */ + private fun saveToCache(attachment: DownloadedAttachment): File { + val dir = File(context.cacheDir, "attachments").apply { mkdirs() } + val safeName = attachment.filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" } + return File(dir, safeName).also { file -> + file.outputStream().use { it.write(attachment.bytes) } + } + } + + private suspend fun accountFor(id: String): Account? { + val entity = messageDao.getById(id) ?: return null + return accountDao.getById(entity.accountId)?.toDomain() + } } -private fun MessageEntity.toDomain(): Message = Message( - id = id, - accountId = accountId, - sender = sender, - senderEmail = senderEmail, - subject = subject, - snippet = snippet, - body = body, - timestampMillis = timestampMillis, - isRead = isRead, - isStarred = isStarred, -) +private const val SEARCH_LIMIT = 50 + +/** Message id is ":"; the uid is the trailing segment. */ +private fun uidOf(id: String): String = id.substringAfterLast(':') + +private fun snippetOf(body: String): String = + body.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim().take(140) diff --git a/app/src/main/kotlin/org/libremail/data/sample/SampleData.kt b/app/src/main/kotlin/org/libremail/data/sample/SampleData.kt deleted file mode 100644 index 4fa11a2..0000000 --- a/app/src/main/kotlin/org/libremail/data/sample/SampleData.kt +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.data.sample - -import org.libremail.domain.model.Message - -/** - * Placeholder content shown until real IMAP sync lands (next increment), so the - * mailbox and reader are populated when the local cache is still empty. - */ -object SampleData { - private const val MINUTE = 60_000L - private const val HOUR = 60 * MINUTE - private val now = System.currentTimeMillis() - - val messages: List = listOf( - Message( - id = "sample-1", - accountId = "sample", - sender = "LibreMail", - senderEmail = "hello@libremail.org", - subject = "Welcome to LibreMail", - snippet = "Thanks for trying LibreMail — a free, open-source email client.", - body = "Thanks for trying LibreMail!\n\n" + - "This is placeholder content. Account sign-in, IMAP sync and sending " + - "arrive in upcoming increments. The screen you are looking at is wired " + - "to the same ViewModel → Repository → Room pipeline the real data will use.", - timestampMillis = now - 5 * MINUTE, - isRead = false, - isStarred = true, - ), - Message( - id = "sample-2", - accountId = "sample", - sender = "Material You", - senderEmail = "design@android.example", - subject = "Your theme follows the wallpaper", - snippet = "On Android 12+ the colors you see are derived from your wallpaper.", - body = "LibreMail uses Material 3 dynamic color. On Android 12 and newer, the " + - "accent colors are generated from your wallpaper. On older versions it falls " + - "back to the LibreMail brand palette.", - timestampMillis = now - 3 * HOUR, - isRead = false, - isStarred = false, - ), - Message( - id = "sample-3", - accountId = "sample", - sender = "Privacy", - senderEmail = "privacy@libremail.org", - subject = "Remote images are blocked by default", - snippet = "We block remote content to protect you from tracking pixels.", - body = "By default, remote images in HTML email are not loaded, which prevents " + - "senders from tracking when you open a message. You can change this under " + - "Settings → Advanced Settings.", - timestampMillis = now - 26 * HOUR, - isRead = true, - isStarred = false, - ), - ) - - fun byId(id: String): Message? = messages.firstOrNull { it.id == id } -} diff --git a/app/src/main/kotlin/org/libremail/data/security/CredentialStore.kt b/app/src/main/kotlin/org/libremail/data/security/CredentialStore.kt new file mode 100644 index 0000000..cf9b977 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/security/CredentialStore.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import javax.inject.Inject +import javax.inject.Singleton +import org.libremail.data.local.dao.CredentialDao +import org.libremail.data.local.entity.CredentialEntity + +/** Stores one encrypted secret per account (OAuth AuthState JSON or IMAP password). */ +@Singleton +class CredentialStore @Inject constructor( + private val crypto: KeystoreCrypto, + private val credentialDao: CredentialDao, +) { + suspend fun saveSecret(accountId: String, secret: String) { + credentialDao.upsert(CredentialEntity(accountId, crypto.encrypt(secret))) + } + + suspend fun loadSecret(accountId: String): String? = + credentialDao.getById(accountId)?.let { crypto.decrypt(it.encryptedSecret) } + + suspend fun delete(accountId: String) = credentialDao.deleteById(accountId) +} diff --git a/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt new file mode 100644 index 0000000..356ae5f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private val Context.dbKeyDataStore: DataStore by preferencesDataStore(name = "libremail_dbkey") + +/** + * Supplies the SQLCipher passphrase for the opt-in encrypted cache. A random 256-bit key is + * generated once and persisted only as ciphertext — sealed by the non-exportable Android Keystore + * key via [KeystoreCrypto] — so the key that protects the cache is itself protected at rest. The + * passphrase is returned as a 64-character hex string and used directly as the SQLCipher passphrase. + * + * Stored in its own DataStore (not the Room database it protects) to avoid a chicken-and-egg cycle. + */ +@Singleton +class DatabaseKeyStore @Inject constructor( + @ApplicationContext private val context: Context, + private val crypto: KeystoreCrypto, +) { + private val generationLock = Mutex() + + /** Returns the cache passphrase, generating and sealing it on first use. */ + suspend fun passphrase(): String { + existing()?.let { return it } + return generationLock.withLock { + // Re-check inside the lock so a concurrent first-caller doesn't generate a second key + // (which would leave a DB encrypted under a key we then overwrite and can't reproduce). + existing() ?: generateAndStore() + } + } + + private suspend fun existing(): String? = + context.dbKeyDataStore.data.first()[SEALED_KEY]?.let { crypto.decrypt(it) } + + private suspend fun generateAndStore(): String { + val raw = ByteArray(KEY_BYTES).also { SecureRandom().nextBytes(it) } + val hex = raw.joinToString("") { "%02x".format(it) } + context.dbKeyDataStore.edit { it[SEALED_KEY] = crypto.encrypt(hex) } + return hex + } + + private companion object { + val SEALED_KEY = stringPreferencesKey("sealed_db_key") + const val KEY_BYTES = 32 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt new file mode 100644 index 0000000..86ecc1d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +/** + * AES-256-GCM encryption backed by a non-exportable key in the Android Keystore. Secrets + * (OAuth tokens, IMAP passwords) are encrypted at rest so they never touch disk in plaintext. + */ +@Singleton +class KeystoreCrypto @Inject constructor() { + + private val keyLock = Any() + + // Synchronized so two concurrent first-run encrypts can't both generate a key under the same + // alias — the second would overwrite the first, leaving the first secret undecryptable. + private fun secretKey(): SecretKey = synchronized(keyLock) { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + generator.generateKey() + } + + /** Returns Base64(iv || ciphertext). */ + fun encrypt(plaintext: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + val iv = cipher.iv + val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(iv + ciphertext, Base64.NO_WRAP) + } + + fun decrypt(encoded: String): String { + val bytes = Base64.decode(encoded, Base64.NO_WRAP) + val iv = bytes.copyOfRange(0, IV_LENGTH) + val ciphertext = bytes.copyOfRange(IV_LENGTH, bytes.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_BITS, iv)) + return String(cipher.doFinal(ciphertext), Charsets.UTF_8) + } + + private companion object { + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val KEY_ALIAS = "libremail.master.key" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val IV_LENGTH = 12 + const val TAG_BITS = 128 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt new file mode 100644 index 0000000..a14585d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "libremail_settings") + +/** User preferences. Only [dynamicColor] and [newMailNotifications] are wired to behaviour so far. */ +data class AppSettings( + val dynamicColor: Boolean = true, + val newMailNotifications: Boolean = true, + val pushIdle: Boolean = true, + val allowStartTls: Boolean = false, + val loadRemoteImages: Boolean = false, + val encryptCache: Boolean = false, +) + +@Singleton +class SettingsRepository @Inject constructor( + @ApplicationContext private val context: Context, +) { + val settings: Flow = context.settingsDataStore.data.map { prefs -> + AppSettings( + dynamicColor = prefs[DYNAMIC_COLOR] ?: true, + newMailNotifications = prefs[NEW_MAIL_NOTIFICATIONS] ?: true, + pushIdle = prefs[PUSH_IDLE] ?: true, + allowStartTls = prefs[ALLOW_STARTTLS] ?: false, + loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false, + encryptCache = prefs[ENCRYPT_CACHE] ?: false, + ) + } + + val dynamicColor: Flow = settings.map { it.dynamicColor } + + suspend fun isNewMailNotificationsEnabled(): Boolean = settings.first().newMailNotifications + + suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value) + suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value) + suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value) + suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value) + suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value) + suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, value) + + private suspend fun put(key: Preferences.Key, value: Boolean) { + context.settingsDataStore.edit { it[key] = value } + } + + private companion object { + val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") + val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications") + val PUSH_IDLE = booleanPreferencesKey("push_idle") + val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls") + val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images") + val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache") + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt new file mode 100644 index 0000000..8e01972 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.libremail.auth.FreshToken +import org.libremail.auth.GmailAuthManager +import org.libremail.auth.OutlookAuthManager +import org.libremail.data.local.toImapParams +import org.libremail.data.local.toSmtpParams +import org.libremail.data.security.CredentialStore +import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.SmtpParams + +/** Resolves an account's stored credential (refreshing OAuth tokens when needed) into connection params. */ +@Singleton +class MailConnectionFactory @Inject constructor( + private val credentialStore: CredentialStore, + private val gmailAuthManager: GmailAuthManager, + private val outlookAuthManager: OutlookAuthManager, + private val settingsRepository: SettingsRepository, +) { + private data class CachedToken(val token: String, val expiry: Long?) + + /** Per-account lock so concurrent refreshes can't redeem the same (rotating) refresh token twice. */ + private val refreshMutexes = ConcurrentHashMap() + + /** In-memory access-token cache keyed by "accountId|scope", to avoid redeeming a still-valid token. */ + private val tokenCache = ConcurrentHashMap() + + suspend fun imapParamsFor(account: Account): ImapConnectionParams = + account.toImapParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP, strictStartTls()) + + suspend fun smtpParamsFor(account: Account): SmtpParams = + account.toSmtpParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP, strictStartTls()) + + /** A fresh Microsoft Graph access token for the primary Outlook (sendMail) send path. */ + suspend fun graphTokenFor(account: Account): String = + cachedAccessToken(account.id, SCOPE_GRAPH, outlookAuthManager::freshGraphToken) + + private suspend fun resolveSecret(account: Account): String = when (account.authType) { + AuthType.PASSWORD_IMAP -> + credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}") + AuthType.OAUTH_GMAIL -> + cachedAccessToken(account.id, SCOPE_GMAIL, gmailAuthManager::freshAccessToken) + AuthType.OAUTH_OUTLOOK -> + cachedAccessToken(account.id, SCOPE_OUTLOOK, outlookAuthManager::freshOutlookToken) + } + + /** + * Returns a cached access token while it is still valid, otherwise refreshes under the account's + * lock (re-checking the cache first, so a concurrent caller redeems only once) and persists the + * updated AuthState. + */ + private suspend fun cachedAccessToken( + accountId: String, + scope: String, + refresh: suspend (String) -> FreshToken, + ): String { + validCachedToken(accountId, scope)?.let { return it } + // computeIfAbsent (not getOrPut) so concurrent first-callers share one mutex per account. + return refreshMutexes.computeIfAbsent(accountId) { Mutex() }.withLock { + validCachedToken(accountId, scope)?.let { return@withLock it } + val stored = credentialStore.loadSecret(accountId) ?: error("No stored credentials for $accountId") + val fresh = refresh(stored) + if (fresh.authStateJson != stored) credentialStore.saveSecret(accountId, fresh.authStateJson) + tokenCache["$accountId|$scope"] = CachedToken(fresh.accessToken, fresh.accessTokenExpiry) + fresh.accessToken + } + } + + private fun validCachedToken(accountId: String, scope: String): String? { + val cached = tokenCache["$accountId|$scope"] ?: return null + val expiry = cached.expiry ?: return null // unknown expiry — don't trust the cache + return cached.token.takeIf { expiry - EXPIRY_BUFFER_MS > System.currentTimeMillis() } + } + + private suspend fun strictStartTls(): Boolean = !settingsRepository.settings.first().allowStartTls + + private companion object { + const val SCOPE_GMAIL = "gmail" + const val SCOPE_OUTLOOK = "outlook" + const val SCOPE_GRAPH = "graph" + const val EXPIRY_BUFFER_MS = 60_000L + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt new file mode 100644 index 0000000..4e8a193 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.toDomain +import org.libremail.data.local.toEntity +import org.libremail.data.settings.SettingsRepository +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. */ +@Singleton +class MailSyncer @Inject constructor( + private val accountDao: AccountDao, + private val messageDao: MessageDao, + private val imapClient: ImapClient, + private val connectionFactory: MailConnectionFactory, + 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. + private val syncMutex = Mutex() + + /** Syncs every account. 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) + + var total = 0 + var firstError: Throwable? = null + var anySuccess = false + for (entity in accounts) { + syncAccountInternal(entity.toDomain()).fold( + onSuccess = { fetched -> + total += fetched + anySuccess = true + }, + onFailure = { error -> if (firstError == null) firstError = error }, + ) + } + if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError) + } + + /** Syncs one account — used by the per-account IDLE watcher so a single 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()) + } + + 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) } + + // 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 } + } + + 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, + ) + } + messageDao.deleteInboxNotIn(account.id, ids) + } + + if (newMessages.isNotEmpty() && settingsRepository.isNewMailNotificationsEnabled()) { + notifier.notifyNewMail(newMessages.sortedByDescending { it.timestampMillis }) + } + } + fetched.size + } + + private companion object { + const val INBOX_LIMIT = 50 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt b/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt new file mode 100644 index 0000000..39605dd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Schedules the outbox send worker via WorkManager. */ +@Singleton +class SendScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + /** Drain the outbox as soon as the network is available, retrying with backoff on failure. */ + fun sendNow() { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + // REPLACE: start a fresh drain so newly-queued mail and manual retries run promptly, + // overriding any pending retry-backoff. At-least-once — a send cancelled mid-flight by a + // replacement may be re-sent, which is preferable to mail stuck waiting behind a backoff. + WorkManager.getInstance(context) + .enqueueUniqueWork(SEND_WORK, ExistingWorkPolicy.REPLACE, request) + } + + private companion object { + const val SEND_WORK = "libremail_send_outbox" + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt new file mode 100644 index 0000000..f4df276 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.io.File +import kotlin.coroutines.cancellation.CancellationException +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.toDomain +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.OutgoingMessage +import org.libremail.mail.GraphSendException +import org.libremail.mail.GraphSender +import org.libremail.mail.SmtpSender + +/** Drains the outbox: sends each queued message over Graph/SMTP, deleting it on success. */ +@HiltWorker +class SendWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val outboxDao: OutboxDao, + private val accountDao: AccountDao, + private val smtpSender: SmtpSender, + private val graphSender: GraphSender, + private val connectionFactory: MailConnectionFactory, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + val pending = outboxDao.getAll() + if (pending.isEmpty()) return Result.success() + + var anyFailed = false + for (entity in pending) { + val attachmentDir = File(applicationContext.cacheDir, "outbox/${entity.id}") + val account = accountDao.getById(entity.accountId)?.toDomain() + if (account == null) { + outboxDao.delete(entity.id) // account removed — drop the queued message + attachmentDir.deleteRecursively() + continue + } + runCatching { + val message = OutgoingMessage( + accountId = entity.accountId, + to = entity.toAddresses, + cc = entity.ccAddresses, + subject = entity.subject, + body = entity.body, + ) + val files = orderedAttachments(attachmentDir) + if (account.authType == AuthType.OAUTH_OUTLOOK) { + sendOutlook(account, message, files) + } else { + smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files) + } + }.fold( + onSuccess = { + outboxDao.delete(entity.id) + attachmentDir.deleteRecursively() + }, + onFailure = { e -> + if (e is GraphSendException && e.mayHaveSent) { + // Graph may already have delivered this; auto-retrying (or any other send) + // would duplicate it, so leave it queued with a clear status and let the + // user decide. Not counted as a failure, so WorkManager won't auto-retry. + outboxDao.setError(entity.id, "Send status unknown — check your Sent folder, then retry or cancel") + } else { + outboxDao.setError(entity.id, e.message) + anyFailed = true + } + }, + ) + } + // Retry (with WorkManager backoff) so failed sends are reattempted when conditions improve. + return if (anyFailed) Result.retry() else Result.success() + } + + /** + * Outlook prefers Microsoft Graph. Fall back to SMTP only when Graph definitely did NOT send + * (a rejection, a pre-send/transport error, or a token failure); never fall back when the Graph + * request may already have been accepted, or the message would be sent twice. + */ + private suspend fun sendOutlook(account: Account, message: OutgoingMessage, files: List) { + try { + val token = connectionFactory.graphTokenFor(account) + graphSender.send(token, message, files) + } catch (e: GraphSendException) { + if (e.mayHaveSent) throw e + smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Graph was never reached (e.g. token refresh failed) — SMTP cannot duplicate it. + smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files) + } + } + + /** Attachments are staged one-per-indexed-subdirectory so their original order is preserved. */ + private fun orderedAttachments(dir: File): List = + dir.listFiles() + ?.filter { it.isDirectory } + ?.sortedBy { it.name.toIntOrNull() ?: Int.MAX_VALUE } + ?.mapNotNull { it.listFiles()?.firstOrNull() } + .orEmpty() +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt b/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt new file mode 100644 index 0000000..c6706bd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Schedules background mail sync via WorkManager. */ +@Singleton +class SyncScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val workManager get() = WorkManager.getInstance(context) + + private val networkConstraint = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + /** Periodic background sync (WorkManager's 15-minute floor). */ + fun schedulePeriodicSync() { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(networkConstraint) + .build() + workManager.enqueueUniquePeriodicWork(PERIODIC_WORK, ExistingPeriodicWorkPolicy.KEEP, request) + } + + /** One-shot sync, e.g. right after an account is added. */ + fun syncNow() { + val request = OneTimeWorkRequestBuilder() + .setConstraints(networkConstraint) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request) + } + + private companion object { + const val PERIODIC_WORK = "libremail_periodic_sync" + const val ONESHOT_WORK = "libremail_oneshot_sync" + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt new file mode 100644 index 0000000..75929dd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val mailSyncer: MailSyncer, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = + mailSyncer.syncAll().fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() }, + ) +} diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index 249c457..e237c63 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -9,9 +9,25 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import org.libremail.data.local.DatabaseEncryption import org.libremail.data.local.LibreMailDatabase +import org.libremail.data.local.MIGRATION_1_2 +import org.libremail.data.local.MIGRATION_2_3 +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.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.MessageDao +import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.security.DatabaseKeyStore +import org.libremail.data.settings.SettingsRepository @Module @InstallIn(SingletonComponent::class) @@ -19,12 +35,58 @@ object DatabaseModule { @Provides @Singleton - fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase = - Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db").build() + fun provideDatabase( + @ApplicationContext context: Context, + keyStore: DatabaseKeyStore, + settingsRepository: SettingsRepository, + ): LibreMailDatabase { + val builder = Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + ) + // No destructive fallback: the migration chain is complete, and silently dropping the + // accounts/credentials/mail tables would lose stored secrets. A missing migration should + // fail loudly in testing instead. + + // Opt-in at-rest encryption of the local cache (off by default). The conversion runs here — + // before the database is opened — so it never races an open connection; toggling the setting + // therefore takes effect on the next app start. The passphrase is sealed by the Keystore. + val dbFile = context.getDatabasePath(DB_NAME) + if (runBlocking { settingsRepository.settings.first().encryptCache }) { + val passphrase = runBlocking { keyStore.passphrase() } + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + builder.openHelperFactory( + SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false), + ) + } else if (DatabaseEncryption.isEncrypted(dbFile)) { + // Encryption was turned back off — decrypt so the default (unkeyed) open succeeds. + DatabaseEncryption.ensurePlaintext(dbFile, runBlocking { keyStore.passphrase() }) + } + return builder.build() + } @Provides fun provideMessageDao(database: LibreMailDatabase): MessageDao = database.messageDao() @Provides fun provideAccountDao(database: LibreMailDatabase): AccountDao = database.accountDao() + + @Provides + fun provideCredentialDao(database: LibreMailDatabase): CredentialDao = database.credentialDao() + + @Provides + fun provideAttachmentDao(database: LibreMailDatabase): AttachmentDao = database.attachmentDao() + + @Provides + fun provideOutboxDao(database: LibreMailDatabase): OutboxDao = database.outboxDao() + + @Provides + fun provideDraftDao(database: LibreMailDatabase): DraftDao = database.draftDao() + + private const val DB_NAME = "libremail.db" } diff --git a/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt b/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt index 93a2adb..96622e6 100644 --- a/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt +++ b/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt @@ -6,7 +6,9 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import org.libremail.data.repository.AccountRepositoryImpl import org.libremail.data.repository.MailRepositoryImpl +import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @Module @@ -16,4 +18,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindMailRepository(impl: MailRepositoryImpl): MailRepository + + @Binds + @Singleton + abstract fun bindAccountRepository(impl: AccountRepositoryImpl): AccountRepository } diff --git a/app/src/main/kotlin/org/libremail/domain/model/Account.kt b/app/src/main/kotlin/org/libremail/domain/model/Account.kt index c762620..3728156 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt @@ -6,6 +6,9 @@ enum class AuthType { /** Gmail via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */ OAUTH_GMAIL, + /** Outlook / Microsoft via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */ + OAUTH_OUTLOOK, + /** Generic IMAP/SMTP with a password or app-password. */ PASSWORD_IMAP, } @@ -15,4 +18,18 @@ data class Account( val email: String, val displayName: String, val authType: AuthType, -) + val imap: ServerConfig, + val smtp: ServerConfig, +) { + companion object { + /** An Outlook/Microsoft account using the unified office365 endpoints (personal + M365). */ + fun outlook(email: String, displayName: String = email): Account = Account( + id = "outlook:$email", + email = email, + displayName = displayName.ifBlank { email }, + authType = AuthType.OAUTH_OUTLOOK, + imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.office365.com", 587, MailSecurity.STARTTLS), + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt b/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt new file mode 100644 index 0000000..bcb3932 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +data class Attachment( + val messageId: String, + val partIndex: Int, + val filename: String, + val mimeType: String, + val sizeBytes: Long, +) diff --git a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt new file mode 100644 index 0000000..e6cecd9 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +data class Draft( + val id: String, + val accountId: String?, + val to: String, + val cc: String, + val subject: String, + val body: String, + val updatedAt: Long, + val attachments: List = emptyList(), +) diff --git a/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt b/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt new file mode 100644 index 0000000..91d41c8 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Everything needed for a single IMAP connection attempt (transient; not persisted). */ +data class ImapConnectionParams( + val host: String, + val port: Int, + val security: MailSecurity, + val username: String, + /** Password, app-password, or — when [useXoauth2] is true — an OAuth access token. */ + val secret: String, + val useXoauth2: Boolean, + /** When true (default), a STARTTLS upgrade must succeed; when false it is best-effort. */ + val strictStartTls: Boolean = true, +) diff --git a/app/src/main/kotlin/org/libremail/domain/model/MailSecurity.kt b/app/src/main/kotlin/org/libremail/domain/model/MailSecurity.kt new file mode 100644 index 0000000..630baa8 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/MailSecurity.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Transport security for an IMAP/SMTP connection. */ +enum class MailSecurity { + /** Implicit TLS (e.g. IMAPS 993 / SMTPS 465). */ + SSL_TLS, + + /** Upgrade a plaintext connection with STARTTLS (e.g. 143 / 587). */ + STARTTLS, + + /** No transport security — only for local test servers. */ + NONE, +} 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 8e34258..0d9becc 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Message.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Message.kt @@ -9,7 +9,10 @@ data class Message( val subject: String, val snippet: String, val body: String, + val isHtml: Boolean, val timestampMillis: Long, val isRead: Boolean, val isStarred: Boolean, + /** True for messages synced as part of the inbox; false for transient server-search hits. */ + val inInbox: Boolean = true, ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt new file mode 100644 index 0000000..bfa25bb --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** A message waiting in the outbox. [lastError] is null while queued, set after a failed attempt. */ +data class OutboxMessage( + val id: String, + val to: String, + val subject: String, + val body: String, + val createdAt: Long, + val lastError: String?, +) diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt new file mode 100644 index 0000000..89ba3c7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** A message the user is sending. [to]/[cc] are comma-separated address lists. */ +data class OutgoingMessage( + val accountId: String, + val to: String, + val cc: String = "", + val subject: String, + val body: String, + val attachments: List = emptyList(), +) + +/** A file the user attached, referenced by its content-URI string until it is sent. */ +data class OutgoingAttachment( + val uri: String, + val name: String, +) diff --git a/app/src/main/kotlin/org/libremail/domain/model/ServerConfig.kt b/app/src/main/kotlin/org/libremail/domain/model/ServerConfig.kt new file mode 100644 index 0000000..9e6524c --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/ServerConfig.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Host/port/security for one mail server (IMAP or SMTP). */ +data class ServerConfig( + val host: String, + val port: Int, + val security: MailSecurity, +) diff --git a/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt b/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt new file mode 100644 index 0000000..bd90c6a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Everything needed for a single SMTP send attempt (transient; not persisted). */ +data class SmtpParams( + val host: String, + val port: Int, + val security: MailSecurity, + val username: String, + /** Password, app-password, or — when [useXoauth2] is true — an OAuth access token. */ + val secret: String, + val useXoauth2: Boolean, + /** When true (default), a STARTTLS upgrade must succeed; when false it is best-effort. */ + val strictStartTls: Boolean = true, +) diff --git a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt new file mode 100644 index 0000000..a571b2c --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.libremail.domain.model.Account +import org.libremail.domain.model.ImapConnectionParams + +/** Account management: list, add (with a live connection test), and remove accounts. */ +interface AccountRepository { + fun observeAccounts(): Flow> + + /** Attempt an IMAP login and return the server's folder names. */ + suspend fun testConnection(params: ImapConnectionParams): Result> + + /** Verify, then persist, a password/app-password IMAP account. Returns the folders found. */ + suspend fun addImapAccount(account: Account, password: String): Result> + + /** Verify (via XOAUTH2), then persist, an Outlook account. Returns the folders found. */ + suspend fun addOutlookAccount(email: String, accessToken: String, authStateJson: String): Result> + + suspend fun deleteAccount(id: String) +} 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 d9b8871..bee54b6 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -1,18 +1,52 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.domain.repository +import java.io.File import kotlinx.coroutines.flow.Flow -import org.libremail.domain.model.Account +import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Draft import org.libremail.domain.model.Message +import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.model.OutgoingMessage /** - * Abstraction over the local cache (and, in later increments, network sync). + * Abstraction over the local message cache (and, in later increments, network sync). * The UI always reads from here; the cache is the single source of truth. */ interface MailRepository { fun observeMessages(): Flow> - fun observeAccounts(): Flow> - suspend fun getMessage(id: String): Message? + + /** Loads a message for reading: fetches+caches the body if missing, and marks it read. */ + suspend fun openMessage(id: String): Result + + /** Cached attachment metadata for a message, populated when the message is opened. */ + fun observeAttachments(messageId: String): Flow> + + /** Downloads an attachment's bytes to a local cache file and returns it. */ + suspend fun downloadAttachment(messageId: String, partIndex: Int): Result + + suspend fun setStarred(id: String, starred: Boolean): Result + + suspend fun deleteMessage(id: String): Result + + suspend fun sendMessage(outgoing: OutgoingMessage): Result + + /** Drafts: composed-but-unsent messages saved for later. */ + fun observeDrafts(): Flow> + suspend fun getDraft(id: String): Draft? + suspend fun saveDraft(draft: Draft) + suspend fun deleteDraft(id: String) + + /** The outbox: messages queued for sending. */ + fun observeOutbox(): Flow> + 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) + + /** 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/GraphSender.kt b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt new file mode 100644 index 0000000..7f62514 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import jakarta.mail.internet.InternetAddress +import java.io.File +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.Base64 +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import org.libremail.domain.model.OutgoingMessage + +/** + * Thrown when a Graph `sendMail` attempt fails. [mayHaveSent] is true only when the request was + * fully transmitted but the response could not be read — in that case the message may already be on + * its way, so callers must NOT retry or fall back to another transport (doing so would duplicate it). + */ +class GraphSendException( + message: String, + val mayHaveSent: Boolean, + cause: Throwable? = null, +) : Exception(message, cause) + +/** + * Sends mail via Microsoft Graph `me/sendMail` — Microsoft's preferred send path for Outlook / + * Microsoft 365, used in place of SMTP. Authenticated with a Graph access token (Bearer). + */ +@Singleton +class GraphSender @Inject constructor() { + + suspend fun send( + accessToken: String, + message: OutgoingMessage, + attachments: List = emptyList(), + ) = withContext(Dispatchers.IO) { + val payload = buildSendMailPayload(message, attachments) + val connection = (URL(SEND_MAIL_URL).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + doOutput = true + setRequestProperty("Authorization", "Bearer $accessToken") + setRequestProperty("Content-Type", "application/json; charset=utf-8") + } + try { + // Failure here means the request never reached Graph — safe to fall back/retry. + try { + connection.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) } + } catch (e: IOException) { + throw GraphSendException("Graph sendMail could not be transmitted", mayHaveSent = false, cause = e) + } + // The request was fully sent; if we can't read the response, Graph may already have + // accepted and sent it — do not fall back to SMTP or the message would be duplicated. + val code = try { + connection.responseCode + } catch (e: IOException) { + throw GraphSendException("Graph sendMail sent but no response received", mayHaveSent = true, cause = e) + } + if (code !in 200..299) { + val body = (connection.errorStream ?: connection.inputStream) + ?.bufferedReader()?.use { it.readText() }.orEmpty() + // An explicit non-2xx means Graph rejected (did not send) — safe to fall back. + throw GraphSendException("Graph sendMail failed (HTTP $code): ${body.take(500)}", mayHaveSent = false) + } + } finally { + connection.disconnect() + } + } + + private companion object { + const val SEND_MAIL_URL = "https://graph.microsoft.com/v1.0/me/sendMail" + const val TIMEOUT_MS = 15_000 + } +} + +/** Builds the Graph `sendMail` JSON body (a pure function, so it is unit-testable without a network). */ +internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List): String { + val mail = JSONObject() + .put("subject", message.subject) + .put("body", JSONObject().put("contentType", "Text").put("content", message.body)) + .put("toRecipients", recipientsJson(message.to)) + if (message.cc.isNotBlank()) { + mail.put("ccRecipients", recipientsJson(message.cc)) + } + if (attachments.isNotEmpty()) { + val items = JSONArray() + attachments.forEach { file -> + items.put( + JSONObject() + .put("@odata.type", "#microsoft.graph.fileAttachment") + .put("name", file.name) + .put("contentBytes", Base64.getEncoder().encodeToString(file.readBytes())), + ) + } + mail.put("attachments", items) + } + return JSONObject().put("message", mail).put("saveToSentItems", true).toString() +} + +/** + * Parses an address list into Graph `emailAddress` recipient objects. Uses RFC 822 parsing (the + * same as the SMTP path) so display-name recipients like `John Doe ` — and commas + * inside quoted display names — produce a valid bare `address` (plus an optional `name`). + */ +private fun recipientsJson(addresses: String): JSONArray { + val array = JSONArray() + val parsed = runCatching { InternetAddress.parse(addresses, false) }.getOrNull() ?: emptyArray() + parsed.forEach { addr -> + val email = addr.address?.trim().orEmpty() + if (email.isEmpty()) return@forEach + val emailAddress = JSONObject().put("address", email) + addr.personal?.takeIf { it.isNotBlank() }?.let { emailAddress.put("name", it) } + array.put(JSONObject().put("emailAddress", emailAddress)) + } + return array +} diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt new file mode 100644 index 0000000..b60d623 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import android.util.Log +import jakarta.mail.FetchProfile +import jakarta.mail.Flags +import jakarta.mail.Folder +import jakarta.mail.Message +import jakarta.mail.Multipart +import jakarta.mail.Part +import jakarta.mail.Session +import jakarta.mail.Store +import jakarta.mail.UIDFolder +import jakarta.mail.event.MessageCountAdapter +import jakarta.mail.event.MessageCountEvent +import jakarta.mail.internet.ContentType +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeUtility +import jakarta.mail.search.BodyTerm +import jakarta.mail.search.FromStringTerm +import jakarta.mail.search.OrTerm +import jakarta.mail.search.SubjectTerm +import java.util.Properties +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.eclipse.angus.mail.imap.IMAPFolder +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.MailSecurity + +/** A message header fetched from the server (no body — that arrives with the reader). */ +data class FetchedMessage( + val uid: String, + val sender: String, + val senderEmail: String, + val subject: String, + val timestampMillis: Long, + val isRead: Boolean, + val isFlagged: Boolean, +) + +/** A message body extracted from the server, with metadata for any attachment parts. */ +data class MessageContent( + val body: String, + val isHtml: Boolean, + val attachments: List = emptyList(), +) + +/** Metadata for one attachment part. [partIndex] is its position in attachment-tree order. */ +data class AttachmentPart( + val partIndex: Int, + val filename: String, + val mimeType: String, + val sizeBytes: Long, +) + +/** A downloaded attachment's bytes plus the metadata needed to open it. */ +class DownloadedAttachment( + val filename: String, + val mimeType: String, + val bytes: ByteArray, +) + +/** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */ +@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) { + withStore(params) { store -> + store.defaultFolder.list("*").map { it.fullName } + } + } + + /** Fetches the most recent [limit] INBOX headers, newest first. */ + suspend fun fetchRecentInbox(params: ImapConnectionParams, limit: Int): List = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_ONLY) + try { + val total = inbox.messageCount + if (total == 0) return@withStore emptyList() + + val messages = inbox.getMessages(maxOf(1, total - limit + 1), total) + inbox.fetch( + messages, + FetchProfile().apply { + add(FetchProfile.Item.ENVELOPE) + add(FetchProfile.Item.FLAGS) + add(UIDFolder.FetchProfileItem.UID) + }, + ) + val uidFolder = inbox as UIDFolder + messages.reversed().map { it.toFetchedMessage(uidFolder) } + } finally { + runCatching { inbox.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 = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_ONLY) + try { + val term = OrTerm(arrayOf(SubjectTerm(query), FromStringTerm(query), BodyTerm(query))) + val matches = inbox.search(term).toList() + if (matches.isEmpty()) return@withStore emptyList() + val recent = if (matches.size > limit) matches.takeLast(limit) else matches + inbox.fetch( + recent.toTypedArray(), + FetchProfile().apply { + add(FetchProfile.Item.ENVELOPE) + add(FetchProfile.Item.FLAGS) + add(UIDFolder.FetchProfileItem.UID) + }, + ) + val uidFolder = inbox as UIDFolder + recent.reversed().map { it.toFetchedMessage(uidFolder) } + } finally { + runCatching { inbox.close(false) } + } + } + } + + /** Fetches a message body by UID and marks it \Seen on the server. */ + suspend fun fetchBodyMarkingSeen(params: ImapConnectionParams, uid: String): MessageContent = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_WRITE) + try { + val message = (inbox 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) } + } + } + } + + /** Downloads the bytes of one attachment part (identified by its [partIndex]). */ + suspend fun fetchAttachment(params: ImapConnectionParams, uid: String, partIndex: Int): DownloadedAttachment = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_ONLY) + try { + val message = (inbox as UIDFolder).getMessageByUID(uid.toLong()) + ?: error("Message $uid not found") + val parts = mutableListOf() + collectAttachmentParts(message, parts) + val part = parts.getOrNull(partIndex) ?: error("Attachment $partIndex not found") + val bytes = part.inputStream.use { it.readBytes() } + DownloadedAttachment( + filename = attachmentName(part) ?: "attachment", + mimeType = baseType(part), + bytes = bytes, + ) + } finally { + runCatching { inbox.close(false) } + } + } + } + + suspend fun setFlag(params: ImapConnectionParams, uid: String, flag: Flags.Flag, value: Boolean) = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_WRITE) + try { + (inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(flag, value) + } finally { + runCatching { inbox.close(false) } + } + } + } + + suspend fun deleteMessage(params: ImapConnectionParams, uid: String) = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_WRITE) + try { + (inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(Flags.Flag.DELETED, true) + inbox.expunge() + } finally { + runCatching { inbox.close(false) } + } + } + } + + /** + * Holds a long-lived IMAP connection and uses IMAP IDLE to wait for server activity. The + * server pushes new-mail notifications while [IMAPFolder.idle] blocks; Jakarta dispatches them + * to the message-count listener (not by returning from idle()), so we forward each push to + * [onActivity] via a conflated channel. Runs until the coroutine is cancelled (which closes the + * connection to unblock idle()) or a connection error is thrown, leaving reconnection to the caller. + */ + suspend fun idle(params: ImapConnectionParams, onActivity: suspend () -> Unit) = + withContext(Dispatchers.IO) { + val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" + val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) + store.connect(params.host, params.port, params.username, params.secret) + // Close the just-connected store if opening the folder fails, so a failed connect in the + // IDLE reconnect loop can't leak connections until the server's per-account limit is hit. + val inbox = try { + (store.getFolder("INBOX") as IMAPFolder).also { it.open(Folder.READ_ONLY) } + } catch (e: Throwable) { + runCatching { store.close() } + throw e + } + Log.d(TAG, "IDLE connected") + + val pushes = Channel(Channel.CONFLATED) + inbox.addMessageCountListener(object : MessageCountAdapter() { + override fun messagesAdded(event: MessageCountEvent) { + Log.d(TAG, "IDLE push: ${event.messages.size} new message(s)") + pushes.trySend(Unit) + } + }) + + coroutineScope { + val syncer = launch { + for (signal in pushes) onActivity() + } + // Close the connection the moment this scope is cancelled (renewal timeout or + // service stop). Doing it here — at cancellation *start*, not job completion — + // unblocks the blocking idle() read below so the loop exits promptly; a + // completion handler would never run while idle() is still blocked. + val closer = launch { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { runCatching { store.close() } } + } + } + // Sync once on connect to catch anything that arrived before IDLE was established. + pushes.trySend(Unit) + try { + while (isActive) { + inbox.idle() + } + } catch (e: Exception) { + if (isActive) throw e // a real connection error: let the caller reconnect + } finally { + closer.cancel() + syncer.cancel() + pushes.close() + runCatching { inbox.close(false) } + runCatching { store.close() } + } + } + } + + /** Recursively finds the best body part: HTML preferred, plain text otherwise. */ + private fun extractBody(part: Part): MessageContent? { + if (part.isMimeType("text/html")) return MessageContent(part.content.toString(), isHtml = true) + if (part.isMimeType("text/plain")) return MessageContent(part.content.toString(), isHtml = false) + if (part.isMimeType("multipart/*")) { + val multipart = part.content as? Multipart ?: return null + var plain: MessageContent? = null + for (i in 0 until multipart.count) { + val child = multipart.getBodyPart(i) + if (Part.ATTACHMENT.equals(child.disposition, ignoreCase = true)) continue + val result = extractBody(child) ?: continue + if (result.isHtml) return result + if (plain == null) plain = result + } + return plain + } + return null + } + + /** Walks the MIME tree and returns attachment metadata in a stable, depth-first order. */ + private fun collectAttachments(message: Part): List { + val parts = mutableListOf() + collectAttachmentParts(message, parts) + return parts.mapIndexed { index, part -> + AttachmentPart( + partIndex = index, + filename = attachmentName(part) ?: "attachment", + mimeType = baseType(part), + sizeBytes = part.size.toLong().coerceAtLeast(0L), + ) + } + } + + private fun collectAttachmentParts(part: Part, into: MutableList) { + when { + part.isMimeType("multipart/*") -> { + val multipart = part.content as? Multipart ?: return + for (i in 0 until multipart.count) collectAttachmentParts(multipart.getBodyPart(i), into) + } + isAttachment(part) -> into.add(part) + } + } + + private fun isAttachment(part: Part): Boolean = + Part.ATTACHMENT.equals(part.disposition, ignoreCase = true) || !part.fileName.isNullOrBlank() + + private fun attachmentName(part: Part): String? = + part.fileName?.let { runCatching { MimeUtility.decodeText(it) }.getOrDefault(it) } + + private fun baseType(part: Part): String = + runCatching { ContentType(part.contentType).baseType }.getOrDefault("application/octet-stream") + + private fun Message.toFetchedMessage(uidFolder: UIDFolder): FetchedMessage { + val from = from?.firstOrNull() as? InternetAddress + return FetchedMessage( + uid = uidFolder.getUID(this).toString(), + sender = from?.personal ?: from?.address ?: "(unknown sender)", + senderEmail = from?.address.orEmpty(), + subject = subject ?: "(no subject)", + timestampMillis = (sentDate ?: receivedDate)?.time ?: System.currentTimeMillis(), + isRead = isSet(Flags.Flag.SEEN), + isFlagged = isSet(Flags.Flag.FLAGGED), + ) + } + + private inline fun withStore(params: ImapConnectionParams, block: (Store) -> T): T { + val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" + val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) + store.connect(params.host, params.port, params.username, params.secret) + return try { + block(store) + } finally { + runCatching { store.close() } + } + } + + private fun buildProps(protocol: String, params: ImapConnectionParams): Properties = Properties().apply { + put("mail.store.protocol", protocol) + put("mail.$protocol.host", params.host) + put("mail.$protocol.port", params.port.toString()) + put("mail.$protocol.connectiontimeout", TIMEOUT_MS) + put("mail.$protocol.timeout", TIMEOUT_MS) + put("mail.$protocol.writetimeout", TIMEOUT_MS) + if (params.security == MailSecurity.STARTTLS) { + put("mail.$protocol.starttls.enable", "true") + put("mail.$protocol.starttls.required", params.strictStartTls.toString()) + } + // Verify the server certificate matches the host whenever TLS is used. Angus already + // defaults this to true; set it explicitly so a future library-default change can't + // silently disable hostname checking and expose us to MITM. (No-op for MailSecurity.NONE.) + put("mail.$protocol.ssl.checkserveridentity", "true") + if (params.useXoauth2) { + put("mail.$protocol.auth.mechanisms", "XOAUTH2") + } + } + + private companion object { + const val TIMEOUT_MS = "15000" + const val TAG = "LibreMailIdle" + } +} diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt new file mode 100644 index 0000000..d7cea75 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import jakarta.mail.Message +import jakarta.mail.Session +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeBodyPart +import jakarta.mail.internet.MimeMessage +import jakarta.mail.internet.MimeMultipart +import java.io.File +import java.util.Date +import java.util.Properties +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.OutgoingMessage +import org.libremail.domain.model.SmtpParams + +/** Sends mail via SMTP over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */ +@Singleton +class SmtpSender @Inject constructor() { + + suspend fun send( + params: SmtpParams, + from: String, + message: OutgoingMessage, + attachments: List = emptyList(), + ) = + withContext(Dispatchers.IO) { + val protocol = if (params.security == MailSecurity.SSL_TLS) "smtps" else "smtp" + val props = Properties().apply { + put("mail.transport.protocol", protocol) + put("mail.$protocol.host", params.host) + put("mail.$protocol.port", params.port.toString()) + put("mail.$protocol.auth", "true") + put("mail.$protocol.connectiontimeout", TIMEOUT_MS) + put("mail.$protocol.timeout", TIMEOUT_MS) + put("mail.$protocol.writetimeout", TIMEOUT_MS) + if (params.security == MailSecurity.SSL_TLS) { + put("mail.$protocol.ssl.enable", "true") + } + if (params.security == MailSecurity.STARTTLS) { + put("mail.$protocol.starttls.enable", "true") + put("mail.$protocol.starttls.required", params.strictStartTls.toString()) + } + // Verify the server certificate matches the host whenever TLS is used (explicit so a + // future Angus default change can't silently disable it). No-op for MailSecurity.NONE. + put("mail.$protocol.ssl.checkserveridentity", "true") + if (params.useXoauth2) { + put("mail.$protocol.auth.mechanisms", "XOAUTH2") + } + } + + val session = Session.getInstance(props) + val mime = MimeMessage(session).apply { + setFrom(InternetAddress(from)) + setRecipients(Message.RecipientType.TO, InternetAddress.parse(message.to)) + if (message.cc.isNotBlank()) { + setRecipients(Message.RecipientType.CC, InternetAddress.parse(message.cc)) + } + subject = message.subject + sentDate = Date() + } + if (attachments.isEmpty()) { + mime.setText(message.body, "UTF-8") + } else { + val multipart = MimeMultipart() + multipart.addBodyPart(MimeBodyPart().apply { setText(message.body, "UTF-8") }) + attachments.forEach { file -> + multipart.addBodyPart(MimeBodyPart().apply { attachFile(file) }) + } + mime.setContent(multipart) + } + + val transport = session.getTransport(protocol) + transport.connect(params.host, params.port, params.username, params.secret) + try { + transport.sendMessage(mime, mime.allRecipients) + } finally { + runCatching { transport.close() } + } + } + + private companion object { + const val TIMEOUT_MS = "15000" + } +} diff --git a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt new file mode 100644 index 0000000..4eae29f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.notifications + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import org.libremail.MainActivity +import org.libremail.R +import org.libremail.data.local.entity.MessageEntity + +/** Posts on-device new-mail notifications (no push service involved). */ +@Singleton +class MailNotifier @Inject constructor( + @ApplicationContext private val context: Context, +) { + // Permission is checked via hasPermission() below; lint can't trace the indirect guard. + @SuppressLint("MissingPermission") + fun notifyNewMail(messages: List) { + if (messages.isEmpty() || !hasPermission()) return + ensureChannel() + val manager = NotificationManagerCompat.from(context) + val contentIntent = contentIntent() + + // One notification per message, keyed by a stable id, so a later batch never overwrites an + // earlier, still-unacknowledged one. setOnlyAlertOnce avoids re-buzzing for the same message. + messages.forEach { message -> + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(message.sender) + .setContentText(message.subject) + .setStyle(NotificationCompat.BigTextStyle().bigText(message.subject)) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setGroup(GROUP_KEY) + .setContentIntent(contentIntent) + .build() + manager.notify(notificationId(message.id), notification) + } + + // Group summary (the system shows it only once two or more children are present). + val summary = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(context.getString(R.string.notif_channel_new_mail)) + .setStyle( + NotificationCompat.InboxStyle().also { style -> + messages.take(SUMMARY_LINES).forEach { style.addLine("${it.sender}: ${it.subject}") } + }, + ) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setContentIntent(contentIntent) + .build() + manager.notify(SUMMARY_ID, summary) + } + + private fun contentIntent(): PendingIntent { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + /** Stable per-message id distinct from the summary id, so each message gets its own notification. */ + private fun notificationId(messageId: String): Int { + val hash = messageId.hashCode() + return if (hash == SUMMARY_ID) hash + 1 else hash + } + + private fun hasPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + + private fun ensureChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notif_channel_new_mail), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + // Redact sender/subject on a secure lock screen (the system shows a generic placeholder + // instead). Applies on fresh installs; Android ignores channel changes after creation. + lockscreenVisibility = Notification.VISIBILITY_PRIVATE + } + NotificationManagerCompat.from(context).createNotificationChannel(channel) + } + + private companion object { + const val CHANNEL_ID = "new_mail" + const val GROUP_KEY = "org.libremail.NEW_MAIL" + const val SUMMARY_ID = 1001 + const val SUMMARY_LINES = 5 + } +} diff --git a/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt b/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt new file mode 100644 index 0000000..25f2089 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** Starts and stops [IdleService] to match the user's push-mail (IMAP IDLE) preference. */ +@Singleton +class IdlePushManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + fun start() { + // Starting a foreground service from a background process is disallowed on modern Android; + // swallow that case — periodic WorkManager sync still covers mail, and IDLE starts the next + // time the app is in the foreground. + runCatching { + ContextCompat.startForegroundService(context, Intent(context, IdleService::class.java)) + } + } + + fun stop() { + runCatching { context.stopService(Intent(context, IdleService::class.java)) } + } +} diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt new file mode 100644 index 0000000..d8f82a0 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.libremail.R +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.toDomain +import org.libremail.data.sync.MailConnectionFactory +import org.libremail.data.sync.MailSyncer +import org.libremail.domain.model.Account +import org.libremail.mail.ImapClient + +/** + * Foreground service that holds a long-lived IMAP IDLE connection per account so the server can + * push new mail to us instantly — no polling, and no third-party push service. When IDLE reports + * activity we run a normal sync, which writes to Room and fires the new-mail notification. + */ +@AndroidEntryPoint +class IdleService : Service() { + + @Inject lateinit var accountDao: AccountDao + @Inject lateinit var connectionFactory: MailConnectionFactory + @Inject lateinit var imapClient: ImapClient + @Inject lateinit var mailSyncer: MailSyncer + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var watching = false + + /** Active IDLE watcher per account id, so we can start/stop them as accounts change. */ + private val watchers = mutableMapOf() + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startAsForeground() + if (!watching) { + watching = true + scope.launch { reconcileWatchers() } + } + return START_STICKY + } + + /** + * Observes the account list and keeps one IDLE watcher per account: a watcher is started for a + * newly-added account and cancelled when its account is removed (which promptly closes that + * account's IDLE connection). The service is started/stopped by the app based on whether any + * accounts exist, so reaching zero here is just a transient state. + */ + private suspend fun reconcileWatchers() { + accountDao.observeAll().collect { entities -> + val accounts = entities.map { it.toDomain() } + val currentIds = accounts.mapTo(mutableSetOf()) { it.id } + (watchers.keys - currentIds).forEach { id -> watchers.remove(id)?.cancel() } + accounts.forEach { account -> + if (account.id !in watchers) { + watchers[account.id] = scope.launch { watchAccount(account) } + } + } + } + } + + /** + * Holds IDLE for one account, reconnecting with exponential backoff whenever it drops. + * Each IDLE session is bounded by [IDLE_RENEWAL_MS]: when it elapses, [withTimeoutOrNull] + * cancels idle() (which closes the connection to unblock it) and we reconnect with a fresh + * IDLE. This re-issues IDLE well within RFC 2177's 29-minute limit and before NAT/firewall + * idle-socket timeouts would silently strand the connection. Each reconnect catches up via + * idle()'s on-connect sync, so no mail is missed across renewals. + */ + private suspend fun watchAccount(account: Account) { + var backoffMs = INITIAL_BACKOFF_MS + while (scope.isActive) { + try { + val params = connectionFactory.imapParamsFor(account) + withTimeoutOrNull(IDLE_RENEWAL_MS) { + // Sync just this account on its own push — not every account. + imapClient.idle(params) { mailSyncer.syncAccount(account.id) } + } + backoffMs = INITIAL_BACKOFF_MS + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + delay(backoffMs) + backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + } + } + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startAsForeground() { + NotificationManagerCompat.from(this).createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.notif_channel_push_status), + NotificationManager.IMPORTANCE_LOW, + ), + ) + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(getString(R.string.notif_push_status_title)) + .setContentText(getString(R.string.notif_push_status_text)) + .setOngoing(true) + .setShowWhen(false) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + ServiceCompat.startForeground( + this, + FOREGROUND_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private companion object { + const val CHANNEL_ID = "push_status" + const val FOREGROUND_ID = 1002 + const val INITIAL_BACKOFF_MS = 5_000L + const val MAX_BACKOFF_MS = 5 * 60_000L + + // Re-establish IDLE on this cadence — under RFC 2177's 29-minute ceiling and short enough + // to beat typical NAT/firewall idle-socket timeouts. + const val IDLE_RENEWAL_MS = 9 * 60_000L + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 8a7dfec..550bbac 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -20,9 +20,12 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import org.libremail.R import org.libremail.ui.accountsetup.AccountSetupScreen +import org.libremail.ui.accountsetup.ManualSetupScreen import org.libremail.ui.compose.ComposeScreen +import org.libremail.ui.drafts.DraftsScreen import org.libremail.ui.mailbox.MailboxScreen import org.libremail.ui.navigation.Routes +import org.libremail.ui.outbox.OutboxScreen import org.libremail.ui.reader.ReaderScreen import org.libremail.ui.settings.SettingsScreen @@ -43,7 +46,10 @@ fun LibreMailApp() { composable(Routes.MAILBOX) { MailboxScreen( onOpenMessage = { id -> navController.navigate(Routes.reader(id)) }, - onCompose = { navController.navigate(Routes.COMPOSE) }, + onCompose = { navController.navigate(Routes.compose()) }, + onOpenDrafts = { navController.navigate(Routes.DRAFTS) }, + onOpenOutbox = { navController.navigate(Routes.OUTBOX) }, + onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) }, onSelectTab = navController::navigateTab, ) } @@ -51,9 +57,20 @@ fun LibreMailApp() { route = Routes.READER_PATTERN, arguments = listOf(navArgument(Routes.READER_ARG_ID) { type = NavType.StringType }), ) { - ReaderScreen(onBack = navController::popBackStack) + ReaderScreen( + onBack = navController::popBackStack, + onReply = { to, subject, from -> navController.navigate(Routes.compose(to, subject, from)) }, + ) } - composable(Routes.COMPOSE) { + composable( + route = Routes.COMPOSE_PATTERN, + arguments = listOf( + navArgument(Routes.COMPOSE_ARG_TO) { type = NavType.StringType; defaultValue = "" }, + navArgument(Routes.COMPOSE_ARG_SUBJECT) { type = NavType.StringType; defaultValue = "" }, + navArgument(Routes.COMPOSE_ARG_FROM) { type = NavType.StringType; defaultValue = "" }, + navArgument(Routes.COMPOSE_ARG_DRAFT) { type = NavType.StringType; defaultValue = "" }, + ), + ) { ComposeScreen(onBack = navController::popBackStack) } composable(Routes.SETTINGS) { @@ -63,7 +80,34 @@ fun LibreMailApp() { ) } composable(Routes.ACCOUNT_SETUP) { - AccountSetupScreen(onBack = navController::popBackStack) + AccountSetupScreen( + onBack = navController::popBackStack, + onManualSetup = { navController.navigate(Routes.MANUAL_SETUP) }, + onAccountAdded = { + navController.navigate(Routes.MAILBOX) { + popUpTo(Routes.MAILBOX) { inclusive = true } + } + }, + ) + } + composable(Routes.MANUAL_SETUP) { + ManualSetupScreen( + onBack = navController::popBackStack, + onAccountAdded = { + navController.navigate(Routes.MAILBOX) { + popUpTo(Routes.MAILBOX) { inclusive = true } + } + }, + ) + } + composable(Routes.DRAFTS) { + DraftsScreen( + onBack = navController::popBackStack, + onOpenDraft = { id -> navController.navigate(Routes.composeDraft(id)) }, + ) + } + composable(Routes.OUTBOX) { + OutboxScreen(onBack = navController::popBackStack) } } } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt index f9fc710..b76d5d4 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt @@ -1,7 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.accountsetup +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -13,65 +17,114 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Email import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton 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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AccountSetupScreen(onBack: () -> Unit) { +fun AccountSetupScreen( + onBack: () -> Unit, + onManualSetup: () -> Unit, + onAccountAdded: () -> Unit, + viewModel: AccountSetupViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + val outlookLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> viewModel.onOutlookResult(result.data) } + + LaunchedEffect(state.status) { + if (state.status == SetupStatus.DONE) onAccountAdded() + } + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } + + val busy = state.status == SetupStatus.CONNECTING + Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.title_account_setup)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.action_back), - ) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } }, ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - Icons.Filled.Email, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(16.dp)) - Text( - text = stringResource(R.string.account_setup_subtitle), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(24.dp)) - // OAuth + IMAP/SMTP wiring arrives in the next increment. - Button(onClick = {}, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.account_setup_gmail)) + Box(Modifier.fillMaxSize().padding(padding)) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Email, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.account_setup_subtitle), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + Button( + onClick = { outlookLauncher.launch(viewModel.outlookAuthIntent()) }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.account_setup_outlook)) + } + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = onManualSetup, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.account_setup_other)) + } } - Spacer(Modifier.height(12.dp)) - OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.account_setup_other)) + if (busy) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } } } } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt new file mode 100644 index 0000000..787e5ea --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.auth.OutlookAuthManager +import org.libremail.domain.repository.AccountRepository + +/** Stage of an account-setup attempt, shared by the Outlook and manual flows. */ +enum class SetupStatus { IDLE, CONNECTING, DONE } + +data class AccountSetupUiState( + val status: SetupStatus = SetupStatus.IDLE, + val error: String? = null, +) + +@HiltViewModel +class AccountSetupViewModel @Inject constructor( + private val outlookAuthManager: OutlookAuthManager, + private val accountRepository: AccountRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(AccountSetupUiState()) + val state: StateFlow = _state.asStateFlow() + + val isOutlookConfigured: Boolean get() = outlookAuthManager.isConfigured + + fun outlookAuthIntent(): Intent = outlookAuthManager.createAuthIntent() + + fun onOutlookResult(data: Intent?) { + if (data == null) { + _state.update { it.copy(error = "Microsoft sign-in was cancelled") } + return + } + viewModelScope.launch { + _state.update { it.copy(status = SetupStatus.CONNECTING, error = null) } + runCatching { + val oauth = outlookAuthManager.exchangeToken(data) + accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow() + }.fold( + onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } }, + onFailure = { e -> _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") } }, + ) + } + } + + fun consumeError() = _state.update { it.copy(error = null) } +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt new file mode 100644 index 0000000..5806157 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.libremail.R +import org.libremail.domain.model.MailSecurity + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManualSetupScreen( + onBack: () -> Unit, + onAccountAdded: () -> Unit, + viewModel: ManualSetupViewModel = hiltViewModel(), +) { + val form by viewModel.form.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(form.status) { + if (form.status == SetupStatus.DONE) onAccountAdded() + } + LaunchedEffect(form.error) { + form.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } + + val busy = form.status == SetupStatus.CONNECTING + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.manual_setup_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + OutlinedTextField( + value = form.email, + onValueChange = viewModel::onEmail, + label = { Text(stringResource(R.string.manual_email)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = form.password, + onValueChange = viewModel::onPassword, + label = { Text(stringResource(R.string.manual_password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(20.dp)) + SectionLabel(stringResource(R.string.manual_incoming)) + OutlinedTextField( + value = form.imapHost, + onValueChange = viewModel::onImapHost, + label = { Text(stringResource(R.string.manual_imap_server)) }, + placeholder = { Text("imap.example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(16.dp)) + SectionLabel(stringResource(R.string.manual_outgoing)) + OutlinedTextField( + value = form.smtpHost, + onValueChange = viewModel::onSmtpHost, + label = { Text(stringResource(R.string.manual_smtp_server)) }, + placeholder = { Text("smtp.example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + AdvancedToggle(expanded = form.advancedExpanded, onToggle = viewModel::toggleAdvanced) + AnimatedVisibility(visible = form.advancedExpanded) { + Column { + OutlinedTextField( + value = form.imapPort, + onValueChange = viewModel::onImapPort, + label = { Text(stringResource(R.string.manual_imap_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + SecuritySelector(stringResource(R.string.manual_imap_security), form.imapSecurity, viewModel::onImapSecurity) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = form.smtpPort, + onValueChange = viewModel::onSmtpPort, + label = { Text(stringResource(R.string.manual_smtp_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + SecuritySelector(stringResource(R.string.manual_smtp_security), form.smtpSecurity, viewModel::onSmtpSecurity) + } + } + + Spacer(Modifier.height(24.dp)) + Button( + onClick = viewModel::testAndSave, + enabled = form.isValid && !busy, + modifier = Modifier.fillMaxWidth(), + ) { + if (busy) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(stringResource(R.string.manual_test_and_add)) + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 4.dp), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SecuritySelector(label: String, selected: MailSecurity, onSelect: (MailSecurity) -> Unit) { + Column { + Text(label, style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // NONE (no transport security) is deliberately not offered in the UI: selecting it would + // send the account password/token in cleartext. It stays in the enum only for local + // test servers, which are configured in tests rather than through this screen. + MailSecurity.entries.filter { it != MailSecurity.NONE }.forEach { security -> + FilterChip( + selected = selected == security, + onClick = { onSelect(security) }, + label = { Text(security.label()) }, + ) + } + } + } +} + +@Composable +private fun AdvancedToggle(expanded: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.settings_advanced), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (expanded) 180f else 0f), + ) + } +} + +private fun MailSecurity.label(): String = when (this) { + MailSecurity.SSL_TLS -> "SSL/TLS" + MailSecurity.STARTTLS -> "STARTTLS" + MailSecurity.NONE -> "None" +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt new file mode 100644 index 0000000..cdd57c7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.domain.repository.AccountRepository + +data class ManualSetupForm( + val email: String = "", + val password: String = "", + val imapHost: String = "", + val imapPort: String = "993", + val imapSecurity: MailSecurity = MailSecurity.SSL_TLS, + val smtpHost: String = "", + val smtpPort: String = "465", + val smtpSecurity: MailSecurity = MailSecurity.SSL_TLS, + val advancedExpanded: Boolean = false, + val status: SetupStatus = SetupStatus.IDLE, + val error: String? = null, +) { + val isValid: Boolean + get() = email.isNotBlank() && password.isNotBlank() && imapHost.isNotBlank() && smtpHost.isNotBlank() +} + +@HiltViewModel +class ManualSetupViewModel @Inject constructor( + private val accountRepository: AccountRepository, +) : ViewModel() { + + private val _form = MutableStateFlow(ManualSetupForm()) + val form: StateFlow = _form.asStateFlow() + + fun onEmail(value: String) = _form.update { it.copy(email = value) } + fun onPassword(value: String) = _form.update { it.copy(password = value) } + fun onImapHost(value: String) = _form.update { it.copy(imapHost = value) } + fun onImapPort(value: String) = _form.update { it.copy(imapPort = value.filter(Char::isDigit).take(5)) } + fun onImapSecurity(security: MailSecurity) = _form.update { it.copy(imapSecurity = security) } + fun onSmtpHost(value: String) = _form.update { it.copy(smtpHost = value) } + fun onSmtpPort(value: String) = _form.update { it.copy(smtpPort = value.filter(Char::isDigit).take(5)) } + fun onSmtpSecurity(security: MailSecurity) = _form.update { it.copy(smtpSecurity = security) } + fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) } + fun consumeError() = _form.update { it.copy(error = null) } + + fun testAndSave() { + val f = _form.value + if (!f.isValid) { + _form.update { it.copy(error = "Enter email, password, and both servers") } + return + } + val account = Account( + id = "imap:${f.email.trim()}", + email = f.email.trim(), + displayName = f.email.trim(), + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig(f.imapHost.trim(), f.imapPort.toIntOrNull() ?: 993, f.imapSecurity), + smtp = ServerConfig(f.smtpHost.trim(), f.smtpPort.toIntOrNull() ?: 465, f.smtpSecurity), + ) + viewModelScope.launch { + _form.update { it.copy(status = SetupStatus.CONNECTING, error = null) } + accountRepository.addImapAccount(account, f.password).fold( + onSuccess = { _form.update { it.copy(status = SetupStatus.DONE) } }, + onFailure = { e -> + _form.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Could not connect to the server") } + }, + ) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index b40cf64..77f57c6 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -1,35 +1,69 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.compose +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.collect import org.libremail.R +import org.libremail.domain.model.Account +import org.libremail.domain.model.OutgoingAttachment @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -38,59 +72,213 @@ fun ComposeScreen( viewModel: ComposeViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - val notImplemented = stringResource(R.string.compose_not_implemented) + val context = LocalContext.current + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> viewModel.onContactsPermission(granted) } + + val attachmentPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments(), + ) { uris -> + viewModel.addAttachments( + uris.map { uri -> + runCatching { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + OutgoingAttachment(uri.toString(), queryFileName(context, uri)) + }, + ) + } + + LaunchedEffect(Unit) { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == + PackageManager.PERMISSION_GRANTED + if (granted) viewModel.onContactsPermission(true) else permissionLauncher.launch(Manifest.permission.READ_CONTACTS) + } + LaunchedEffect(Unit) { viewModel.finished.collect { onBack() } } + BackHandler { viewModel.onExit() } + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.title_compose)) }, navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.action_back), - ) + IconButton(onClick = viewModel::onExit) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } }, actions = { - IconButton(onClick = { scope.launch { snackbarHostState.showSnackbar(notImplemented) } }) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.action_send), - ) + IconButton( + onClick = viewModel::send, + enabled = state.to.isNotBlank() && !state.sending, + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.action_send)) } }, ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), - ) { - OutlinedTextField( - value = state.to, - onValueChange = viewModel::onToChange, - label = { Text(stringResource(R.string.compose_to)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = state.subject, - onValueChange = viewModel::onSubjectChange, - label = { Text(stringResource(R.string.compose_subject)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = state.body, - onValueChange = viewModel::onBodyChange, - label = { Text(stringResource(R.string.compose_body)) }, - modifier = Modifier.fillMaxWidth().weight(1f), + Box(Modifier.fillMaxSize().padding(padding)) { + Column(Modifier.fillMaxSize().padding(16.dp)) { + FromRow(accounts = accounts, selectedId = state.fromAccountId, onSelect = viewModel::selectFrom) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = state.to, + onValueChange = viewModel::onToChange, + label = { Text(stringResource(R.string.compose_to)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + if (state.suggestions.isNotEmpty()) { + SuggestionList(state.suggestions, viewModel::pickSuggestion) + } + + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.cc, + onValueChange = viewModel::onCcChange, + label = { Text(stringResource(R.string.compose_cc)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.subject, + onValueChange = viewModel::onSubjectChange, + label = { Text(stringResource(R.string.compose_subject)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + AttachmentsSection( + attachments = state.attachments, + onAttach = { attachmentPicker.launch(arrayOf("*/*")) }, + onRemove = viewModel::removeAttachment, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.body, + onValueChange = viewModel::onBodyChange, + label = { Text(stringResource(R.string.compose_body)) }, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } + + if (state.sending) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } +} + +@Composable +private fun FromRow(accounts: List, selectedId: String?, onSelect: (String) -> Unit) { + val from = accounts.firstOrNull { it.id == selectedId } ?: accounts.firstOrNull() + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.compose_from) + ": ", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (from == null) { + Text(stringResource(R.string.compose_no_account), style = MaterialTheme.typography.bodyMedium) + } else if (accounts.size <= 1) { + Text(from.email, style = MaterialTheme.typography.bodyMedium) + } else { + var open by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { open = true }) { + Text(from.email) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + accounts.forEach { account -> + DropdownMenuItem( + text = { Text(account.email) }, + onClick = { onSelect(account.id); open = false }, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AttachmentsSection( + attachments: List, + onAttach: () -> Unit, + onRemove: (String) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + TextButton(onClick = onAttach) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.compose_attach)) + } + attachments.forEach { attachment -> + InputChip( + selected = false, + onClick = { onRemove(attachment.uri) }, + label = { Text(attachment.name, maxLines = 1) }, + trailingIcon = { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.compose_attachment_remove)) + }, ) } } } + +private fun queryFileName(context: Context, uri: Uri): String { + val name = context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + return name ?: uri.lastPathSegment?.substringAfterLast('/') ?: "attachment" +} + +@Composable +private fun SuggestionList( + suggestions: List, + onPick: (org.libremail.contacts.ContactSuggestion) -> Unit, +) { + Surface(tonalElevation = 2.dp, modifier = Modifier.fillMaxWidth()) { + Column { + suggestions.forEach { suggestion -> + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onPick(suggestion) } + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text(suggestion.name, style = MaterialTheme.typography.bodyMedium) + Text( + suggestion.email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider() + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 8f44f2b..7c640e3 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -1,26 +1,195 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.compose +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.UUID import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.contacts.ContactSuggestion +import org.libremail.contacts.ContactsRepository +import org.libremail.domain.model.Account +import org.libremail.domain.model.Draft +import org.libremail.domain.model.OutgoingAttachment +import org.libremail.domain.model.OutgoingMessage +import org.libremail.domain.repository.AccountRepository +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes data class ComposeUiState( val to: String = "", + val cc: String = "", val subject: String = "", val body: String = "", + val fromAccountId: String? = null, + val attachments: List = emptyList(), + val suggestions: List = emptyList(), + val contactsAllowed: Boolean = false, + val sending: Boolean = false, + val error: String? = null, ) @HiltViewModel -class ComposeViewModel @Inject constructor() : ViewModel() { - private val _state = MutableStateFlow(ComposeUiState()) +class ComposeViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val mailRepository: MailRepository, + private val accountRepository: AccountRepository, + private val contactsRepository: ContactsRepository, +) : ViewModel() { + + private val draftId: String? = + savedStateHandle.get(Routes.COMPOSE_ARG_DRAFT)?.takeIf { it.isNotBlank() } + + private val _state = MutableStateFlow( + ComposeUiState( + to = savedStateHandle.get(Routes.COMPOSE_ARG_TO).orEmpty(), + subject = savedStateHandle.get(Routes.COMPOSE_ARG_SUBJECT).orEmpty(), + fromAccountId = savedStateHandle.get(Routes.COMPOSE_ARG_FROM)?.takeIf { it.isNotBlank() }, + ), + ) val state: StateFlow = _state.asStateFlow() - fun onToChange(value: String) = _state.update { it.copy(to = value) } + val accounts: StateFlow> = accountRepository.observeAccounts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Emitted when the screen should close — after the draft is saved/deleted, or after sending. */ + private val _finished = Channel(Channel.BUFFERED) + val finished = _finished.receiveAsFlow() + + private var searchJob: Job? = null + + /** Guards against double-navigation and against saving a draft for an already-sent message. */ + @Volatile private var navigated = false + + init { + if (draftId != null) { + viewModelScope.launch { + mailRepository.getDraft(draftId)?.let { draft -> + _state.update { + it.copy( + to = draft.to, + cc = draft.cc, + subject = draft.subject, + body = draft.body, + fromAccountId = draft.accountId ?: it.fromAccountId, + attachments = draft.attachments, + ) + } + } + } + } + } + + fun onToChange(value: String) { + _state.update { it.copy(to = value) } + searchContacts(value) + } + + fun onCcChange(value: String) = _state.update { it.copy(cc = value) } fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) } fun onBodyChange(value: String) = _state.update { it.copy(body = value) } + fun selectFrom(accountId: String) = _state.update { it.copy(fromAccountId = accountId) } + fun addAttachments(items: List) = + _state.update { it.copy(attachments = it.attachments + items) } + fun removeAttachment(uri: String) = + _state.update { it.copy(attachments = it.attachments.filterNot { a -> a.uri == uri }) } + fun consumeError() = _state.update { it.copy(error = null) } + fun onContactsPermission(granted: Boolean) = _state.update { it.copy(contactsAllowed = granted) } + + fun pickSuggestion(suggestion: ContactSuggestion) { + val current = _state.value.to + val prefix = if (current.contains(',')) current.substringBeforeLast(',') + ", " else "" + _state.update { it.copy(to = prefix + suggestion.email, suggestions = emptyList()) } + } + + private fun searchContacts(value: String) { + searchJob?.cancel() + if (!_state.value.contactsAllowed) return + val token = value.substringAfterLast(',').trim() + if (token.length < 2) { + _state.update { it.copy(suggestions = emptyList()) } + return + } + searchJob = viewModelScope.launch { + val results = contactsRepository.search(token) + _state.update { it.copy(suggestions = results) } + } + } + + /** Leaving the screen: keep a draft if there's anything worth keeping, then close. */ + fun onExit() { + if (navigated) return + viewModelScope.launch { + // Don't save a draft for a message that's mid-send (send() will finish the screen). + if (!_state.value.sending) saveOrDeleteDraft() + finish() + } + } + + /** Closes the screen exactly once, so send() and a stray back-press can't double-pop. */ + private suspend fun finish() { + if (navigated) return + navigated = true + _finished.send(Unit) + } + + private suspend fun saveOrDeleteDraft() { + val s = _state.value + val hasContent = s.to.isNotBlank() || s.cc.isNotBlank() || s.subject.isNotBlank() || + s.body.isNotBlank() || s.attachments.isNotEmpty() + when { + hasContent -> mailRepository.saveDraft( + Draft( + id = draftId ?: UUID.randomUUID().toString(), + accountId = s.fromAccountId, + to = s.to, + cc = s.cc, + subject = s.subject, + body = s.body, + updatedAt = System.currentTimeMillis(), + attachments = s.attachments, + ), + ) + draftId != null -> mailRepository.deleteDraft(draftId) // an existing draft was emptied out + } + } + + fun send() { + viewModelScope.launch { + val s = _state.value + // Await the account list if it hasn't emitted yet, so an early tap doesn't wrongly + // report "Add an account first". + val available = accounts.value.ifEmpty { accountRepository.observeAccounts().first() } + val account = available.firstOrNull { it.id == s.fromAccountId } ?: available.firstOrNull() + when { + account == null -> _state.update { it.copy(error = "Add an account first") } + s.to.isBlank() -> _state.update { it.copy(error = "Add a recipient") } + else -> { + _state.update { it.copy(sending = true, error = null) } + mailRepository.sendMessage( + OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body, s.attachments), + ).fold( + onSuccess = { + draftId?.let { mailRepository.deleteDraft(it) } + _state.update { it.copy(sending = false) } + finish() + }, + onFailure = { e -> _state.update { it.copy(sending = false, error = e.message ?: "Could not send") } }, + ) + } + } + } + } } diff --git a/app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt new file mode 100644 index 0000000..1d2ab62 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.drafts + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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 org.libremail.R +import org.libremail.domain.model.Draft + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DraftsScreen( + onBack: () -> Unit, + onOpenDraft: (String) -> Unit, + viewModel: DraftsViewModel = hiltViewModel(), +) { + val drafts by viewModel.drafts.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.drafts_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + ) + }, + ) { padding -> + if (drafts.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.drafts_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(drafts, key = { it.id }) { draft -> + DraftRow( + draft = draft, + onClick = { onOpenDraft(draft.id) }, + onDelete = { viewModel.deleteDraft(draft.id) }, + ) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun DraftRow(draft: Draft, onClick: () -> Unit, onDelete: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = draft.subject.ifBlank { stringResource(R.string.draft_no_subject) }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = draft.to.ifBlank { stringResource(R.string.draft_no_recipient) }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (draft.body.isNotBlank()) { + Text( + text = draft.body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(Modifier.width(8.dp)) + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.draft_delete)) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt new file mode 100644 index 0000000..1a8c30c --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.drafts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.domain.model.Draft +import org.libremail.domain.repository.MailRepository + +@HiltViewModel +class DraftsViewModel @Inject constructor( + private val repository: MailRepository, +) : ViewModel() { + + val drafts: StateFlow> = repository.observeDrafts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun deleteDraft(id: String) { + viewModelScope.launch { repository.deleteDraft(id) } + } +} 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 03561d4..8e5be4b 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -2,8 +2,10 @@ package org.libremail.ui.mailbox import android.text.format.DateUtils +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,23 +19,39 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons +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.Search +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -41,22 +59,73 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R +import org.libremail.domain.model.Account import org.libremail.domain.model.Message -import org.libremail.ui.LibreMailBottomBar -import org.libremail.ui.TopDest @OptIn(ExperimentalMaterial3Api::class) @Composable fun MailboxScreen( onOpenMessage: (String) -> Unit, onCompose: () -> Unit, - onSelectTab: (TopDest) -> Unit, + onOpenDrafts: () -> Unit, + onOpenOutbox: () -> Unit, + onAddAccount: () -> Unit, + onSelectTab: (org.libremail.ui.TopDest) -> Unit, viewModel: MailboxViewModel = hiltViewModel(), ) { val messages by viewModel.messages.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle() + val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() + val draftCount by viewModel.draftCount.collectAsStateWithLifecycle() + val outboxCount by viewModel.outboxCount.collectAsStateWithLifecycle() + val searchActive by viewModel.searchActive.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + BackHandler(enabled = searchActive) { viewModel.closeSearch() } + + LaunchedEffect(error) { + error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } + Scaffold( - topBar = { TopAppBar(title = { Text(stringResource(R.string.title_mailbox)) }) }, - bottomBar = { LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = onSelectTab) }, + 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)) + } + } + }, + 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, @@ -64,14 +133,56 @@ fun MailboxScreen( text = { Text(stringResource(R.string.action_compose)) }, ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - if (messages.isEmpty()) { - EmptyState(Modifier.padding(padding)) - } else { - LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { - items(messages, key = { it.id }) { message -> - MessageRow(message = message, onClick = { onOpenMessage(message.id) }) - HorizontalDivider() + 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()) + } + } + } else { + items(messages, key = { it.id }) { message -> + MessageRow( + message = message, + accountLabel = if (showAccount) accountsById[message.accountId]?.email else null, + onClick = { onOpenMessage(message.id) }, + ) + HorizontalDivider() + } + } + } + } } } } @@ -79,7 +190,93 @@ fun MailboxScreen( } @Composable -private fun MessageRow(message: Message, onClick: () -> Unit) { +private fun SearchField(query: String, onQueryChange: (String) -> Unit) { + val focusRequester = remember { FocusRequester() } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + decorationBox = { innerTextField -> + if (query.isEmpty()) { + Text( + stringResource(R.string.search_hint), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + innerTextField() + }, + ) + LaunchedEffect(Unit) { focusRequester.requestFocus() } +} + +@Composable +private fun DraftsEntry(count: Int, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Edit, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.drafts_count, count), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun OutboxEntry(count: Int, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.outbox_count, count), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun AccountFilterRow(accounts: List, selectedId: String?, onSelect: (String?) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedId == null, + onClick = { onSelect(null) }, + label = { Text(stringResource(R.string.mailbox_all_accounts)) }, + ) + accounts.forEach { account -> + FilterChip( + selected = selectedId == account.id, + onClick = { onSelect(account.id) }, + label = { Text(account.email, maxLines = 1) }, + ) + } + } +} + +@Composable +private fun MessageRow(message: Message, accountLabel: String?, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -113,13 +310,24 @@ private fun MessageRow(message: Message, onClick: () -> Unit) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = message.snippet, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (message.snippet.isNotBlank()) { + Text( + text = message.snippet, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (accountLabel != null) { + Text( + text = accountLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -142,9 +350,37 @@ private fun Avatar(name: String) { } @Composable -private fun EmptyState(modifier: Modifier = Modifier) { +private fun NoAccountState(onAddAccount: () -> Unit) { Column( - modifier = modifier.fillMaxSize().padding(24.dp), + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Email, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + Text(stringResource(R.string.mailbox_welcome_title), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.mailbox_welcome_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + Button(onClick = onAddAccount) { + Text(stringResource(R.string.settings_add_account)) + } + } +} + +@Composable +private fun NoMessagesState(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -158,13 +394,31 @@ private fun EmptyState(modifier: Modifier = Modifier) { Text(stringResource(R.string.mailbox_empty), style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(4.dp)) Text( - stringResource(R.string.mailbox_empty_subtitle), + stringResource(R.string.mailbox_pull_to_refresh), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } +@Composable +private fun NoResultsState(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Search, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text(stringResource(R.string.search_no_results), style = MaterialTheme.typography.titleMedium) + } +} + private fun formatTimestamp(millis: Long): String = DateUtils.getRelativeTimeSpanString( millis, 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 8d92ad0..6f18500 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -5,17 +5,127 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +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.Message +import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @HiltViewModel class MailboxViewModel @Inject constructor( - repository: MailRepository, + private val mailRepository: MailRepository, + accountRepository: AccountRepository, + private val mailSyncer: MailSyncer, ) : ViewModel() { + + val accounts: StateFlow> = accountRepository.observeAccounts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val hasAccounts: StateFlow = accounts + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + /** null = unified "All inboxes"; otherwise the account whose mail is shown. */ + private val _selectedAccountId = MutableStateFlow(null) + val selectedAccountId: StateFlow = _selectedAccountId.asStateFlow() + + private val _searchActive = MutableStateFlow(false) + val searchActive: StateFlow = _searchActive.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + val messages: StateFlow> = - repository.observeMessages() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + combine(mailRepository.observeMessages(), _selectedAccountId, _searchQuery) { all, accountId, 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. + (if (q.isEmpty()) message.inInbox else message.matchesSearch(q)) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val draftCount: StateFlow = mailRepository.observeDrafts() + .map { it.size } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + + val outboxCount: StateFlow = mailRepository.observeOutbox() + .map { it.size } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + init { + // Fall back to the unified view 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 + } + } + // Server-side search: fetch matches 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) } + } + } + + fun selectAccount(accountId: String?) { + _selectedAccountId.value = accountId + } + + fun openSearch() { + _searchActive.value = true + } + + fun closeSearch() { + _searchActive.value = false + _searchQuery.value = "" + // Drop the transient server-search hits so they don't linger in the inbox. + viewModelScope.launch { mailRepository.clearSearchResults() } + } + + fun onSearchQuery(query: String) { + _searchQuery.value = query + } + + fun refresh() { + if (_isRefreshing.value) return + viewModelScope.launch { + _isRefreshing.value = true + mailSyncer.syncAll().onFailure { _error.value = it.message ?: "Sync failed" } + _isRefreshing.value = false + } + } + + fun consumeError() { + _error.value = null + } } + +/** 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) || + senderEmail.contains(query, ignoreCase = true) || + subject.contains(query, ignoreCase = true) || + snippet.contains(query, ignoreCase = true) diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index e4d5975..d65e6dc 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -1,14 +1,28 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.navigation +import android.net.Uri + /** Navigation routes. String-based to avoid extra serialization setup for the MVP. */ object Routes { const val MAILBOX = "mailbox" const val SETTINGS = "settings" - const val COMPOSE = "compose" const val ACCOUNT_SETUP = "account_setup" + const val MANUAL_SETUP = "manual_setup" + const val DRAFTS = "drafts" + const val OUTBOX = "outbox" const val READER_ARG_ID = "messageId" const val READER_PATTERN = "reader/{$READER_ARG_ID}" - fun reader(messageId: String) = "reader/$messageId" + fun reader(messageId: String) = "reader/${Uri.encode(messageId)}" + + const val COMPOSE_ARG_TO = "to" + const val COMPOSE_ARG_SUBJECT = "subject" + const val COMPOSE_ARG_FROM = "from" + const val COMPOSE_ARG_DRAFT = "draft" + const val COMPOSE_PATTERN = + "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}&from={$COMPOSE_ARG_FROM}&draft={$COMPOSE_ARG_DRAFT}" + fun compose(to: String = "", subject: String = "", from: String = ""): String = + "compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}&from=${Uri.encode(from)}" + fun composeDraft(draftId: String): String = "compose?draft=${Uri.encode(draftId)}" } diff --git a/app/src/main/kotlin/org/libremail/ui/outbox/OutboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/outbox/OutboxScreen.kt new file mode 100644 index 0000000..4a5b293 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/outbox/OutboxScreen.kt @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.outbox + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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 org.libremail.R +import org.libremail.domain.model.OutboxMessage + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutboxScreen( + onBack: () -> Unit, + viewModel: OutboxViewModel = hiltViewModel(), +) { + val messages by viewModel.messages.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.outbox_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + actions = { + if (messages.isNotEmpty()) { + TextButton(onClick = viewModel::retry) { + Text(stringResource(R.string.outbox_retry)) + } + } + }, + ) + }, + ) { padding -> + if (messages.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.outbox_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(messages, key = { it.id }) { message -> + OutboxRow(message = message, onCancel = { viewModel.cancel(message.id) }) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun OutboxRow(message: OutboxMessage, onCancel: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = message.subject.ifBlank { stringResource(R.string.draft_no_subject) }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = message.to.ifBlank { stringResource(R.string.draft_no_recipient) }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (message.lastError == null) { + Text( + text = stringResource(R.string.outbox_status_queued), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = stringResource(R.string.outbox_status_failed), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + Spacer(Modifier.width(8.dp)) + IconButton(onClick = onCancel) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.outbox_cancel)) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/outbox/OutboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/outbox/OutboxViewModel.kt new file mode 100644 index 0000000..6aedfd8 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/outbox/OutboxViewModel.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.outbox + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.repository.MailRepository + +@HiltViewModel +class OutboxViewModel @Inject constructor( + private val repository: MailRepository, +) : ViewModel() { + + val messages: StateFlow> = repository.observeOutbox() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun cancel(id: String) { + viewModelScope.launch { repository.cancelOutboxMessage(id) } + } + + fun retry() { + viewModelScope.launch { repository.retryOutbox() } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt new file mode 100644 index 0000000..7f5a993 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reader + +import android.annotation.SuppressLint +import android.content.Intent +import android.graphics.Color +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView + +/** + * Renders an HTML email body in a hardened WebView: JavaScript and file/content access are + * disabled, links open in the system browser, and remote content is blocked until the user + * opts in (tracking-pixel protection). + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun HtmlBody( + html: String, + loadRemoteImages: Boolean, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + // Tracks the content actually loaded so recompositions (star/attachment state changes) don't + // reload the page and throw away the user's scroll position. + val lastLoaded = remember { mutableStateOf?>(null) } + AndroidView( + modifier = modifier, + factory = { ctx -> + WebView(ctx).apply { + with(settings) { + javaScriptEnabled = false + allowFileAccess = false + allowContentAccess = false + domStorageEnabled = false + cacheMode = WebSettings.LOAD_NO_CACHE + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false + } + setBackgroundColor(Color.TRANSPARENT) + isVerticalScrollBarEnabled = true + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val url = request?.url ?: return false + // Only open ordinary web/mail links, and only on an actual user tap — never + // auto-launch arbitrary intent:/market:/custom-scheme URIs (e.g. via a + // meta-refresh in a malicious email) or navigate inside the WebView. + val scheme = url.scheme?.lowercase() + if (scheme != "http" && scheme != "https" && scheme != "mailto") return true + if (!request.hasGesture()) return true + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, url).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + return true + } + } + } + }, + update = { webView -> + webView.settings.blockNetworkLoads = !loadRemoteImages + val key = html to loadRemoteImages + if (lastLoaded.value != key) { + lastLoaded.value = key + webView.loadDataWithBaseURL(null, wrapHtml(html), "text/html", "UTF-8", null) + } + }, + ) +} + +private fun wrapHtml(body: String): String = + """ + + + + + + + $body + + """.trimIndent() diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt index 98ccf1d..89fbf94 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -1,21 +1,30 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.reader +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Star import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -23,88 +32,311 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import java.io.File import org.libremail.R +import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Message @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderScreen( onBack: () -> Unit, + onReply: (to: String, subject: String, from: String) -> Unit, viewModel: ReaderViewModel = hiltViewModel(), ) { - val message by viewModel.message.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val noAppMessage = stringResource(R.string.attachment_no_app) + val downloadFailedTemplate = stringResource(R.string.attachment_download_failed) + + LaunchedEffect(state.deleted) { + if (state.deleted) onBack() + } + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is ReaderEvent.OpenFile -> + if (!openAttachment(context, event.file, event.mimeType)) { + snackbarHostState.showSnackbar(noAppMessage) + } + + is ReaderEvent.DownloadFailed -> + snackbarHostState.showSnackbar(downloadFailedTemplate.format(event.name)) + } + } + } + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Text(stringResource(R.string.title_reader)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.action_back), - ) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + actions = { + val message = state.message + if (message != null) { + TextButton(onClick = { onReply(message.senderEmail, "Re: ${message.subject}", message.accountId) }) { + Text(stringResource(R.string.reader_reply)) + } + IconButton(onClick = viewModel::toggleStar) { + Icon( + Icons.Filled.Star, + contentDescription = stringResource(R.string.reader_star), + tint = if (message.isStarred) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + IconButton(onClick = viewModel::delete) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.reader_delete)) + } } }, ) }, ) { padding -> - val msg = message - if (msg == null) { - Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center, - ) { + val message = state.message + when { + state.loading -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - ) { - Text(msg.subject, style = MaterialTheme.typography.headlineSmall) - Spacer(Modifier.height(16.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Text( - text = msg.sender.trim().firstOrNull()?.uppercase() ?: "?", - color = MaterialTheme.colorScheme.onPrimaryContainer, - style = MaterialTheme.typography.titleMedium, - ) - } - Spacer(Modifier.width(12.dp)) - Column { - Text(msg.sender, style = MaterialTheme.typography.titleMedium) - Text( - msg.senderEmail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Spacer(Modifier.height(16.dp)) - HorizontalDivider() - Spacer(Modifier.height(16.dp)) - Text(msg.body, style = MaterialTheme.typography.bodyLarge) + + message != null -> MessageBody( + message = message, + attachments = state.attachments, + downloading = state.downloading, + onDownloadAttachment = viewModel::downloadAttachment, + loadRemoteImages = state.loadRemoteImages, + onLoadRemoteImages = viewModel::loadRemoteImages, + contentPadding = padding, + ) + + else -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text(state.error ?: stringResource(R.string.reader_empty)) } } } } + +@Composable +private fun MessageBody( + message: Message, + attachments: List, + downloading: Set, + onDownloadAttachment: (Attachment) -> Unit, + loadRemoteImages: Boolean, + onLoadRemoteImages: () -> Unit, + contentPadding: PaddingValues, +) { + Column(Modifier.fillMaxSize().padding(contentPadding)) { + Header(message) + HorizontalDivider() + if (attachments.isNotEmpty()) { + Attachments(attachments, downloading, onDownloadAttachment) + HorizontalDivider() + } + if (message.isHtml && !loadRemoteImages) { + RemoteImagesBanner(onLoadRemoteImages) + } + when { + message.body.isBlank() -> Text( + stringResource(R.string.reader_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + + message.isHtml -> HtmlBody( + html = message.body, + loadRemoteImages = loadRemoteImages, + modifier = Modifier.fillMaxSize(), + ) + + else -> SelectionContainer(modifier = Modifier.fillMaxSize()) { + Text( + text = message.body, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + ) + } + } + } +} + +@Composable +private fun Attachments( + attachments: List, + downloading: Set, + onDownload: (Attachment) -> Unit, +) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + stringResource(R.string.attachments_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + attachments.forEach { attachment -> + AttachmentRow( + attachment = attachment, + downloading = attachment.partIndex in downloading, + onClick = { onDownload(attachment) }, + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun AttachmentRow(attachment: Attachment, downloading: Boolean, onClick: () -> Unit) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !downloading, onClick = onClick), + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(40.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + if (downloading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Text( + text = fileExtension(attachment.filename), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + attachment.filename, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val size = formatSize(attachment.sizeBytes) + if (size.isNotEmpty()) { + Text( + size, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun Header(message: Message) { + Column(Modifier.padding(16.dp)) { + Text(message.subject, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = message.sender.trim().firstOrNull()?.uppercase() ?: "?", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.width(12.dp)) + Column { + Text(message.sender, style = MaterialTheme.typography.titleMedium) + Text( + message.senderEmail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun RemoteImagesBanner(onLoadRemoteImages: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.reader_images_blocked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onLoadRemoteImages) { + Text(stringResource(R.string.reader_show_images)) + } + } +} + +/** Launches a viewer for the downloaded file via a FileProvider URI. Returns false if no app handles it. */ +private fun openAttachment(context: Context, file: File, mimeType: String): Boolean { + val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType.ifBlank { "*/*" }) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } +} + +private fun fileExtension(filename: String): String = + filename.substringAfterLast('.', "").uppercase().take(4).ifBlank { "FILE" } + +private fun formatSize(bytes: Long): String = when { + bytes <= 0 -> "" + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.0f KB".format(bytes / 1024.0) + else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) +} diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt index b09d4db..c80efa3 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt @@ -5,23 +5,100 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File import javax.inject.Inject -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.model.Attachment import org.libremail.domain.model.Message import org.libremail.domain.repository.MailRepository import org.libremail.ui.navigation.Routes +data class ReaderUiState( + val loading: Boolean = true, + val message: Message? = null, + val attachments: List = emptyList(), + val downloading: Set = emptySet(), + val loadRemoteImages: Boolean = false, + val deleted: Boolean = false, + val error: String? = null, +) + +/** One-shot effects the reader screen acts on (launching a viewer, showing a message). */ +sealed interface ReaderEvent { + data class OpenFile(val file: File, val mimeType: String, val name: String) : ReaderEvent + data class DownloadFailed(val name: String) : ReaderEvent +} + @HiltViewModel class ReaderViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: MailRepository, + private val settingsRepository: SettingsRepository, ) : ViewModel() { + private val messageId: String = checkNotNull(savedStateHandle[Routes.READER_ARG_ID]) - val message: StateFlow = flow { - emit(repository.getMessage(messageId)) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + private val _state = MutableStateFlow(ReaderUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + + init { + // Honor the global "load remote images by default" preference. + viewModelScope.launch { + if (settingsRepository.settings.first().loadRemoteImages) { + _state.update { it.copy(loadRemoteImages = true) } + } + } + viewModelScope.launch { + repository.openMessage(messageId).fold( + onSuccess = { message -> _state.update { it.copy(loading = false, message = message) } }, + onFailure = { e -> _state.update { it.copy(loading = false, error = e.message ?: "Could not load message") } }, + ) + } + viewModelScope.launch { + repository.observeAttachments(messageId).collect { attachments -> + _state.update { it.copy(attachments = attachments) } + } + } + } + + fun downloadAttachment(attachment: Attachment) { + if (attachment.partIndex in _state.value.downloading) return + _state.update { it.copy(downloading = it.downloading + attachment.partIndex) } + viewModelScope.launch { + repository.downloadAttachment(attachment.messageId, attachment.partIndex).fold( + onSuccess = { file -> + _events.send(ReaderEvent.OpenFile(file, attachment.mimeType, attachment.filename)) + }, + onFailure = { _events.send(ReaderEvent.DownloadFailed(attachment.filename)) }, + ) + _state.update { it.copy(downloading = it.downloading - attachment.partIndex) } + } + } + + fun toggleStar() { + val message = _state.value.message ?: return + val starred = !message.isStarred + _state.update { it.copy(message = message.copy(isStarred = starred)) } + viewModelScope.launch { repository.setStarred(messageId, starred) } + } + + fun loadRemoteImages() = _state.update { it.copy(loadRemoteImages = true) } + + fun delete() { + viewModelScope.launch { + repository.deleteMessage(messageId) + _state.update { it.copy(deleted = true) } + } + } } diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index 52e0499..f857a58 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -42,7 +43,10 @@ fun SettingsScreen( onSelectTab: (TopDest) -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val advancedExpanded by viewModel.advancedExpanded.collectAsStateWithLifecycle() + Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) }, bottomBar = { LibreMailBottomBar(current = TopDest.SETTINGS, onSelect = onSelectTab) }, @@ -53,39 +57,84 @@ fun SettingsScreen( .padding(padding) .verticalScroll(rememberScrollState()), ) { + SectionHeader(stringResource(R.string.settings_accounts)) + if (accounts.isEmpty()) { + Text( + text = stringResource(R.string.settings_no_accounts), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } else { + accounts.forEach { account -> + AccountRow(email = account.email, onRemove = { viewModel.removeAccount(account.id) }) + } + } + ClickRow(title = stringResource(R.string.settings_add_account), onClick = onAddAccount) + HorizontalDivider() + + SectionHeader(stringResource(R.string.settings_notifications)) + SwitchRow( + title = stringResource(R.string.settings_new_mail), + checked = settings.newMailNotifications, + onCheckedChange = viewModel::setNewMailNotifications, + subtitle = stringResource(R.string.settings_new_mail_summary), + ) + HorizontalDivider() + SectionHeader(stringResource(R.string.settings_appearance)) SwitchRow( title = stringResource(R.string.settings_dynamic_color), - checked = state.dynamicColor, + checked = settings.dynamicColor, onCheckedChange = viewModel::setDynamicColor, subtitle = stringResource(R.string.settings_dynamic_color_summary), ) - ClickRow(title = stringResource(R.string.settings_add_account), onClick = onAddAccount) HorizontalDivider() - AdvancedHeader(expanded = state.advancedExpanded, onToggle = viewModel::toggleAdvanced) - AnimatedVisibility(visible = state.advancedExpanded) { + + AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced) + AnimatedVisibility(visible = advancedExpanded) { Column { SwitchRow( title = stringResource(R.string.settings_adv_idle), - checked = state.pushIdle, + checked = settings.pushIdle, onCheckedChange = viewModel::setPushIdle, ) SwitchRow( title = stringResource(R.string.settings_adv_starttls), - checked = state.allowStartTls, + checked = settings.allowStartTls, onCheckedChange = viewModel::setAllowStartTls, + subtitle = stringResource(R.string.settings_adv_starttls_summary), ) SwitchRow( title = stringResource(R.string.settings_adv_remote_images), - checked = state.loadRemoteImages, + checked = settings.loadRemoteImages, onCheckedChange = viewModel::setLoadRemoteImages, ) + SwitchRow( + title = stringResource(R.string.settings_adv_encrypt_cache), + checked = settings.encryptCache, + onCheckedChange = viewModel::setEncryptCache, + subtitle = stringResource(R.string.settings_adv_encrypt_cache_summary), + ) } } } } } +@Composable +private fun AccountRow(email: String, onRemove: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(email, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + TextButton(onClick = onRemove) { Text(stringResource(R.string.account_remove)) } + } +} + @Composable private fun SectionHeader(text: String) { Text( diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt index f41fca4..b629774 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt @@ -2,29 +2,48 @@ package org.libremail.ui.settings import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update - -data class SettingsUiState( - val dynamicColor: Boolean = true, - val advancedExpanded: Boolean = false, - val pushIdle: Boolean = true, - val allowStartTls: Boolean = false, - val loadRemoteImages: Boolean = false, -) +import kotlinx.coroutines.launch +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.model.Account +import org.libremail.domain.repository.AccountRepository @HiltViewModel -class SettingsViewModel @Inject constructor() : ViewModel() { - private val _state = MutableStateFlow(SettingsUiState()) - val state: StateFlow = _state.asStateFlow() +class SettingsViewModel @Inject constructor( + private val accountRepository: AccountRepository, + private val settingsRepository: SettingsRepository, +) : ViewModel() { - fun setDynamicColor(value: Boolean) = _state.update { it.copy(dynamicColor = value) } - fun toggleAdvanced() = _state.update { it.copy(advancedExpanded = !it.advancedExpanded) } - fun setPushIdle(value: Boolean) = _state.update { it.copy(pushIdle = value) } - fun setAllowStartTls(value: Boolean) = _state.update { it.copy(allowStartTls = value) } - fun setLoadRemoteImages(value: Boolean) = _state.update { it.copy(loadRemoteImages = value) } + val accounts: StateFlow> = accountRepository.observeAccounts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val settings: StateFlow = settingsRepository.settings + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()) + + private val _advancedExpanded = MutableStateFlow(false) + val advancedExpanded: StateFlow = _advancedExpanded.asStateFlow() + + fun toggleAdvanced() = _advancedExpanded.update { !it } + + fun removeAccount(id: String) = viewModelScope.launch { accountRepository.deleteAccount(id) }.let {} + + fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) } + fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) } + fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) } + fun setAllowStartTls(value: Boolean) = update { settingsRepository.setAllowStartTls(value) } + fun setLoadRemoteImages(value: Boolean) = update { settingsRepository.setLoadRemoteImages(value) } + fun setEncryptCache(value: Boolean) = update { settingsRepository.setEncryptCache(value) } + + private inline fun update(crossinline action: suspend () -> Unit) { + viewModelScope.launch { action() } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 203f7a1..cac4329 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,11 @@ Inbox Message + Star + Delete + This message has no text content + Remote images are blocked + Show images Compose Settings Add account @@ -19,20 +24,81 @@ No messages yet - Add an account to start syncing your mail. + All + Search + Close search + Search mail + No results + Pull down to refresh + Welcome to LibreMail + Add an account to start reading your mail. + From To + Cc Subject Message - Sending is not available yet + Add an account before composing + Attach file + Remove attachment + + + Drafts + No drafts + Drafts (%1$d) + (no subject) + (no recipient) + Delete draft + + + Outbox + Outbox is empty + Outbox (%1$d) + Queued + Couldn\'t send — will retry + Cancel send + Retry + + + Reply + Attachments + Couldn\'t download %1$s + No app can open this file - Sign in with Google + Sign in with Microsoft Other (IMAP/SMTP) Choose how you want to connect your mailbox. + + IMAP / SMTP + Email address + Password or app password + Incoming mail (IMAP) + Outgoing mail (SMTP) + IMAP server + SMTP server + IMAP port + SMTP port + IMAP security + SMTP security + Test & add account + + + New mail + %1$d new messages + Push (IMAP IDLE) + Watching for new mail + Connected for instant delivery + + Accounts + Notifications + New-mail notifications + Show a notification when new mail arrives + No accounts yet + Remove Appearance Use wallpaper colors Material You dynamic theming (Android 12+) @@ -40,6 +106,9 @@ Advanced Settings Power-user options for sync and connections Push mail (IMAP IDLE) - Allow STARTTLS + Allow insecure STARTTLS fallback + Connect even when the server can\'t upgrade to an encrypted (STARTTLS) connection. Leave this off unless a specific server requires it — while on, your username and password may be sent unencrypted. Load remote images by default + Encrypt local cache + Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail. diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..39841b1 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..446244d --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + 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 d2f832d..8f7abd5 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -9,29 +9,35 @@ 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.MessageDao import org.libremail.data.local.entity.MessageEntity -import org.libremail.data.sample.SampleData class MailRepositoryImplTest { private val messageDao = mockk() - private val accountDao = mockk() + private val repository = MailRepositoryImpl( + context = mockk(), + messageDao = messageDao, + accountDao = mockk(), + attachmentDao = mockk(), + outboxDao = mockk(), + draftDao = mockk(), + imapClient = mockk(), + connectionFactory = mockk(), + sendScheduler = mockk(), + ) @Test - fun `observeMessages emits sample data when cache is empty`() = runTest { + fun `observeMessages is empty when the cache is empty`() = runTest { every { messageDao.observeAll() } returns flowOf(emptyList()) - val repository = MailRepositoryImpl(messageDao, accountDao) - repository.observeMessages().test { - assertEquals(SampleData.messages, awaitItem()) + assertTrue(awaitItem().isEmpty()) awaitComplete() } } @Test - fun `observeMessages maps cached entities when present`() = runTest { + fun `observeMessages maps cached entities`() = runTest { val entity = MessageEntity( id = "1", accountId = "a", @@ -45,8 +51,6 @@ class MailRepositoryImplTest { isStarred = false, ) every { messageDao.observeAll() } returns flowOf(listOf(entity)) - val repository = MailRepositoryImpl(messageDao, accountDao) - repository.observeMessages().test { val items = awaitItem() assertEquals(1, items.size) diff --git a/app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt b/app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt deleted file mode 100644 index e7075fa..0000000 --- a/app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.data.sample - -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import org.junit.Test - -class SampleDataTest { - - @Test - fun `byId returns a known sample message`() { - val message = SampleData.byId("sample-1") - assertNotNull(message) - assertEquals("sample-1", message.id) - } - - @Test - fun `byId returns null for an unknown id`() { - assertNull(SampleData.byId("does-not-exist")) - } -} diff --git a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt new file mode 100644 index 0000000..2ac0a78 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.json.JSONObject +import org.junit.Test +import org.libremail.domain.model.OutgoingMessage + +class GraphSenderTest { + + private fun message(to: String, cc: String = "", subject: String = "Hi", body: String = "Body") = + OutgoingMessage(accountId = "outlook:me@example.com", to = to, cc = cc, subject = subject, body = body) + + @Test + fun `payload carries subject, body and parsed recipients`() { + val json = JSONObject(buildSendMailPayload(message(to = "a@x.com, b@y.com", cc = "c@z.com"), emptyList())) + assertTrue(json.getBoolean("saveToSentItems")) + + val msg = json.getJSONObject("message") + assertEquals("Hi", msg.getString("subject")) + assertEquals("Text", msg.getJSONObject("body").getString("contentType")) + assertEquals("Body", msg.getJSONObject("body").getString("content")) + + val to = msg.getJSONArray("toRecipients") + assertEquals(2, to.length()) + assertEquals("a@x.com", to.getJSONObject(0).getJSONObject("emailAddress").getString("address")) + assertEquals("b@y.com", to.getJSONObject(1).getJSONObject("emailAddress").getString("address")) + + val cc = msg.getJSONArray("ccRecipients") + assertEquals(1, cc.length()) + assertEquals("c@z.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address")) + } + + @Test + fun `recipients parse RFC822 display names into bare addresses`() { + val json = JSONObject( + buildSendMailPayload( + message(to = "John Doe ", cc = "\"Doe, Jane\" "), + emptyList(), + ), + ) + val msg = json.getJSONObject("message") + + val to = msg.getJSONArray("toRecipients") + assertEquals(1, to.length()) + val toAddress = to.getJSONObject(0).getJSONObject("emailAddress") + assertEquals("john@example.com", toAddress.getString("address")) + assertEquals("John Doe", toAddress.getString("name")) + + // The comma inside the quoted display name must not be treated as an address separator. + val cc = msg.getJSONArray("ccRecipients") + assertEquals(1, cc.length()) + assertEquals("jane@example.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address")) + } + + @Test + fun `payload omits cc when blank and encodes attachments as base64`() { + val file = File.createTempFile("graph-att", ".txt").apply { writeText("hello") } + try { + val msg = JSONObject(buildSendMailPayload(message(to = "a@x.com"), listOf(file))).getJSONObject("message") + assertFalse(msg.has("ccRecipients")) + + val attachments = msg.getJSONArray("attachments") + assertEquals(1, attachments.length()) + val attachment = attachments.getJSONObject(0) + assertEquals("#microsoft.graph.fileAttachment", attachment.getString("@odata.type")) + assertEquals(file.name, attachment.getString("name")) + assertEquals("aGVsbG8=", attachment.getString("contentBytes")) // base64("hello") + } finally { + file.delete() + } + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt new file mode 100644 index 0000000..01dbe9f --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import com.icegreen.greenmail.util.GreenMail +import com.icegreen.greenmail.util.GreenMailUtil +import com.icegreen.greenmail.util.ServerSetupTest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.MailSecurity + +class ImapClientTest { + + private lateinit var greenMail: GreenMail + private val client = ImapClient() + + @Before + fun setUp() { + greenMail = GreenMail(ServerSetupTest.SMTP_IMAP) + greenMail.start() + greenMail.setUser("alice@example.org", "secret") + } + + @After + fun tearDown() { + greenMail.stop() + } + + private fun params(secret: String = "secret") = ImapConnectionParams( + host = "127.0.0.1", + port = greenMail.imap.port, + security = MailSecurity.NONE, + username = "alice@example.org", + secret = secret, + useXoauth2 = false, + ) + + @Test + fun `listFolders returns INBOX for a valid login`() = runTest { + assertTrue(client.listFolders(params()).any { it.equals("INBOX", ignoreCase = true) }) + } + + @Test + fun `listFolders fails for a wrong password`() = runTest { + assertFailsWith { client.listFolders(params(secret = "wrong-password")) } + } + + @Test + fun `fetchRecentInbox returns delivered messages newest first`() = runTest { + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "First subject", "Body one") + GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Second subject", "Body two") + greenMail.waitForIncomingEmail(2) + + val messages = client.fetchRecentInbox(params(), limit = 50) + + assertEquals(2, messages.size) + assertEquals("Second subject", messages.first().subject) + assertEquals(setOf("First subject", "Second subject"), messages.map { it.subject }.toSet()) + assertEquals("bob@example.org", messages.first { it.subject == "First subject" }.senderEmail) + } + + @Test + 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 content = client.fetchBodyMarkingSeen(params(), uid) + + assertTrue(content.body.contains("quick brown fox"), "body=${content.body}") + assertTrue(client.fetchRecentInbox(params(), limit = 50).first().isRead, "should be marked read") + } + + @Test + fun `search returns only messages matching the query`() = runTest { + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Vacation plans", "Beach trip") + GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Invoice 42", "Payment due") + greenMail.waitForIncomingEmail(2) + + val results = client.search(params(), query = "Vacation", limit = 50) + + assertEquals(1, results.size) + assertEquals("Vacation plans", results.first().subject) + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt new file mode 100644 index 0000000..acadaf0 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import com.icegreen.greenmail.util.GreenMail +import com.icegreen.greenmail.util.GreenMailUtil +import com.icegreen.greenmail.util.ServerSetupTest +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.OutgoingMessage +import org.libremail.domain.model.SmtpParams + +class SmtpSenderTest { + + private lateinit var greenMail: GreenMail + private val sender = SmtpSender() + + @Before + fun setUp() { + greenMail = GreenMail(ServerSetupTest.SMTP) + greenMail.start() + greenMail.setUser("sender@example.org", "secret") + } + + @After + fun tearDown() { + greenMail.stop() + } + + @Test + fun `send delivers the message to the SMTP server`() = runTest { + val params = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "sender@example.org", + secret = "secret", + useXoauth2 = false, + ) + + sender.send( + params = params, + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + subject = "Hi Bob", + body = "Hello there from LibreMail.", + ), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages + assertEquals(1, received.size) + assertEquals("Hi Bob", received[0].subject) + assertTrue(GreenMailUtil.getBody(received[0]).contains("Hello there")) + } + + @Test + fun `send delivers a message with an attachment`() = runTest { + val file = File.createTempFile("libremail-report", ".txt").apply { writeText("quarterly numbers") } + val params = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "sender@example.org", + secret = "secret", + useXoauth2 = false, + ) + + sender.send( + params = params, + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + subject = "With file", + body = "See the attached report.", + ), + attachments = listOf(file), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages.single() + assertTrue(received.contentType.contains("multipart", ignoreCase = true)) + val raw = GreenMailUtil.getWholeMessage(received) + assertTrue(raw.contains("See the attached report.")) + assertTrue(raw.contains(file.name)) + file.delete() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e83a8f8..bc252c4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ activityCompose = "1.12.4" navigationCompose = "2.9.8" composeBom = "2026.06.00" room = "2.8.4" +sqlcipher = "4.16.0" datastore = "1.2.1" work = "2.11.2" coroutines = "1.10.2" @@ -20,6 +21,8 @@ androidxJunit = "1.2.1" espresso = "3.6.1" turbine = "1.2.1" mockk = "1.14.11" +greenmail = "2.1.9" +errorprone = "2.50.0" [libraries] # AndroidX core / lifecycle / activity / navigation @@ -43,13 +46,19 @@ androidx-compose-material-icons-core = { group = "androidx.compose.material", na # Hilt (DI) hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +# Pulled onto the compile classpath because Hilt/Dagger generated code references it. +error-prone-annotations = { group = "com.google.errorprone", name = "error_prone_annotations", version.ref = "errorprone" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" } # Room (local cache) androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +# SQLCipher — opt-in at-rest encryption of the Room cache. +sqlcipher-android = { group = "net.zetetic", name = "sqlcipher-android", version.ref = "sqlcipher" } # DataStore (settings) / WorkManager (sync) — wired in later increments androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } @@ -68,6 +77,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/secrets.properties.example b/secrets.properties.example index 80dd531..48df0a9 100644 --- a/secrets.properties.example +++ b/secrets.properties.example @@ -5,3 +5,14 @@ # the Authorization Code + PKCE login flow; no client secret is required for an # installed Android app. GMAIL_OAUTH_CLIENT_ID= + +# Optional: Microsoft (Outlook) OAuth public client id. A working default ships with the build; +# set this only to use your own Azure app registration. +#OUTLOOK_OAUTH_CLIENT_ID= + +# Optional: release signing. When these are set, release builds are signed with this keystore; +# otherwise they fall back to the debug key (installable for testing, but not publishable). +#RELEASE_STORE_FILE=/absolute/path/to/release.keystore +#RELEASE_STORE_PASSWORD= +#RELEASE_KEY_ALIAS= +#RELEASE_KEY_PASSWORD=