Fix/code review findings #1
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
+63
-3
@@ -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)
|
||||
|
||||
Vendored
+31
-13
@@ -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.**
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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<Context>()
|
||||
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<Unit> {
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
@@ -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<Context>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Account> = emptyList(),
|
||||
private var result: Result<List<String>> = 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<List<Account>> = accountsFlow
|
||||
|
||||
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> = result
|
||||
|
||||
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> {
|
||||
addedAccount = account
|
||||
addedPassword = password
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun addOutlookAccount(
|
||||
email: String,
|
||||
accessToken: String,
|
||||
authStateJson: String,
|
||||
): Result<List<String>> = 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<Unit> = Result.success(Unit),
|
||||
) : MailRepository {
|
||||
|
||||
val sentMessages = mutableListOf<OutgoingMessage>()
|
||||
val savedDrafts = mutableListOf<Draft>()
|
||||
val deletedDraftIds = mutableListOf<String>()
|
||||
|
||||
override fun observeMessages(): Flow<List<Message>> = flowOf(emptyList())
|
||||
|
||||
override suspend fun getMessage(id: String): Message? = null
|
||||
|
||||
override suspend fun openMessage(id: String): Result<Message> =
|
||||
Result.failure(UnsupportedOperationException("not used in UI tests"))
|
||||
|
||||
override fun observeAttachments(messageId: String): Flow<List<Attachment>> = flowOf(emptyList())
|
||||
|
||||
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
|
||||
Result.failure(UnsupportedOperationException("not used in UI tests"))
|
||||
|
||||
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun deleteMessage(id: String): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> {
|
||||
sentMessages += outgoing
|
||||
return sendResult
|
||||
}
|
||||
|
||||
override fun observeDrafts(): Flow<List<Draft>> = 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<List<OutboxMessage>> = flowOf(emptyList())
|
||||
|
||||
override suspend fun cancelOutboxMessage(id: String) {}
|
||||
|
||||
override suspend fun retryOutbox() {}
|
||||
|
||||
override suspend fun searchServer(query: String) {}
|
||||
|
||||
override suspend fun clearSearchResults() {}
|
||||
}
|
||||
@@ -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<ComponentActivity>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<ComponentActivity>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<ComponentActivity>()
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application
|
||||
android:name=".LibreMailApplication"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -23,5 +29,51 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- WorkManager is initialized on-demand via Configuration.Provider, so remove the
|
||||
default automatic initializer. -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<!-- Holds a long-lived IMAP IDLE connection for instant push (opt-in via Advanced Settings). -->
|
||||
<service
|
||||
android:name=".push.IdleService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Shares downloaded attachments with viewer apps via a content:// URI. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
|
||||
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
|
||||
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
|
||||
(mobile/desktop) redirect in the Azure app registration. -->
|
||||
<activity
|
||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
android:exported="true"
|
||||
tools:node="merge">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="org.libremail.outlook" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<ContactSuggestion> = 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<ContactSuggestion>()
|
||||
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<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<OutgoingAttachment>.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<OutgoingAttachment> {
|
||||
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)
|
||||
@@ -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`")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,15 @@ interface AccountDao {
|
||||
@Query("SELECT * FROM accounts ORDER BY email")
|
||||
fun observeAll(): Flow<List<AccountEntity>>
|
||||
|
||||
@Query("SELECT * FROM accounts ORDER BY email")
|
||||
suspend fun getAll(): List<AccountEntity>
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@@ -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<List<AttachmentEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(attachments: List<AttachmentEntity>)
|
||||
|
||||
@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<AttachmentEntity>) {
|
||||
deleteForMessage(messageId)
|
||||
insert(attachments)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<List<DraftEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM drafts")
|
||||
fun observeCount(): Flow<Int>
|
||||
|
||||
@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)
|
||||
}
|
||||
@@ -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<MessageEntity>)
|
||||
/** Ids of an account's inbox rows (excludes transient server-search hits). */
|
||||
@Query("SELECT id FROM messages WHERE accountId = :accountId AND inInbox = 1")
|
||||
suspend fun getInboxIdsForAccount(accountId: String): List<String>
|
||||
|
||||
/** Inserts only new messages, leaving existing rows (and their cached bodies/flags) intact. */
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertNew(messages: List<MessageEntity>)
|
||||
|
||||
/**
|
||||
* 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<String>)
|
||||
|
||||
@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<String>)
|
||||
|
||||
/** Removes transient server-search hits (called when search closes). */
|
||||
@Query("DELETE FROM messages WHERE inInbox = 0")
|
||||
suspend fun deleteSearchRows()
|
||||
}
|
||||
|
||||
@@ -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<OutboxEntity>
|
||||
|
||||
@Query("SELECT * FROM outbox ORDER BY createdAt")
|
||||
fun observeAll(): Flow<List<OutboxEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM outbox")
|
||||
fun observeCount(): Flow<Int>
|
||||
|
||||
@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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<List<Account>> =
|
||||
accountDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
|
||||
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> =
|
||||
runCatching { imapClient.listFolders(params) }
|
||||
|
||||
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> = 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<List<String>> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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<List<Message>> =
|
||||
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<List<Account>> =
|
||||
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<Message> = 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<List<Attachment>> =
|
||||
attachmentDao.observeForMessage(messageId).map { rows -> rows.map { it.toDomain() } }
|
||||
|
||||
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> = 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<Unit> = 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<Unit> = 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<Unit> = 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<OutgoingAttachment>) {
|
||||
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<List<Draft>> =
|
||||
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<List<OutboxMessage>> =
|
||||
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 "<accountId>:<uid>"; 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)
|
||||
|
||||
@@ -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<Message> = 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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<Preferences> 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Preferences> 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<AppSettings> = 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<Boolean> = 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<Boolean>, 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")
|
||||
}
|
||||
}
|
||||
@@ -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<String, Mutex>()
|
||||
|
||||
/** In-memory access-token cache keyed by "accountId|scope", to avoid redeeming a still-valid token. */
|
||||
private val tokenCache = ConcurrentHashMap<String, CachedToken>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Int> = 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<Int> = syncMutex.withLock {
|
||||
val entity = accountDao.getById(accountId) ?: return@withLock Result.success(0)
|
||||
syncAccountInternal(entity.toDomain())
|
||||
}
|
||||
|
||||
private suspend fun syncAccountInternal(account: Account): Result<Int> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<SendWorker>()
|
||||
.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"
|
||||
}
|
||||
}
|
||||
@@ -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<File>) {
|
||||
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<File> =
|
||||
dir.listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.sortedBy { it.name.toIntOrNull() ?: Int.MAX_VALUE }
|
||||
?.mapNotNull { it.listFiles()?.firstOrNull() }
|
||||
.orEmpty()
|
||||
}
|
||||
@@ -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<SyncWorker>(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<SyncWorker>()
|
||||
.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"
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<OutgoingAttachment> = emptyList(),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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<OutgoingAttachment> = 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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<List<Account>>
|
||||
|
||||
/** Attempt an IMAP login and return the server's folder names. */
|
||||
suspend fun testConnection(params: ImapConnectionParams): Result<List<String>>
|
||||
|
||||
/** Verify, then persist, a password/app-password IMAP account. Returns the folders found. */
|
||||
suspend fun addImapAccount(account: Account, password: String): Result<List<String>>
|
||||
|
||||
/** Verify (via XOAUTH2), then persist, an Outlook account. Returns the folders found. */
|
||||
suspend fun addOutlookAccount(email: String, accessToken: String, authStateJson: String): Result<List<String>>
|
||||
|
||||
suspend fun deleteAccount(id: String)
|
||||
}
|
||||
@@ -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<List<Message>>
|
||||
|
||||
fun observeAccounts(): Flow<List<Account>>
|
||||
|
||||
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<Message>
|
||||
|
||||
/** Cached attachment metadata for a message, populated when the message is opened. */
|
||||
fun observeAttachments(messageId: String): Flow<List<Attachment>>
|
||||
|
||||
/** Downloads an attachment's bytes to a local cache file and returns it. */
|
||||
suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File>
|
||||
|
||||
suspend fun setStarred(id: String, starred: Boolean): Result<Unit>
|
||||
|
||||
suspend fun deleteMessage(id: String): Result<Unit>
|
||||
|
||||
suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit>
|
||||
|
||||
/** Drafts: composed-but-unsent messages saved for later. */
|
||||
fun observeDrafts(): Flow<List<Draft>>
|
||||
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<List<OutboxMessage>>
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<File> = 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<File>): 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 <john@example.com>` — 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
|
||||
}
|
||||
@@ -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<AttachmentPart> = 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<String> = 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<FetchedMessage> =
|
||||
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<FetchedMessage> =
|
||||
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<Part>()
|
||||
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<Unit>(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<AttachmentPart> {
|
||||
val parts = mutableListOf<Part>()
|
||||
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<Part>) {
|
||||
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 <T> 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"
|
||||
}
|
||||
}
|
||||
@@ -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<File> = 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"
|
||||
}
|
||||
}
|
||||
@@ -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<MessageEntity>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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<String, Job>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AccountSetupUiState> = _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) }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<ManualSetupForm> = _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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Account>, 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<OutgoingAttachment>,
|
||||
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<org.libremail.contacts.ContactSuggestion>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OutgoingAttachment> = emptyList(),
|
||||
val suggestions: List<ContactSuggestion> = 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<String>(Routes.COMPOSE_ARG_DRAFT)?.takeIf { it.isNotBlank() }
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
ComposeUiState(
|
||||
to = savedStateHandle.get<String>(Routes.COMPOSE_ARG_TO).orEmpty(),
|
||||
subject = savedStateHandle.get<String>(Routes.COMPOSE_ARG_SUBJECT).orEmpty(),
|
||||
fromAccountId = savedStateHandle.get<String>(Routes.COMPOSE_ARG_FROM)?.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
val state: StateFlow<ComposeUiState> = _state.asStateFlow()
|
||||
|
||||
fun onToChange(value: String) = _state.update { it.copy(to = value) }
|
||||
val accounts: StateFlow<List<Account>> = 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<Unit>(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<OutgoingAttachment>) =
|
||||
_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") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<Draft>> = repository.observeDrafts()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun deleteDraft(id: String) {
|
||||
viewModelScope.launch { repository.deleteDraft(id) }
|
||||
}
|
||||
}
|
||||
@@ -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<Account>, 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,
|
||||
|
||||
@@ -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<List<Account>> = accountRepository.observeAccounts()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val hasAccounts: StateFlow<Boolean> = 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<String?>(null)
|
||||
val selectedAccountId: StateFlow<String?> = _selectedAccountId.asStateFlow()
|
||||
|
||||
private val _searchActive = MutableStateFlow(false)
|
||||
val searchActive: StateFlow<Boolean> = _searchActive.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val messages: StateFlow<List<Message>> =
|
||||
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<Int> = mailRepository.observeDrafts()
|
||||
.map { it.size }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
|
||||
|
||||
val outboxCount: StateFlow<Int> = mailRepository.observeOutbox()
|
||||
.map { it.size }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _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)
|
||||
|
||||
@@ -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)}"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<OutboxMessage>> = repository.observeOutbox()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun cancel(id: String) {
|
||||
viewModelScope.launch { repository.cancelOutboxMessage(id) }
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
viewModelScope.launch { repository.retryOutbox() }
|
||||
}
|
||||
}
|
||||
@@ -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<Pair<String, Boolean>?>(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 =
|
||||
"""
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src http: https: data: cid:; style-src 'unsafe-inline'; font-src data:">
|
||||
<style>
|
||||
body { font-family: sans-serif; line-height: 1.5; padding: 16px; word-wrap: break-word; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #0B57D0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>$body</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
@@ -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<Attachment>,
|
||||
downloading: Set<Int>,
|
||||
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<Attachment>,
|
||||
downloading: Set<Int>,
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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<Attachment> = emptyList(),
|
||||
val downloading: Set<Int> = 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<Message?> = flow {
|
||||
emit(repository.getMessage(messageId))
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
private val _state = MutableStateFlow(ReaderUiState())
|
||||
val state: StateFlow<ReaderUiState> = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<ReaderEvent>(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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<SettingsUiState> = _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<List<Account>> = accountRepository.observeAccounts()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val settings: StateFlow<AppSettings> = settingsRepository.settings
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings())
|
||||
|
||||
private val _advancedExpanded = MutableStateFlow(false)
|
||||
val advancedExpanded: StateFlow<Boolean> = _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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
<!-- Destinations -->
|
||||
<string name="title_mailbox">Inbox</string>
|
||||
<string name="title_reader">Message</string>
|
||||
<string name="reader_star">Star</string>
|
||||
<string name="reader_delete">Delete</string>
|
||||
<string name="reader_empty">This message has no text content</string>
|
||||
<string name="reader_images_blocked">Remote images are blocked</string>
|
||||
<string name="reader_show_images">Show images</string>
|
||||
<string name="title_compose">Compose</string>
|
||||
<string name="title_settings">Settings</string>
|
||||
<string name="title_account_setup">Add account</string>
|
||||
@@ -19,20 +24,81 @@
|
||||
|
||||
<!-- Mailbox -->
|
||||
<string name="mailbox_empty">No messages yet</string>
|
||||
<string name="mailbox_empty_subtitle">Add an account to start syncing your mail.</string>
|
||||
<string name="mailbox_all_accounts">All</string>
|
||||
<string name="search">Search</string>
|
||||
<string name="search_close">Close search</string>
|
||||
<string name="search_hint">Search mail</string>
|
||||
<string name="search_no_results">No results</string>
|
||||
<string name="mailbox_pull_to_refresh">Pull down to refresh</string>
|
||||
<string name="mailbox_welcome_title">Welcome to LibreMail</string>
|
||||
<string name="mailbox_welcome_subtitle">Add an account to start reading your mail.</string>
|
||||
|
||||
<!-- Compose -->
|
||||
<string name="compose_from">From</string>
|
||||
<string name="compose_to">To</string>
|
||||
<string name="compose_cc">Cc</string>
|
||||
<string name="compose_subject">Subject</string>
|
||||
<string name="compose_body">Message</string>
|
||||
<string name="compose_not_implemented">Sending is not available yet</string>
|
||||
<string name="compose_no_account">Add an account before composing</string>
|
||||
<string name="compose_attach">Attach file</string>
|
||||
<string name="compose_attachment_remove">Remove attachment</string>
|
||||
|
||||
<!-- Drafts -->
|
||||
<string name="drafts_title">Drafts</string>
|
||||
<string name="drafts_empty">No drafts</string>
|
||||
<string name="drafts_count">Drafts (%1$d)</string>
|
||||
<string name="draft_no_subject">(no subject)</string>
|
||||
<string name="draft_no_recipient">(no recipient)</string>
|
||||
<string name="draft_delete">Delete draft</string>
|
||||
|
||||
<!-- Outbox -->
|
||||
<string name="outbox_title">Outbox</string>
|
||||
<string name="outbox_empty">Outbox is empty</string>
|
||||
<string name="outbox_count">Outbox (%1$d)</string>
|
||||
<string name="outbox_status_queued">Queued</string>
|
||||
<string name="outbox_status_failed">Couldn\'t send — will retry</string>
|
||||
<string name="outbox_cancel">Cancel send</string>
|
||||
<string name="outbox_retry">Retry</string>
|
||||
|
||||
<!-- Reader -->
|
||||
<string name="reader_reply">Reply</string>
|
||||
<string name="attachments_title">Attachments</string>
|
||||
<string name="attachment_download_failed">Couldn\'t download %1$s</string>
|
||||
<string name="attachment_no_app">No app can open this file</string>
|
||||
|
||||
<!-- Account setup -->
|
||||
<string name="account_setup_gmail">Sign in with Google</string>
|
||||
<string name="account_setup_outlook">Sign in with Microsoft</string>
|
||||
<string name="account_setup_other">Other (IMAP/SMTP)</string>
|
||||
<string name="account_setup_subtitle">Choose how you want to connect your mailbox.</string>
|
||||
|
||||
<!-- Manual IMAP/SMTP setup -->
|
||||
<string name="manual_setup_title">IMAP / SMTP</string>
|
||||
<string name="manual_email">Email address</string>
|
||||
<string name="manual_password">Password or app password</string>
|
||||
<string name="manual_incoming">Incoming mail (IMAP)</string>
|
||||
<string name="manual_outgoing">Outgoing mail (SMTP)</string>
|
||||
<string name="manual_imap_server">IMAP server</string>
|
||||
<string name="manual_smtp_server">SMTP server</string>
|
||||
<string name="manual_imap_port">IMAP port</string>
|
||||
<string name="manual_smtp_port">SMTP port</string>
|
||||
<string name="manual_imap_security">IMAP security</string>
|
||||
<string name="manual_smtp_security">SMTP security</string>
|
||||
<string name="manual_test_and_add">Test & add account</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notif_channel_new_mail">New mail</string>
|
||||
<string name="notif_new_mail_count">%1$d new messages</string>
|
||||
<string name="notif_channel_push_status">Push (IMAP IDLE)</string>
|
||||
<string name="notif_push_status_title">Watching for new mail</string>
|
||||
<string name="notif_push_status_text">Connected for instant delivery</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_accounts">Accounts</string>
|
||||
<string name="settings_notifications">Notifications</string>
|
||||
<string name="settings_new_mail">New-mail notifications</string>
|
||||
<string name="settings_new_mail_summary">Show a notification when new mail arrives</string>
|
||||
<string name="settings_no_accounts">No accounts yet</string>
|
||||
<string name="account_remove">Remove</string>
|
||||
<string name="settings_appearance">Appearance</string>
|
||||
<string name="settings_dynamic_color">Use wallpaper colors</string>
|
||||
<string name="settings_dynamic_color_summary">Material You dynamic theming (Android 12+)</string>
|
||||
@@ -40,6 +106,9 @@
|
||||
<string name="settings_advanced">Advanced Settings</string>
|
||||
<string name="settings_advanced_summary">Power-user options for sync and connections</string>
|
||||
<string name="settings_adv_idle">Push mail (IMAP IDLE)</string>
|
||||
<string name="settings_adv_starttls">Allow STARTTLS</string>
|
||||
<string name="settings_adv_starttls">Allow insecure STARTTLS fallback</string>
|
||||
<string name="settings_adv_starttls_summary">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.</string>
|
||||
<string name="settings_adv_remote_images">Load remote images by default</string>
|
||||
<string name="settings_adv_encrypt_cache">Encrypt local cache</string>
|
||||
<string name="settings_adv_encrypt_cache_summary">Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail.</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!--
|
||||
LibreMail stores encrypted IMAP/OAuth credentials and cached mail in its Room database, encrypted
|
||||
with a non-exportable Android Keystore key. The key never leaves the device, so backing up or
|
||||
transferring the ciphertext would only restore undecryptable data (and needlessly ship mail and
|
||||
credential blobs off-device). Exclude all app data from both cloud backup and device-to-device
|
||||
transfer; accounts are simply re-added on a new device.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<paths>
|
||||
<cache-path name="attachments" path="attachments/" />
|
||||
</paths>
|
||||
@@ -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<MessageDao>()
|
||||
private val accountDao = mockk<AccountDao>()
|
||||
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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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 <john@example.com>", cc = "\"Doe, Jane\" <jane@example.com>"),
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Exception> { 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user