From db961344925a2398ad9c18c0fda9dea17809ffb2 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 21:43:58 -0500 Subject: [PATCH 1/5] chore(fdroid): add F-Droid metadata, license audit, and anti-feature docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare for F-Droid publication (issue #16): - docs/fdroid-compliance.md: full dependency license audit (release runtime classpath + buildscript classpath — all FOSS, no Play Services/Firebase, no non-free Gradle plugins), an anti-feature review of actual app behavior (none to declare: debug reporting is opt-in/local-only with no endpoint by default, Android Backup is gated off by default, Outlook OAuth is optional per-account with a public client id), a complete network-surface inventory, and the clean-room build verification (assembleRelease succeeds with no secrets.properties). - app/build.gradle.kts: stop embedding AGP's dependency-info block (a Google-Play-encrypted dependency list in the APK signing block) in APKs/bundles — a known F-Droid inclusion/reproducibility blocker. - fastlane/metadata/android/en-US/: store listing (title, short/full description, changelog for versionCode 1) that F-Droid reads from the repo; listing .txt files deliberately carry no license headers. - docs/fdroid/org.libremail.app.yml: commented template + instructions for the eventual fdroiddata build recipe (submission out of scope). - README.md: F-Droid section pointing at the above. Co-Authored-By: Claude Fable 5 --- README.md | 12 + app/build.gradle.kts | 10 + docs/fdroid-compliance.md | 235 ++++++++++++++++++ docs/fdroid/org.libremail.app.yml | 49 ++++ .../metadata/android/en-US/changelogs/1.txt | 8 + .../android/en-US/full_description.txt | 23 ++ .../android/en-US/short_description.txt | 1 + fastlane/metadata/android/en-US/title.txt | 1 + 8 files changed, 339 insertions(+) create mode 100644 docs/fdroid-compliance.md create mode 100644 docs/fdroid/org.libremail.app.yml create mode 100644 fastlane/metadata/android/en-US/changelogs/1.txt create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 fastlane/metadata/android/en-US/title.txt diff --git a/README.md b/README.md index 125c37d..029a8a3 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,18 @@ The UI observes Room via `Flow`; a sync engine (Angus Mail over IMAP/SMTP, plus Graph for Outlook send) writes into Room, and an auth layer (AppAuth for OAuth and an Android Keystore-backed credential store for app passwords) handles sign-in. +## F-Droid + +LibreMail is built to meet F-Droid's inclusion criteria: every dependency is +FOSS-licensed, there are no Google Play Services / Firebase / proprietary SDKs, the +build needs no `secrets.properties`, and there are **no anti-features to declare** +(the privacy-sensitive extras above are all opt-in). The full dependency license +audit, anti-feature review, and clean-room build verification live in +[`docs/fdroid-compliance.md`](docs/fdroid-compliance.md); the store listing is under +[`fastlane/metadata/android/`](fastlane/metadata/android/en-US), and +[`docs/fdroid/org.libremail.app.yml`](docs/fdroid/org.libremail.app.yml) is the +template for the eventual fdroiddata build recipe. + ## License LibreMail is licensed under the **GNU General Public License v3.0** — see diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1440adb..36e4778 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -96,6 +96,16 @@ android { buildConfig = true } + // F-Droid compliance (issue #16): by default AGP embeds a "dependency info block" in the APK + // signing block — a list of every dependency, encrypted so that ONLY Google Play can read it. + // F-Droid's inclusion policy treats that opaque, Google-only blob as a blocker (it cannot be + // verified from source and breaks reproducible builds), so keep it out of APKs and bundles. + // See docs/fdroid-compliance.md. + dependenciesInfo { + includeInApk = false + includeInBundle = false + } + packaging { resources { // Angus Mail / Jakarta Activation (added later) ship duplicate META-INF entries. diff --git a/docs/fdroid-compliance.md b/docs/fdroid-compliance.md new file mode 100644 index 0000000..4728fca --- /dev/null +++ b/docs/fdroid-compliance.md @@ -0,0 +1,235 @@ + +# F-Droid compliance + +Audit for issue #16, performed 2026-07-01 against `main` (versionName 0.1.0 / +versionCode 1). **Verdict: LibreMail meets F-Droid's inclusion criteria with no +anti-features to declare.** Every runtime dependency is FOSS-licensed and +GPL-3.0-or-later-compatible, there are no Google Play Services / Firebase / +proprietary artifacts, no non-free Gradle plugins, and a clean-room build (no +`secrets.properties`, no proprietary keys) produces an installable APK. + +Companion deliverables: + +- `fastlane/metadata/android/en-US/` — the store listing F-Droid reads from this repo. +- `docs/fdroid/org.libremail.app.yml` — template + instructions for the build recipe + that goes into [fdroiddata](https://gitlab.com/fdroid/fdroiddata). + +## 1. Dependency license audit + +### 1.1 Runtime dependencies (what ships in the APK) + +Enumerated with `./gradlew :app:dependencies --configuration releaseRuntimeClasspath`. +Direct dependencies, with the resolved versions at audit time: + +| Dependency | Version | License | Role | +|---|---|---|---| +| `androidx.core:core-ktx` | 1.17.0 | Apache-2.0 | AndroidX core | +| `androidx.lifecycle:lifecycle-runtime-ktx` / `-runtime-compose` / `-viewmodel-compose` | 2.9.4 | Apache-2.0 | Lifecycle/MVVM | +| `androidx.activity:activity-compose` | 1.12.4 | Apache-2.0 | Compose host activity | +| `androidx.navigation:navigation-compose` | 2.9.8 | Apache-2.0 | Navigation | +| `androidx.compose.*` (BOM 2026.06.00: ui, ui-graphics, material3, material-icons-core, …) | 1.11.3 / m3 1.4.0 | Apache-2.0 | UI toolkit | +| `androidx.webkit:webkit` | 1.12.1 | Apache-2.0 | Hardened WebView compat | +| `androidx.work:work-runtime-ktx` | 2.11.2 | Apache-2.0 | Background sync/outbox | +| `androidx.datastore:datastore-preferences` | 1.2.1 | Apache-2.0 | Settings store | +| `androidx.room:room-runtime` / `room-ktx` | 2.8.4 | Apache-2.0 | Local mail cache | +| `androidx.hilt:hilt-navigation-compose` / `hilt-work` | 1.3.0 | Apache-2.0 | Hilt integrations | +| `org.jetbrains.kotlin:kotlin-stdlib` | 2.4.0 | Apache-2.0 | Kotlin runtime | +| `org.jetbrains.kotlinx:kotlinx-coroutines-android` | 1.10.2 | Apache-2.0 | Coroutines | +| `com.google.dagger:hilt-android` (Dagger/Hilt) | 2.60 | Apache-2.0 | Dependency injection | +| `org.eclipse.angus:angus-mail` (+ `angus-activation`) | 2.0.5 / 2.0.3 | EPL-2.0 OR GPL-2.0 w/ Classpath-exception OR EDL-1.0 (BSD-3-Clause) | IMAP/SMTP transport | +| `net.openid:appauth` | 0.11.1 | Apache-2.0 | OAuth 2.0 + PKCE (Outlook) | +| `net.zetetic:sqlcipher-android` | 4.16.0 | BSD-3-Clause-style (SQLCipher Community Edition) | Opt-in cache encryption | + +Transitive dependencies, grouped (full tree available from the Gradle command above): + +| Group | License | Notes | +|---|---|---| +| `androidx.*` (~60 artifacts: appcompat, browser, collection, emoji2, fragment, savedstate, sqlite, startup, tracing, window, …) | Apache-2.0 | AndroidX | +| `org.jetbrains.*` (kotlin-stdlib, kotlinx-coroutines, kotlinx-serialization, annotations) | Apache-2.0 | JetBrains | +| `com.google.dagger:*` (dagger, hilt-core, dagger-lint-aar) | Apache-2.0 | via Hilt | +| `com.google.code.findbugs:jsr305` 3.0.2 | Apache-2.0 | annotations only | +| `com.google.guava:listenablefuture` 1.0 | Apache-2.0 | empty stub artifact (not Guava) | +| `com.squareup.okio:okio` 3.9.1 | Apache-2.0 | via DataStore | +| `jakarta.mail:jakarta.mail-api` 2.1.5 | EPL-2.0 OR GPL-2.0 w/ CPE OR EDL-1.0 | via Angus Mail | +| `jakarta.activation:jakarta.activation-api` 2.1.4 | EDL-1.0 (BSD-3-Clause) | via Angus Mail | +| `jakarta.inject:jakarta.inject-api` 2.0.1, `javax.inject:javax.inject` 1 | Apache-2.0 | DI annotations | +| `org.jspecify:jspecify` 1.0.0 | Apache-2.0 | nullness annotations | + +**GPL compatibility.** Everything is Apache-2.0 or BSD-3-Clause except the +Jakarta/Angus mail stack, which is tri-licensed; LibreMail uses it under the +EDL-1.0 (BSD-3-Clause) / GPL-2.0-with-Classpath-exception options, both of which are +GPL-3.0-or-later-compatible. **No proprietary, source-unavailable, or +"free for open source use only" artifact appears anywhere in the tree.** In +particular there is **no** `com.google.android.gms:*` (Play Services), **no** +`com.google.firebase:*`, no Play Billing/Install Referrer, and no analytics or +crash-reporting SDK. + +Native libraries in the release APK — all from the audited dependencies above: +`libsqlcipher.so` (SQLCipher), `libdatastore_shared_counter.so` (AndroidX DataStore), +`libandroidx.graphics.path.so` (AndroidX, via Compose). + +### 1.2 Build-time dependencies (never ship in the APK) + +Enumerated with `./gradlew buildEnvironment :app:buildEnvironment`: + +| Plugin / tool | License | +|---|---| +| Android Gradle Plugin 9.2.x (`com.android.tools.*`) | Apache-2.0 | +| Kotlin Gradle plugin + Compose compiler 2.4.0 | Apache-2.0 | +| KSP 2.3.9 | Apache-2.0 | +| Hilt Gradle plugin 2.60 | Apache-2.0 | +| ktlint-gradle 14.2.0 (`org.jlleitschuh.gradle`) | MIT | +| detekt 2.0.0-alpha.5 (`dev.detekt`) | Apache-2.0 | + +Their transitive tooling deps (protobuf, Tink, flatbuffers, bouncycastle, +juniversalchardet, jose4j, …) are Apache-2.0/MIT/MPL — all FOSS. **No non-free +Gradle plugin is used** (no Play Publisher, no Crashlytics/Google Services plugin, +no proprietary obfuscator; R8 ships with AGP and is Apache-2.0). + +Artifacts resolve exclusively from open repositories: `google()` and +`mavenCentral()` (plus `gradlePluginPortal()` for the lint/format plugins). +`gradle-wrapper.jar` is the standard Gradle 9.6 wrapper (Apache-2.0), verifiable +against the official distribution. + +### 1.3 APK payload hygiene + +AGP by default embeds a *dependency info block* in the APK signing block: a list of +every dependency **encrypted with a Google Play public key**, readable only by +Google. That opaque blob is a known F-Droid blocker (it cannot be verified from +source and breaks reproducible-build verification), so this repo disables it in +`app/build.gradle.kts`: + +```kotlin +dependenciesInfo { + includeInApk = false + includeInBundle = false +} +``` + +## 2. Anti-feature review + +Reviewed against the [F-Droid anti-feature list](https://f-droid.org/docs/Anti-Features/), +based on the manifest and source at audit time. **Declared anti-features: none.** + +| Anti-feature | Verdict | Reasoning | +|---|---|---| +| `Ads` | Clear | No advertising of any kind. | +| `Tracking` | Clear | No analytics/telemetry SDK; no identifiers are collected. Debug reporting is off by default, local-only, user-reviewed, and user-submitted (§2.1). | +| `NonFreeNet` | Clear | Generic IMAP/SMTP client, fully functional against free-software mail servers; the Microsoft integration is optional and user-chosen (§2.2). | +| `NonFreeAdd` | Clear | No add-ons; nothing is upsold. | +| `NonFreeDep` | Clear | Dependency audit in §1: every dependency is FOSS. | +| `NonFreeAssets` | Clear | All assets are first-party vector drawables carrying the repo's GPL SPDX headers; no bundled proprietary art, fonts, or blobs. | +| `NSFW` | Clear | N/A. | +| `UpstreamNonFree` | Clear | This repo is the upstream and is wholly GPL-3.0-or-later. | +| `KnownVuln` | Clear | No known-vulnerable pinned dependency at audit time; TLS enforced for mail transport. | +| `ApplicationDebuggable` | Clear | Release builds are non-debuggable (AGP default) and R8-minified. | +| `TetheredNet` | Clear | No tethered/proprietary backend; the app talks to the user's own mail servers. | +| `NoSourceSince` | Clear | N/A — source is published. | + +### 2.1 Debug reporting is not `Tracking` + +The crash/debug-report pipeline (`org.libremail.reporting.*`) is designed to stay on +the right side of F-Droid's Tracking definition ("reports user activity ... without +consent"): + +- **Off by default.** Nothing is captured until the user enables debug reporting. +- **Local capture only.** Reports (app/OS version, device model, stack trace, a + non-PII settings summary, recent in-app log lines) are stored on-device. + `DiagnosticsCollector` deliberately collects no account emails, server names, + message content, or hardware/advertising identifiers. +- **User-initiated, reviewed submission.** A report leaves the device only when the + user opens it, sees the full contents plus a PII disclaimer, and taps Submit + (`ReportSubmitter` is the single egress seam). +- **No endpoint in F-Droid builds.** The ingest URL comes from + `BuildConfig.DEBUG_REPORT_ENDPOINT`, default **empty** (settable only via the + git-ignored `secrets.properties`). An F-Droid build therefore *cannot* transmit a + report anywhere; the UI steers users to copy/save the report instead. + +### 2.2 Outlook OAuth / Microsoft Graph is not `NonFreeNet` + +`NonFreeNet` applies to apps that *promote or depend entirely on* a non-free network +service. LibreMail is a general-purpose email client: it works fully against any +IMAP/SMTP server, including self-hosted free-software stacks (Dovecot/Postfix, …), +and no Microsoft endpoint is ever contacted unless the user adds an +Outlook/Microsoft account. For transparency: + +- Adding an Outlook account uses OAuth 2.0 + PKCE against + `login.microsoftonline.com` and sends via Microsoft Graph + (`graph.microsoft.com`), with SMTP/XOAUTH2 fallback — proprietary services, but + the *user's own mailbox provider*, exactly like connecting to any other mail host. +- The build bundles a default Azure **public client id** (`OUTLOOK_OAUTH_CLIENT_ID` + in `app/build.gradle.kts`). A public-client id is an identifier, not a secret or a + key, and is overridable via `secrets.properties`. This mirrors what established + F-Droid mail clients (K-9 Mail / Thunderbird) ship for Gmail/Outlook OAuth without + a `NonFreeNet` flag. +- Gmail/Yahoo/iCloud accounts use plain app-password IMAP/SMTP — no proprietary + SDK. Onboarding links to each vendor's app-password page open in the system + browser only on an explicit tap. + +Should F-Droid reviewers read the built-in Outlook convenience differently, +declaring `NonFreeNet` on the fdroiddata side is the documented fallback; nothing +in the app needs to change. + +### 2.3 Android Backup (Google transport) is opt-in + +`android:allowBackup="true"` is required at the manifest level, but +`LibreMailBackupAgent` enforces the runtime preference: **backup is off by +default**, and with it disabled the agent ships nothing. When the user opts in, the +allowlist in `res/xml/data_extraction_rules.xml` / `backup_rules.xml` backs up +*only* the settings DataStore — never credentials, the mail cache, or the +Keystore-sealed cache passphrase. Because Android Auto Backup can route through +Google's transport, the feature stays disabled unless explicitly chosen; F-Droid +has no anti-feature for opt-in platform backup. + +### 2.4 Complete network surface + +| Destination | When | Consent | +|---|---|---| +| User-configured IMAP/SMTP servers | Mail sync/send | Inherent (user adds the account) | +| `login.microsoftonline.com` | Outlook sign-in / token refresh | Only if an Outlook account is added | +| `graph.microsoft.com` (`sendMail`) | Outlook send | Only if an Outlook account is added | +| Vendor app-password help pages (Google/Yahoo/Apple) | Opened in the system browser | Explicit tap during setup | +| Remote images in HTML mail | Blocked by default | Per-user opt-in (tracking-pixel protection) | +| `DEBUG_REPORT_ENDPOINT` | Debug-report submission | Empty by default → impossible; otherwise explicit Submit tap | + +No other endpoint exists in the code; there is no update checker, no push relay +(new-mail notifications are generated on-device; instant push is a direct IMAP IDLE +connection to the user's server), and no font/asset CDN. + +## 3. Clean-room build verification + +Verified 2026-07-01 on this branch, in a checkout containing **no +`secrets.properties`** (and no other proprietary keys — the file is optional by +design; `OUTLOOK_OAUTH_CLIENT_ID` has an in-tree default and +`DEBUG_REPORT_ENDPOINT` defaults to empty): + +``` +$ JAVA_HOME= ./gradlew :app:assembleRelease +BUILD SUCCESSFUL +app/build/outputs/apk/release/app-release.apk (~12.6 MiB) +``` + +The APK is installable: with no release keystore configured, release builds are +signed with the debug key (see `signingConfigs` in `app/build.gradle.kts`) — fine +for local testing and irrelevant to F-Droid, which builds from source and signs +with its own key. APK contents were inspected: single `classes.dex`, resources, +and the three native libraries listed in §1.1 — no bundled binaries of unknown +origin. `./gradlew :app:assembleDebug` and the unit-test/lint/static-analysis gates +run on every PR in CI, also without secrets. + +## 4. Publishing checklist (for the maintainer) + +1. Tag releases `v` **and bump `versionCode`** in + `app/build.gradle.kts` in the same commit. (At audit time tags `v0.1.0` and + `v0.2.0` both point at versionCode 1 / versionName 0.1.0 — F-Droid's + `UpdateCheckMode: Tags` needs the code to increase per release tag.) +2. Copy `docs/fdroid/org.libremail.app.yml` into a fork of fdroiddata as + `metadata/org.libremail.app.yml`, run `fdroid lint org.libremail.app` and a test + `fdroid build`, then open the merge request. +3. The store listing (title/short/full description, per-release changelogs) is read + from `fastlane/metadata/android/en-US/` in this repo — add a + `changelogs/.txt` for each release, and optionally + `images/phoneScreenshots/`. +4. Keep this document current when dependencies or network behaviors change; if a + future feature genuinely trips an anti-feature, declare it in the fdroiddata + metadata rather than hiding it. diff --git a/docs/fdroid/org.libremail.app.yml b/docs/fdroid/org.libremail.app.yml new file mode 100644 index 0000000..5c518d4 --- /dev/null +++ b/docs/fdroid/org.libremail.app.yml @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# TEMPLATE for LibreMail's F-Droid build recipe ("app metadata"). +# +# The real recipe does NOT live in this repository: F-Droid builds every app from a +# metadata file kept in the fdroiddata repo (https://gitlab.com/fdroid/fdroiddata) at +# metadata/org.libremail.app.yml. To submit LibreMail, fork fdroiddata, copy this file +# there (dropping this comment block — `fdroid rewritemeta` strips comments anyway), +# validate it, and open a merge request: +# +# fdroid readmeta # parse check +# fdroid lint org.libremail.app # style/policy check +# fdroid build -v -l org.libremail.app # test build (needs the Android SDK) +# +# Notes for the submitter: +# - Summary/Description/changelogs are deliberately NOT set here: F-Droid pulls the +# localized store listing from this repo's fastlane/metadata/android/ tree. +# - Each release must be git-tagged (v) AND bump versionCode in +# app/build.gradle.kts; UpdateCheckMode: Tags matches tags against versionCode. +# - The build needs no secrets.properties: the Outlook OAuth client id (a public, +# non-secret GUID) has an in-tree default, and the debug-report endpoint defaults +# to empty (reports then cannot be submitted anywhere). See docs/fdroid-compliance.md. +# - JDK: 17-21 (AGP 9.x does not support JDK 25). + +Categories: + - Internet +License: GPL-3.0-or-later +AuthorName: Jason Ross +SourceCode: https://github.com/JMR-dev/LibreMail +IssueTracker: https://github.com/JMR-dev/LibreMail/issues +Changelog: https://github.com/JMR-dev/LibreMail/releases + +AutoName: LibreMail + +RepoType: git +Repo: https://github.com/JMR-dev/LibreMail.git + +Builds: + - versionName: 0.1.0 + versionCode: 1 + commit: v0.1.0 + subdir: app + gradle: + - yes + +AutoUpdateMode: Version +UpdateCheckMode: Tags ^v[0-9]+\.[0-9]+\.[0-9]+$ +CurrentVersion: 0.1.0 +CurrentVersionCode: 1 diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt new file mode 100644 index 0000000..bac1140 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1.txt @@ -0,0 +1,8 @@ +First release of LibreMail. + +* Outlook/Microsoft (OAuth 2.0), Gmail/Yahoo/iCloud (app password), and generic IMAP/SMTP accounts +* Offline-first mail cache with full-history backfill and background sync +* Rich-text compose, drafts, signatures, attachments, and a reliable outbox +* Unified inbox across multiple accounts, local + server search +* New-mail notifications and optional IMAP IDLE instant push +* Hardened HTML reader: JavaScript off, remote images blocked by default diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..e9b6bcf --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,23 @@ +LibreMail is a free and open-source email client for Android, built with Kotlin, Jetpack Compose and Material 3 (Material You). It aims for a friendly default experience with power-user features tucked under an Advanced Settings group. + +Features: + +* Send and receive email with Outlook/Microsoft (OAuth 2.0), Gmail, Yahoo and iCloud (app password), and any other IMAP/SMTP provider +* Guided first-run onboarding: welcome, vendor picker, per-vendor setup, then straight to your inbox +* Offline-first: a local mail cache with full-history backfill (resumable) and an optional device-only retention limit +* Multiple accounts with a unified inbox and per-account filtering +* Rich-text compose with a formatting toolbar, per-account signatures, drafts, and a reliable background outbox +* Message bodies rendered in a hardened WebView: JavaScript off, remote images blocked by default (tracking-pixel protection) +* On-device new-mail notifications (no push service), with optional instant push via IMAP IDLE +* Search across cached mail and the server (IMAP SEARCH) +* Attachments: download on demand, open in a system viewer, and attach files when composing +* mailto: link handling and share-to-email +* Material You dynamic theming, light/dark, edge-to-edge + +Privacy and security: + +* No ads, no analytics SDK, no tracking. By default network traffic goes only to your mail providers. +* Credentials are encrypted with the Android Keystore; OAuth uses Authorization Code + PKCE. +* Optional (opt-in) extras: SQLCipher cache encryption, a biometric/device-credential app lock, Android Backup of settings only, and local-only debug reports that are sent nowhere unless you explicitly submit one. + +LibreMail is licensed under the GNU General Public License v3.0 or later. diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..c049c54 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Privacy-respecting, offline-first email for any IMAP/SMTP provider diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..838672b --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +LibreMail From 807f2402a51b2f917685cfc929d300b9a3bb0050 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 21:46:06 -0500 Subject: [PATCH 2/5] chore(fdroid): tighten accuracy of CI and KnownVuln wording in audit doc CI runs ktlint/detekt and the E2E suites (not Android lintDebug), and the KnownVuln rationale should not imply blanket TLS enforcement. Co-Authored-By: Claude Fable 5 --- docs/fdroid-compliance.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/fdroid-compliance.md b/docs/fdroid-compliance.md index 4728fca..abd1ffb 100644 --- a/docs/fdroid-compliance.md +++ b/docs/fdroid-compliance.md @@ -121,7 +121,7 @@ based on the manifest and source at audit time. **Declared anti-features: none.* | `NonFreeAssets` | Clear | All assets are first-party vector drawables carrying the repo's GPL SPDX headers; no bundled proprietary art, fonts, or blobs. | | `NSFW` | Clear | N/A. | | `UpstreamNonFree` | Clear | This repo is the upstream and is wholly GPL-3.0-or-later. | -| `KnownVuln` | Clear | No known-vulnerable pinned dependency at audit time; TLS enforced for mail transport. | +| `KnownVuln` | Clear | No dependency with a known security vulnerability is pinned at audit time (all on current stable lines). | | `ApplicationDebuggable` | Clear | Release builds are non-debuggable (AGP default) and R8-minified. | | `TetheredNet` | Clear | No tethered/proprietary backend; the app talks to the user's own mail servers. | | `NoSourceSince` | Clear | N/A — source is published. | @@ -214,8 +214,9 @@ signed with the debug key (see `signingConfigs` in `app/build.gradle.kts`) — f for local testing and irrelevant to F-Droid, which builds from source and signs with its own key. APK contents were inspected: single `classes.dex`, resources, and the three native libraries listed in §1.1 — no bundled binaries of unknown -origin. `./gradlew :app:assembleDebug` and the unit-test/lint/static-analysis gates -run on every PR in CI, also without secrets. +origin. `./gradlew :app:assembleDebug`, the unit tests, static analysis +(ktlint/detekt), and the emulator E2E suites run on every PR in CI, likewise +without any secrets configured. ## 4. Publishing checklist (for the maintainer) From a6436719b1b008768633fdcf379d3cbe76c4ed7f Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 21:48:46 -0500 Subject: [PATCH 3/5] fix(mail): prefer special-use folder when resolving move-by-role destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveRoleFolder().pick() chose the destination for archive/reportSpam/ trash as the first selectable folder with the matching role, in server LIST order. A provider's built-in folder (role via an RFC 6154 attribute, e.g. [Gmail]/Spam via \Junk) and a same-named user folder (role via roleFromDisplayName) can share a role, so the winner depended on which one the server happened to LIST first — silently misrouting mail past the provider's junk training, retention, and auto-purge. Prefer the server-advertised special-use folder among same-role matches: maxByOrNull { it.specialUse } picks a specialUse=true folder over name-derived ones, and, because maxByOrNull returns the first max, keeps the existing LIST-order behavior when no special-use folder exists. Closes #58 Co-Authored-By: Claude Fable 5 --- .../data/repository/MailRepositoryImpl.kt | 11 ++- .../data/repository/MailRepositoryImplTest.kt | 69 ++++++++++++++++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index 6ae11cd..388a65f 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -256,9 +256,16 @@ class MailRepositoryImpl @Inject constructor( } } - /** Resolves the full name of an account's folder for [role], refreshing the cache once if needed. */ + /** + * Resolves the full name of an account's folder for [role], refreshing the cache once if needed. + * Among same-role selectable folders (e.g. `[Gmail]/Spam` via RFC 6154 `\Junk` plus a user label + * "Spam" matched by name), the server-advertised special-use folder wins regardless of LIST order, + * so mail reaches the provider's built-in mailbox; absent one, the earliest LISTed folder is kept + * (`maxByOrNull` returns the first max). + */ private suspend fun resolveRoleFolder(accountId: String, role: FolderRole): String? { - fun pick(folders: List) = folders.firstOrNull { it.role == role.name && it.selectable }?.fullName + fun pick(folders: List) = + folders.filter { it.role == role.name && it.selectable }.maxByOrNull { it.specialUse }?.fullName pick(folderDao.getForAccountOnce(accountId))?.let { return it } // The folder cache can be cold (the user may not have opened the drawer yet); refresh and retry. runCatching { refreshFolders(accountId) } diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index 72ba7ac..5773d14 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -399,8 +399,73 @@ class MailRepositoryImplTest { coVerify { imapClient.moveMessages(any(), "INBOX", listOf("14"), "Archive") } } - private fun folderEntity(fullName: String, role: String) = - FolderEntity("acct", fullName, fullName.substringAfterLast('/'), role, selectable = true, sortOrder = 0) + @Test + fun `reportSpam prefers the special-use spam folder when the user folder is listed first`() = runTest { + val id = "acct:INBOX:16" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { messageDao.deleteByIds(any()) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + // A user label "Spam" (role from its name) is LISTed before Gmail's built-in \Junk folder. + coEvery { folderDao.getForAccountOnce("acct") } returns listOf( + folderEntity("Spam", "SPAM"), + folderEntity("[Gmail]/Spam", "SPAM", specialUse = true), + ) + + val result = repository.reportSpam(listOf(id)) + + assertTrue(result.isSuccess) + coVerify { imapClient.moveMessages(any(), "INBOX", listOf("16"), "[Gmail]/Spam") } + coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), "Spam") } + } + + @Test + fun `reportSpam prefers the special-use spam folder when it is listed first`() = runTest { + val id = "acct:INBOX:18" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { messageDao.deleteByIds(any()) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { folderDao.getForAccountOnce("acct") } returns listOf( + folderEntity("[Gmail]/Spam", "SPAM", specialUse = true), + folderEntity("Spam", "SPAM"), + ) + + val result = repository.reportSpam(listOf(id)) + + assertTrue(result.isSuccess) + coVerify { imapClient.moveMessages(any(), "INBOX", listOf("18"), "[Gmail]/Spam") } + coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), "Spam") } + } + + @Test + fun `reportSpam keeps the first listed folder when no special-use folder holds the role`() = runTest { + val id = "acct:INBOX:20" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { messageDao.deleteByIds(any()) } just Runs + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + // No SPECIAL-USE advertised (common outside the big providers): LIST order still decides. + coEvery { folderDao.getForAccountOnce("acct") } returns listOf( + folderEntity("Junk", "SPAM"), + folderEntity("Spam", "SPAM"), + ) + + val result = repository.reportSpam(listOf(id)) + + assertTrue(result.isSuccess) + coVerify { imapClient.moveMessages(any(), "INBOX", listOf("20"), "Junk") } + } + + private fun folderEntity(fullName: String, role: String, specialUse: Boolean = false) = FolderEntity( + accountId = "acct", + fullName = fullName, + displayName = fullName.substringAfterLast('/'), + role = role, + selectable = true, + sortOrder = 0, + specialUse = specialUse, + ) private fun attachmentEntity(messageId: String, partIndex: Int, filename: String) = AttachmentEntity(messageId, partIndex, filename, "application/octet-stream", 10L) From 3f7024d05d57401b94bc00a7a147ad1c59b07de8 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 21:52:40 -0500 Subject: [PATCH 4/5] docs(play): add privacy policy, data-safety mapping, and permissions justification Repo-actionable deliverables for the Google Play compliance work (issue #17), every claim verified against the code and the built release artifacts: - PRIVACY.md: user-facing privacy policy (device-local mail cache, optional SQLCipher encryption, traffic only to the user's own mail provider, on-device-only contacts autocomplete, strictly local opt-in debug reports, no ads/analytics/tracking SDKs). - docs/play-data-safety.md: Play Data safety questionnaire mapping -- answer 'no data collected/shared' with per-category code evidence, the policy exemptions relied on, a dependency audit, and a conservative fallback. - docs/play-permissions.md: merged-manifest permission audit (incl. the WorkManager-injected WAKE_LOCK / RECEIVE_BOOT_COMPLETED) with paste-ready Console justifications for READ_CONTACTS, POST_NOTIFICATIONS, and the FOREGROUND_SERVICE_DATA_SYNC declaration + demo-video script. - docs/play-compliance.md: verified targetSdk 37 (requirement: 35+), 16 KB page-size compliance (all packaged .so PT_LOAD p_align=0x4000, incl. sqlcipher-android 4.16.0), bundleRelease AAB check, the Gmail-app-password / no-CASA OAuth note, the console-steps checklist with drafted content-rating and listing answers, and repo findings (push-mail default vs docs, README minSdk/app-lock drift, debug-key release fallback). - README.md: link PRIVACY.md and note the no-Google-OAuth/no-CASA status (fuller README pass stays issue #20). Part of #17. Co-Authored-By: Claude Fable 5 --- PRIVACY.md | 135 ++++++++++++++++++++++++++++++++++ README.md | 7 +- docs/play-compliance.md | 152 +++++++++++++++++++++++++++++++++++++++ docs/play-data-safety.md | 110 ++++++++++++++++++++++++++++ docs/play-permissions.md | 112 +++++++++++++++++++++++++++++ 5 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 PRIVACY.md create mode 100644 docs/play-compliance.md create mode 100644 docs/play-data-safety.md create mode 100644 docs/play-permissions.md diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..d1be1df --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,135 @@ + +# LibreMail Privacy Policy + +**Effective date: 2026-07-01** · Applies to the LibreMail Android app (`org.libremail.app`). + +LibreMail is a free and open-source (GPL-3.0-or-later) email client. This policy describes what +the app does with your data. Because the source code is public, every statement here can be +verified against the code at . + +## Summary + +- **We run no servers and receive no data from you.** The LibreMail project has no backend: the + app talks only to the email provider(s) *you* configure (e.g. your Gmail, Outlook, Yahoo, + iCloud, or self-hosted IMAP/SMTP server) and, for Outlook accounts, to Microsoft's sign-in and + Graph endpoints. +- **Your mail stays on your device.** Messages are cached locally so the app works offline; the + cache can optionally be encrypted at rest. +- **No ads, no analytics, no tracking.** The app contains no advertising, analytics, or tracking + SDK of any kind, and no Google Play Services or Firebase dependency. +- **Nothing is sent to the developers** — including crash reports, which are strictly opt-in, + stored locally, shown to you for review, and (in this build) cannot be uploaded at all because + no ingest endpoint is configured. + +## What the app stores on your device + +All of the following lives in the app's private storage on your device only: + +- **Account settings** — your email address, display name, and server host/port/security + settings for each account you add. +- **Credentials** — your per-account app password or OAuth tokens, encrypted with a hardware- + backed key in the Android Keystore before being written to storage. +- **Mail cache** — headers, message bodies, and folder state, in a local database so your mail is + available offline. You can optionally enable **cache encryption** (SQLCipher) in Settings; the + database key is random, never leaves the device, and is itself sealed by the Android Keystore. +- **Attachments** you download or attach, in the app's cache directory (Android may clear this + automatically to reclaim space). +- **Preferences** — theme, notification, sync, and privacy toggles. +- **Debug reports** — only if a crash occurs or you ask the app to capture one; see + [Diagnostics](#diagnostics-and-debug-reports). + +Uninstalling the app, or clearing its storage in Android settings, deletes all of the above. + +## What leaves your device + +The app makes network connections **only** to servers that operate your email service: + +- **Your mail servers** — the IMAP and SMTP hosts of each account you configure (for the built-in + presets: `imap/smtp.gmail.com`, `imap/smtp.mail.yahoo.com`, `imap/smtp.mail.me.com`; + `outlook.office.com` for Outlook). This traffic is your email itself: signing in, downloading + your mail, sending the messages you write, and — when you use server search — your search + query. That is the app doing its job as your email client; none of it goes to us. +- **Microsoft identity platform and Graph** (`login.microsoftonline.com`, + `graph.microsoft.com`) — only for Outlook/Hotmail accounts, to sign you in with OAuth 2.0 and + to send mail via Microsoft's API. +- **Remote images in emails** — blocked by default. If you enable "load remote images", the + message viewer will fetch images from the servers referenced by the email (which can reveal + your IP address to the sender), so it stays off unless you turn it on. + +Every mail connection uses TLS (SSL/TLS or STARTTLS) with server-certificate hostname +verification; the account-setup UI does not offer an unencrypted option. + +The app never transmits your data to the LibreMail project or any third party of ours. There is +no telemetry, no "phone home", and no ad or analytics traffic. + +## Contacts (`READ_CONTACTS` permission) + +When composing a message, LibreMail can suggest recipients from your device contacts. The app +asks for the contacts permission the first time you open the compose screen: + +- Contact lookups run **entirely on the device** and return at most a handful of name/email + matches for what you typed. Your contact list is never uploaded, copied, or synced anywhere. +- The only way a contact detail leaves the device is when *you* put an address in an email you + send — it then appears in that email, like in any mail client. +- The permission is optional: if you deny it, autocomplete is silently disabled and everything + else keeps working. + +## Notifications (`POST_NOTIFICATIONS` permission) + +Used to show new-mail notifications (per-account, with sender/subject hidden on a locked screen) +and the persistent low-priority status notification Android requires while the optional +instant-push connection is active. New-mail notifications are generated **on the device** from +your synced mail — there is no push server and no cloud messaging service involved. You can +decline the permission or disable notifications per account in system settings. + +## Instant push (foreground service) + +For instant mail delivery the app can hold an open IMAP IDLE connection to your mail server in a +foreground service (shown as a persistent notification). This connects only to your own mail +server, and can be turned off in Settings ("push mail"), which falls back to periodic background +sync. + +## Diagnostics and debug reports + +LibreMail has **no automatic crash or usage reporting**. What exists instead: + +- If the app crashes, or you use "Report a problem", a report is saved **locally** on your + device. It contains the app version, Android version, device make/model, a stack trace (for + crashes), a short summary of non-identifying settings, and recent internal log lines — by + design no account addresses, server names, or message content fields are collected. +- You can view the full report text (with a plain-language notice to check it for anything + personal), copy it, share it yourself, or delete it. It is transmitted **only** if you + explicitly tap Submit — never in the background. +- In the builds produced from this repository **no upload endpoint is configured**, so even an + explicit Submit cannot send anything; the report simply stays on your device. If a future + release adds an endpoint, submission will remain strictly opt-in and user-initiated, and this + policy will be updated. + +## Android Backup + +Android's cloud backup is **off by default** for LibreMail. If you enable "Include settings in +Android Backup" in Settings, only your app preferences are backed up through your device's +Android Backup transport (typically Google's). Your credentials, the mail cache, and the cache +encryption key are always excluded from backups. + +## Data deletion + +- **Remove an account** (in the app's account settings) — deletes that account's stored + credentials, its cached messages, folders, and per-account settings from the local database, + and its notification channels. Copies of downloaded attachments in the app's cache directory + are cleared by Android's normal cache management, or immediately via "Clear cache" in system + settings. +- **Uninstall the app / clear storage** — removes all locally stored app data. +- **Your mailbox is unaffected**: mail lives with your email provider; deleting data in + LibreMail does not delete mail from the server unless you explicitly delete messages in the + app. We hold no copy of your data, so there is nothing for us to delete on any server. + +## Children + +LibreMail is a general-audience utility that requires an existing email account. It is not +directed at children, and — as described above — it collects no data from any user. + +## Changes and contact + +Changes to this policy are made in the public repository with full version history. Questions or +concerns: open an issue at . diff --git a/README.md b/README.md index 125c37d..742eab7 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,11 @@ token. A working client ID ships with the build; to use your own Azure app regis LibreMail is offline-first: your mail lives in a local cache, and by default network traffic goes only to your mail providers (IMAP/SMTP, plus Microsoft's OAuth and Graph endpoints for -Outlook). There is no analytics SDK and no always-on telemetry. The privacy-sensitive extras -are all **opt-in**: +Outlook). There is no analytics SDK and no always-on telemetry. The full privacy policy lives in +[`PRIVACY.md`](PRIVACY.md); Google Play compliance notes (data-safety mapping, permissions +justification) are under [`docs/`](docs/). Because Gmail uses an app password (no Google OAuth +scopes), no Google restricted-scope verification or CASA assessment applies; Outlook's OAuth +client is governed by Microsoft's Azure rules. The privacy-sensitive extras are all **opt-in**: - **Cache encryption** — the Room cache can be encrypted at rest with **SQLCipher**. With the optional **app lock** (biometric or device credential) enabled, the cache key is bound to diff --git a/docs/play-compliance.md b/docs/play-compliance.md new file mode 100644 index 0000000..b5a48b0 --- /dev/null +++ b/docs/play-compliance.md @@ -0,0 +1,152 @@ + +# Google Play technical compliance & console checklist (issue #17) + +Verified 2026-07-01 against this repository (commit on `main` at time of writing). Companion +docs: [`PRIVACY.md`](../PRIVACY.md), [`play-data-safety.md`](play-data-safety.md), +[`play-permissions.md`](play-permissions.md). + +## 1. Target API level — PASS + +| Fact | Value | Source | +|---|---|---| +| `targetSdk` | **37** | `app/build.gradle.kts:52` | +| `compileSdk` | 37 | `app/build.gradle.kts:46` | +| `minSdk` | 29 (Android 10) | `app/build.gradle.kts:51` | +| Play requirement (new apps & updates, phones/tablets) | target API **35** (Android 15)+ since 2025-08-31 | [Play target-API policy](https://support.google.com/googleplay/android-developer/answer/11926878) | + +Target 37 exceeds the requirement with two versions of headroom; no action needed. When Google +announces the 2026 deadline (expected: API 36 for the Aug 2026 window), 37 still passes. + +## 2. 16 KB page-size support — PASS (verified empirically) + +Play requires new apps and updates targeting Android 15+ to support 16 KB memory page sizes on +64-bit devices since 2025-11-01 ([Android developers blog](https://android-developers.googleblog.com/2025/05/prepare-play-apps-for-devices-with-16kb-page-size.html)). +Compliance = every `PT_LOAD` segment of every packaged 64-bit `.so` aligned to ≥ 0x4000 (16384). + +The release AAB packages exactly three native libraries. All were extracted from +`app-release.aab` and their ELF program headers checked (same check as AOSP's +`check_elf_alignment.sh`); **every one reports `p_align = 0x4000` on every ABI**: + +| Library | From dependency | arm64-v8a | x86_64 | armeabi-v7a / x86 (32-bit, not gated) | +|---|---|---|---|---| +| `libsqlcipher.so` | `net.zetetic:sqlcipher-android:4.16.0` | 0x4000 OK | 0x4000 OK | 0x4000 OK | +| `libandroidx.graphics.path.so` | Compose (BOM `2026.06.00`) | 0x4000 OK | 0x4000 OK | 0x4000 OK | +| `libdatastore_shared_counter.so` | `androidx.datastore:1.2.1` | 0x4000 OK | 0x4000 OK | 0x4000 OK | + +SQLCipher — the dependency called out in issue #17 — has shipped 16 KB-aligned binaries since +well before 4.16.0, and the pinned version is confirmed aligned above. AGP 9.2 also emits 16 +KB-zip-aligned uncompressed libraries by default (AGP ≥ 8.5.1 behavior), and Play regenerates +delivery APKs from the AAB anyway. Re-verify after any bump of `sqlcipher`, `datastore`, or +`composeBom` in `gradle/libs.versions.toml`: Play Console → **App bundle explorer** shows a +16 KB compliance verdict per upload. + +## 3. App Bundle (AAB) — PASS, signing is a human step + +- `./gradlew :app:bundleRelease` succeeds and produces + `app/build/outputs/bundle/release/app-release.aab` (~10.4 MB, R8-minified). Verified + 2026-07-01 with JDK 21. +- **Signing:** without `secrets.properties` the release build intentionally falls back to the + **debug** keystore (`app/build.gradle.kts:86` — installable locally, not publishable). For + Play the maintainer must create an upload keystore, set `RELEASE_STORE_FILE` / + `RELEASE_STORE_PASSWORD` / `RELEASE_KEY_ALIAS` / `RELEASE_KEY_PASSWORD` in + `secrets.properties`, rebuild, and enroll in **Play App Signing** on first upload (Play holds + the app signing key; the local key becomes the upload key). +- `versionCode 1` / `versionName "0.1.0"` (`app/build.gradle.kts:53`) — bump per release. + +## 4. OAuth / CASA — no Google verification applies + +Verified in source, 2026-07-01: + +- **Gmail onboarding uses an app password over IMAP/SMTP, not OAuth.** The Gmail preset + (`domain/model/MailProvider.kt:37`) is plain `imap.gmail.com:993` / `smtp.gmail.com:587` + authenticating with a user-created app password; the only Google URL in the app is the + `myaccount.google.com/apppasswords` help link opened in the browser. There is **no Google + OAuth client, no Google sign-in flow, and no Gmail API scope anywhere in the code** — so the + Google restricted-scope verification and **CASA security assessment do not apply** to + LibreMail. (This is deliberate — issue #9; do not "fix" Gmail back to OAuth.) +- **Outlook OAuth is Microsoft-side only.** `auth/OutlookAuthManager.kt` uses AppAuth (PKCE, + public client) against `login.microsoftonline.com` with Microsoft Graph + (`Mail.Send`) and Exchange Online (`IMAP.AccessAsUser.All`, `SMTP.Send`) scopes. Verification + of that client is governed by **Microsoft's** app-registration/publisher rules in Azure — + nothing on the Google side. Google Play itself imposes no OAuth review; only the data-safety + and permissions declarations above cover it. +- README follow-up: tracked as issue **#20** (a fuller README pass); `README.md`'s privacy + section now links `PRIVACY.md`. + +## 5. Console checklist (human steps, in order) + +Everything below happens in Play Console and cannot be done from the repo. Drafted answers are +ready to paste. + +1. **Developer account** — one-time registration + identity verification. +2. **Create app** — name *LibreMail*, default language, **App** (not game), **Free**. + Free-to-paid can never be toggled later; LibreMail is GPL and free. +3. **Store listing** (assets required): + - App icon **512×512 PNG** (≤1 MB); feature graphic **1024×500**; **2–8 phone screenshots** + (16:9 or 9:16, 320–3840 px; onboarding, inbox, reader, compose, settings are good + candidates); optional 7"/10" tablet screenshots. + - Short description (≤80 chars), draft: + > Open-source email for Outlook, Gmail, Yahoo, iCloud and any IMAP provider. + - Full description (≤4000 chars), draft: + > LibreMail is a free and open-source (GPL-3.0) email client with a friendly Material You + > design. Add Outlook/Hotmail (OAuth sign-in), Gmail, Yahoo, iCloud (app password), or any + > IMAP/SMTP provider; read, search, and manage your mail offline-first; compose with rich + > text, signatures, attachments, and contact autocomplete; get instant new-mail + > notifications via IMAP IDLE push — no tracking, no ads, no analytics, and your mail + > never touches our servers because we don't have any. Optional extras: encrypted local + > cache (SQLCipher), unified inbox for multiple accounts, and full mail-history backfill + > with a retention cap. + - Category **Communication**; contact email (maintainer's); privacy policy URL + `https://github.com/JMR-dev/LibreMail/blob/main/PRIVACY.md`. +4. **App content declarations:** + - **Privacy policy** — URL above. + - **Ads** — *No, my app does not contain ads* (no ad SDK; see dependency audit in + [`play-data-safety.md`](play-data-safety.md)). + - **App access** — reviewers need a mail account to exercise the app. Provide either + "All functionality is available without special access" plus a note that any IMAP account + works, or (safer) supply a disposable test account (e.g. a throwaway IMAP mailbox) under + *Special access instructions*. Do **not** hand over a personal account. + - **Content rating (IARC questionnaire)** — draft answers: email/communication app; category + **Utility / Communication**; violence/sex/language/drugs/gambling: **No** to all; + user interaction: **Yes** (users exchange email — expect an "Interactive elements: Users + Interact" notice); shares user-provided location: **No**; digital purchases: **No**. + Expected rating: **Everyone / PEGI 3** with the Users-Interact disclosure. + - **Target audience** — **13 and over** (requires an email account; not directed at + children — do not select under-13, which triggers Families policy). + - **News app** — No. **COVID-19 app** — No. **Government app** — No. + - **Financial features** — None. **Health apps** — Not a health app. + - **Data safety** — answers and evidence in [`play-data-safety.md`](play-data-safety.md). + - **Foreground service permissions** (`FOREGROUND_SERVICE_DATA_SYNC`) — declaration text and + demo-video script in [`play-permissions.md`](play-permissions.md). + - **Account deletion** — Play's deletion-URL requirement applies to apps that let users + *create an account with the developer*. LibreMail creates no such accounts (users connect + their own third-party mailboxes), so answer the "App access/account creation" question + with **no account creation** and the deletion section does not apply. In-app truth, if a + free-text answer is wanted: + > LibreMail has no user accounts of its own and stores data only on the device. Removing + > an account inside the app deletes its saved credentials and its locally cached + > messages, folders, and settings (`AccountRepositoryImpl.deleteAccount`); uninstalling + > the app removes all app data. The user's mailbox at their email provider is unaffected. +5. **Upload** the properly signed release AAB to **Internal testing** first; check **App bundle + explorer** (16 KB verdict) and the **pre-launch report** (automated crawl on real devices; + supply the test-account credentials so it can get past onboarding). +6. **Countries/regions**, pricing (Free), then promote Internal → Closed/Open testing → + Production. Note: new personal developer accounts must run a closed test (12 testers / + 14 days) before production access. + +## 6. Findings for the maintainer (repo-side, discovered during verification) + +1. **"Push mail" is on by default, not opt-in.** `SettingsRepository.kt:41` defaults + `pushIdle = true`, so the dataSync foreground service starts as soon as the first account is + added. The manifest comment (`AndroidManifest.xml:88` — "opt-in via Advanced Settings") and + README wording say opt-in. Either flip the default to `false` or fix the comments; the Play + FGS declaration drafted here describes the **actual** behavior (default-on, user-visible + toggle, persistent notification), which is acceptable to declare but must stay truthful. +2. **README tech-stack table says min SDK 33**; the build uses `minSdk 29` + (`app/build.gradle.kts:51`). Fix with the #20 README pass. +3. **README advertises an "app lock" (biometric/device-credential)** that does not exist in the + code yet (no biometric API usage anywhere in `app/src/main`). `PRIVACY.md` deliberately does + not claim it; remove or de-scope the README claim until implemented (#20), and update + `PRIVACY.md` when it ships. +4. **Release signing falls back to the debug key** without `secrets.properties` — fine for CI, + but the Play upload must be built with the real upload keystore (section 3). diff --git a/docs/play-data-safety.md b/docs/play-data-safety.md new file mode 100644 index 0000000..856413c --- /dev/null +++ b/docs/play-data-safety.md @@ -0,0 +1,110 @@ + +# Google Play Data safety form — mapping (issue #17) + +Fill-in guide for Play Console → **App content → Data safety**. Every answer below is grounded +in this repository's code; re-verify against source if the data flows change. Companion docs: +[`PRIVACY.md`](../PRIVACY.md) (the policy to link in the form), +[`play-permissions.md`](play-permissions.md), [`play-compliance.md`](play-compliance.md). + +## How Play defines "collection", and why LibreMail declares none + +Play's definition ([Play Console Help — Provide information for Google Play's Data safety +section](https://support.google.com/googleplay/android-developer/answer/10787469)): *"Collect" +means transmitting data from your app off a user's device*, with exemptions that do **not** need +to be disclosed: + +1. **On-device access/processing** — data "only processed locally on the user's device and not + sent off device". +2. **End-to-end encryption** — data unreadable by anyone other than sender and recipient. +3. **Ephemeral processing** — data held in memory and "retained for no longer than necessary to + service a specific request in real time". + +LibreMail's data flows fall under exemptions 1 and 3: + +- The developer **operates no servers and receives no user data**. There is no analytics, + crash-reporting, or ad SDK in the dependency tree (see audit below), and the only + developer-directed channel that exists in code — opt-in debug-report upload + (`app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt`) — is dead in shipped + builds because `DEBUG_REPORT_ENDPOINT` defaults to `""` (`app/build.gradle.kts`), which makes + the upload worker fail without transmitting. +- All other traffic is the app doing its job as the user's mail agent against **servers the + user chose** (their own IMAP/SMTP provider; Microsoft's OAuth/Graph endpoints for the Outlook + account type). Each transfer services a specific user request in real time (sign-in, sync, + send, server search); the app retains nothing off-device and the developer can never access + any of it. + +The same reasoning is the established practice of comparable open-source mail clients on Play +that declare no collection. If a Play reviewer pushes back, use the conservative alternative at +the bottom of this page — it is also truthful. + +## Form answers + +| Form question | Answer | +|---|---| +| Does your app collect or share any of the required user data types? | **No** | +| Is all of the user data collected by your app encrypted in transit? | Not asked when "No" above; for the record: **yes**, all connections are TLS (`ImapClient.kt`, `SmtpSender.kt` set `ssl.checkserveridentity=true`; `MailSecurity.NONE` is not offered in the UI — `ManualSetupScreen.kt:211`) | +| Do you provide a way for users to request that their data is deleted? | Not asked when "No" above; see account-deletion notes in [`play-compliance.md`](play-compliance.md) | +| Privacy policy URL | `https://github.com/JMR-dev/LibreMail/blob/main/PRIVACY.md` | + +Result shown on the store listing: **"No data collected"** / **"No data shared with third +parties"**. + +## Category-by-category evidence + +Every Play data-safety category, the truthful answer, and where the code proves it: + +| Play category | Collected? | Shared? | Evidence in code | +|---|---|---|---| +| Personal info → Name | No | No | Account display name stored in local Room DB only (`data/local/entity/AccountEntity` via `AccountRepositoryImpl.kt`); appears off-device only inside mail the user sends | +| Personal info → Email address | No | No | The user's own address is their mail login, sent only to their chosen provider to authenticate/send (`ImapClient.kt`, `SmtpSender.kt`, `GraphSender.kt`) — user-initiated, real-time, never to the developer | +| Personal info → User IDs | No | No | No developer-side accounts or IDs exist; OAuth tokens go only between the device and `login.microsoftonline.com` (`auth/OutlookAuthManager.kt`) | +| Financial info / Health / Location | No | No | No such APIs or permissions anywhere in the merged manifest (see [`play-permissions.md`](play-permissions.md)) | +| Messages → Emails | No | No | Mail syncs from the user's server *to* the device (`MailSyncer`), is cached locally (Room, optional SQLCipher — `di/DatabaseModule.kt`), and is transmitted only when the user sends a message to their own SMTP/Graph endpoint | +| Photos and videos / Audio files / Files and docs | No | No | Attachments are chosen via the system document picker (`ComposeScreen.kt` `OpenMultipleDocuments`, no storage permission), stored under `cacheDir` (`MailRepositoryImpl.kt:361`), and leave the device only inside mail the user sends | +| Calendar | No | No | No calendar API usage | +| Contacts | **No** | No | `contacts/ContactsRepository.kt` queries `ContactsContract` **on-device** for ≤8 autocomplete matches; results are held in memory for the compose screen. Nothing is uploaded — Play's on-device exemption applies | +| App activity (interactions, search history, installed apps) | No | No | No analytics SDK; server search sends the query string to the *user's own* IMAP server as an IMAP `SEARCH` command (user-initiated, ephemeral) | +| Web browsing | No | No | The reader WebView has JavaScript disabled and network loads blocked unless the user enables remote images (`ui/reader/HtmlBody.kt:62,98`) — and even then requests go to hosts referenced by the email, not to the developer | +| App info and performance (crash logs, diagnostics) | **No** | No | Crash/debug reports are written to local app storage only (`reporting/ReportStore.kt` → `filesDir/debug_reports`); upload requires an explicit user tap **and** a configured endpoint, and the endpoint is empty in this repo (`ReportSubmitter.isEnabled` → false) | +| Device or other IDs | No | No | No advertising ID (no `AD_ID` permission in the merged manifest), no device-ID reads; debug reports include only `Build.MANUFACTURER`/`MODEL`/OS version, and stay on device (`reporting/DiagnosticsCollector.kt`) | + +### Dependency audit (no ads / analytics / tracking SDKs) + +The complete runtime dependency list (`app/build.gradle.kts` + `gradle/libs.versions.toml`) is: +AndroidX (core, lifecycle, activity, navigation, webkit, Compose BOM, Room, DataStore, +WorkManager, Hilt-androidx), Dagger Hilt, kotlinx-coroutines, Eclipse Angus Mail (IMAP/SMTP), +AppAuth-Android (OAuth), and Zetetic SQLCipher. There is **no** Google Play Services, Firebase, +ad, analytics, or crash-reporting dependency, and the merged release manifest contains no +`com.google.android.gms.permission.AD_ID` permission (verified in +`app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml`). + +## Security-practices section of the form + +- **Encrypted in transit:** yes — TLS everywhere, hostname verification pinned on + (`mail..ssl.checkserveridentity=true` in `ImapClient.kt:480` / `SmtpSender.kt:50`). +- **Encryption at rest (optional extra credit, not a form field):** credentials are always + encrypted with an Android Keystore key (`data/security/KeystoreCrypto.kt`, + `CredentialStore.kt`); the mail cache can be SQLCipher-encrypted with a Keystore-sealed random + key (`data/security/DatabaseKeyStore.kt`, opt-in, default off — + `SettingsRepository.kt:44`). +- **Independent security review badge:** not requested (optional program). + +## If anything changes, this form must change + +| Future change | Data-safety impact | +|---|---| +| Configuring a real `DEBUG_REPORT_ENDPOINT` (issue #34) | Declare **App info and performance → Crash logs / Diagnostics**: collected, optional (user-initiated), not shared, encrypted in transit, user can delete (reports are deletable pre-submit) | +| Any opt-in telemetry from issues #10/#11 | Declare the specific types as collected + optional; backlog decision requires it stay strictly opt-in (F-Droid constraint) | +| Any new SDK with network access | Re-run this audit; SDKs count toward the form ("data transmitted by libraries/SDKs") | + +## Conservative alternative declaration (only if Google rejects "no collection") + +Declare the following, all with *Collected: yes · Optional: no · Shared: no · Processed +ephemerally: yes · Purpose: App functionality · Encrypted in transit: yes · Deletion: user can +delete data in-app (remove account)*: + +- Personal info → Email address (account sign-in) +- Messages → Emails (sending/syncing the user's own mail with their provider) + +Contacts, crash logs, and diagnostics remain **not collected** under any reading — they +demonstrably never leave the device in this codebase. diff --git a/docs/play-permissions.md b/docs/play-permissions.md new file mode 100644 index 0000000..a5b4568 --- /dev/null +++ b/docs/play-permissions.md @@ -0,0 +1,112 @@ + +# Permissions justification — merged manifest audit (issue #17) + +Every permission in the **merged release manifest** (source of truth: +`app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml` +after `./gradlew :app:bundleRelease`; attribution from +`app/build/outputs/logs/manifest-merger-release-report.txt`), why it exists, where it is used, +and the text to paste into Play Console where a declaration is required. + +## Complete merged-manifest permission list + +| Permission | Declared by | Runtime prompt? | Purpose | +|---|---|---|---| +| `INTERNET` | app manifest | No | IMAP/SMTP/OAuth/Graph connections to the user's mail provider | +| `ACCESS_NETWORK_STATE` | app manifest | No | Connectivity checks so sync/WorkManager runs only when online | +| `READ_CONTACTS` | app manifest | **Yes** | On-device recipient autocomplete in the compose screen | +| `POST_NOTIFICATIONS` | app manifest | **Yes** (API 33+) | New-mail notifications + mandatory foreground-service status notification | +| `FOREGROUND_SERVICE` | app manifest | No | Prerequisite for running any foreground service (API 28+) | +| `FOREGROUND_SERVICE_DATA_SYNC` | app manifest | No | Type-specific permission for the IMAP IDLE push service (API 34+) | +| `WAKE_LOCK` | `androidx.work:work-runtime:2.11.2` | No | WorkManager keeps the CPU awake while a scheduled job (mail sync, outbox send) runs | +| `RECEIVE_BOOT_COMPLETED` | `androidx.work:work-runtime:2.11.2` | No | WorkManager reschedules pending jobs (periodic sync, queued outbox mail) after reboot | +| `org.libremail.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` | `androidx.core:core:1.17.0` | No | Auto-generated app-signature permission guarding non-exported runtime receivers; not user-facing | + +Nothing else. Notably **absent** (worth stating in any review exchange): + +- **No `AD_ID`** — no ads or analytics SDKs at all. +- **No storage/media permissions** — attachments use the Storage Access Framework + (`OpenMultipleDocuments` in `ui/compose/ComposeScreen.kt:88`) and a `FileProvider` for viewing + (`AndroidManifest.xml:95`). +- **No `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`** — deliberately avoided because Play restricts + it; the app deep-links to the system app-details screen instead + (`push/BatteryOptimizationManager.kt`, comment cites this issue). +- No location, camera, microphone, SMS, call-log, accessibility, or `QUERY_ALL_PACKAGES`. + +## `READ_CONTACTS` (Play "sensitive" permission — scrutinized, no declaration form) + +- **Feature:** recipient autocomplete while composing. `contacts/ContactsRepository.kt` queries + `ContactsContract.CommonDataKinds.Email` for at most 8 name/email matches of the typed text. +- **Data handling:** query and results are entirely **on-device** (results live in memory for + the suggestion dropdown). Nothing from the contacts provider is stored, logged, or + transmitted; an address reaches the network only if the user puts it on an email they send. +- **Request flow:** first composition of the compose screen (`ui/compose/ComposeScreen.kt:101`); + denial is handled gracefully — `ContactsRepository.search` returns empty and composing works + normally (manual address entry). +- **Play-Console justification text (if asked in review):** + > LibreMail is an email client. READ_CONTACTS powers recipient autocomplete on the compose + > screen only: the app queries the on-device contacts provider for names/email addresses + > matching what the user typed and shows up to 8 suggestions. Contact data is processed + > entirely on the device — it is never uploaded, stored outside the suggestion list, or shared. + > The permission is requested in context (first open of the compose screen) and the feature + > degrades gracefully if denied. + +## `POST_NOTIFICATIONS` + +- **Features:** (1) per-account new-mail notifications, generated on-device from synced mail — + `notifications/MailNotifier.kt` (no push/cloud-messaging service; lock-screen content + redacted via `VISIBILITY_PRIVATE`); (2) the persistent low-importance status notification + Android requires while the IMAP IDLE foreground service runs (`push/IdleService.kt:120`). +- **Request flow:** once at first launch, API 33+ only (`MainActivity.kt` + `NotificationPermissionEffect`). If denied, `MailNotifier.notifyNewMail` no-ops (permission + re-checked before every post, `MailNotifier.kt:134`); mail sync itself is unaffected. +- **Play-Console justification text (if asked):** + > Notifies the user of newly received email (per-account channels, generated on the device + > from the user's own mailbox — no push service) and shows the persistent status notification + > Android requires for the optional foreground IMAP IDLE connection. Requested once at first + > launch; all app functions except notifications work if declined. + +## `FOREGROUND_SERVICE_DATA_SYNC` (requires the Play Console FGS declaration) + +Play Console → App content → **Foreground service permissions** asks for the type's use case +and a demo video. Facts to declare, all verifiable in `push/IdleService.kt`: + +- **What runs:** one foreground service (`.push.IdleService`, manifest + `foregroundServiceType="dataSync"`, `AndroidManifest.xml:89`) holding a long-lived IMAP IDLE + (RFC 2177) connection per configured account so the user's own mail server can push new mail + instantly. On server activity it triggers a normal sync into the local cache and a new-mail + notification. +- **Why a foreground service:** IMAP IDLE requires a continuously open TCP connection that + survives while the app is backgrounded; it cannot be modeled as deferrable work. WorkManager + **is** used for everything deferrable (periodic sync, outbox sending) — the service exists + only for the always-connected push case. There is no push-notification alternative (FCM) + because plain IMAP servers cannot address one, and the app deliberately uses no Google cloud + services. +- **User control / lifecycle:** starts only when at least one account exists **and** the "push + mail" setting is enabled (`LibreMailApplication.kt:70`); the setting is a visible toggle in + Settings (`ui/settings/SettingsScreen.kt:147`); the service stops reactively when the toggle + turns off or the last account is removed, and shows a persistent low-importance status + notification while running (`IdleService.startAsForeground`). +- **Declaration text to paste:** + > LibreMail is an email client. The dataSync foreground service maintains a long-lived IMAP + > IDLE (RFC 2177) connection to the user's own mail server so new mail arrives instantly. + > IMAP has no out-of-band push channel (such as FCM), so real-time delivery requires keeping + > this user-visible connection open; all deferrable transfers (periodic sync, sending queued + > mail) already use WorkManager instead. The service runs only while the user has an account + > configured and the "push mail" setting enabled, displays a persistent status notification, + > and stops immediately when the user disables the setting or removes their last account. +- **Demo video (human step):** screen-record: Settings → toggle "push mail" on → the status + notification appears → send the account a mail from elsewhere → the new-mail notification + arrives with the app backgrounded → toggle off → status notification disappears. +- **Note:** the app targets SDK 37, so the API-34 requirement to declare a type for every FGS + is in force; `FOREGROUND_SERVICE` plus the typed permission are both declared and the service + calls `ServiceCompat.startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC)` + (`IdleService.kt:136`). + +## Library-injected permissions (`WAKE_LOCK`, `RECEIVE_BOOT_COMPLETED`) + +Injected by `androidx.work:work-runtime:2.11.2` for its own machinery: holding a partial wake +lock while an enqueued job executes, and re-registering scheduled jobs after a reboot. LibreMail +uses WorkManager for periodic mail sync (`data/sync/` workers), reliable outbox sending +(`SendWorker.kt`), and the opt-in debug-report upload (`ReportUploadWorker.kt`, dormant — +endpoint unconfigured). Neither permission needs a Play declaration; keep this attribution handy +for review questions. From 1d796e3c4116bcfad80690699e673eed40996a97 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 22:13:05 -0500 Subject: [PATCH 5/5] feat(message): move message actions from dropdown to top-bar icons The multi-select contextual action bar buried Archive, Spam, Move, Select all, and the single-selection Reply/Reply All/Forward behind one MoreVert dropdown; only Close and Delete were direct. Promote the common actions to direct IconButtons, matching the reader app bar's icons-not-menus pattern: Archive (Done glyph - material-icons-core has no archive icon, so this leans on the "done = archive" mail idiom), Spam (Warning), and Delete, each with a contentDescription for accessibility. The overflow keeps only the long tail: Move (no usable core glyph, per the ticket it stays text-labeled), Select all, and the single-selection reply actions. All conditional visibility is preserved: Archive/Spam still hide while viewing their own role folder, Move still requires a single-account selection, and the reply actions still require exactly one selected message. Four 48dp actions plus Close still fit a 320dp-wide bar; the count title just truncates earlier. UI tests: the direct Archive icon archives without opening the overflow, the direct Spam icon still confirms before reporting, the Archive icon hides inside the archive folder, and the overflow test now keys on Select all instead of the promoted Archive. Closes #87 Co-Authored-By: Claude Fable 5 --- .../libremail/ui/mailbox/MailboxScreenTest.kt | 58 +++++++++++++++++-- .../org/libremail/ui/mailbox/MailboxScreen.kt | 41 +++++++------ 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt index 0be3688..fafb851 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt @@ -54,8 +54,8 @@ class MailboxScreenTest { smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), ) - private fun message(uid: String, subject: String, bodyFetched: Boolean = false) = Message( - id = "imap:a:INBOX:$uid", + private fun message(uid: String, subject: String, bodyFetched: Boolean = false, folder: String = "INBOX") = Message( + id = "imap:a:$folder:$uid", accountId = "imap:a", sender = "Sender $uid", senderEmail = "s$uid@example.org", @@ -66,12 +66,12 @@ class MailboxScreenTest { timestampMillis = 1_000L, isRead = true, isStarred = false, - folder = "INBOX", + folder = folder, inInbox = true, bodyFetched = bodyFetched, ) - private fun setContent(repo: FakeMailRepository) { + private fun setContent(repo: FakeMailRepository): MailboxViewModel { val viewModel = MailboxViewModel( repo, FakeAccountRepository(accounts = listOf(account)), @@ -92,6 +92,7 @@ class MailboxScreenTest { ) } } + return viewModel } private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) { @@ -132,11 +133,58 @@ class MailboxScreenTest { composeTestRule.onNodeWithText("Second").performClick() composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick() - composeTestRule.onNodeWithText(string(R.string.action_archive)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_select_all)).assertIsDisplayed() composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist() composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist() } + @Test + fun archiveIcon_isDirect_andArchivesTheSelection() { + val repo = FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second"))) + setContent(repo) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + composeTestRule.onNodeWithText("Second").performClick() + // A direct icon button — no trip through the overflow menu. + composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).performClick() + + composeTestRule.waitUntil(5_000) { repo.archivedIds.isNotEmpty() } + assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), repo.archivedIds.first().toSet()) + } + + @Test + fun spamIcon_isDirect_andConfirmsBeforeReporting() { + val repo = FakeMailRepository(messages = listOf(message("1", "First"))) + setContent(repo) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).performClick() + composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_move)).performClick() + + composeTestRule.waitUntil(5_000) { repo.spammedIds.isNotEmpty() } + assertEquals(listOf("imap:a:INBOX:1"), repo.spammedIds.first()) + } + + @Test + fun archiveIcon_hides_whileViewingTheArchiveFolder() { + val repo = FakeMailRepository( + messages = listOf(message("1", "Old news", folder = "Archive")), + folders = listOf(Folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE, selectable = true)), + ) + val viewModel = setContent(repo) + viewModel.selectFolder("imap:a", "Archive") + waitForText("Old news") + + composeTestRule.onNodeWithText("Old news").performTouchInput { longClick() } + + composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).assertIsDisplayed() + } + @Test fun delete_confirmsMoveToTrash_thenTrashesViaRepository() { val repo = FakeMailRepository(messages = listOf(message("1", "First"))) diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index 5c87165..aaebd11 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -31,11 +31,13 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue @@ -530,9 +532,12 @@ private fun SelectedAvatar() { } /** - * The contextual action bar shown while messages are selected. Archive/Delete are the common actions; - * the overflow holds the rest. Reply/Reply All/Forward appear only for a single selected message, and - * Archive/Spam are hidden while already viewing that role's folder. + * The contextual action bar shown while messages are selected. The common actions — Archive, Spam, + * Delete — are direct icon buttons (matching the reader's icons-not-menus app bar); Archive/Spam are + * hidden while already viewing that role's folder. The overflow keeps only the long tail: Move (no + * usable glyph in material-icons-core) and Select all, plus Reply/Reply All/Forward for a single + * selected message. At most four 48dp actions plus the close button fit a 320dp-wide bar; the + * count title just truncates earlier on such screens. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -558,6 +563,18 @@ private fun SelectionTopBar( } }, actions = { + if (folderRole != FolderRole.ARCHIVE) { + IconButton(onClick = onArchive) { + // material-icons-core ships no archive glyph; the checkmark leans on the + // "done with it = archive it" mail idiom (Google Inbox's sweep). + Icon(Icons.Filled.Done, contentDescription = stringResource(R.string.action_archive)) + } + } + if (folderRole != FolderRole.SPAM) { + IconButton(onClick = onSpam) { + Icon(Icons.Filled.Warning, contentDescription = stringResource(R.string.action_spam)) + } + } IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) } @@ -566,24 +583,6 @@ private fun SelectionTopBar( Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more)) } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - if (folderRole != FolderRole.ARCHIVE) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_archive)) }, - onClick = { - expanded = false - onArchive() - }, - ) - } - if (folderRole != FolderRole.SPAM) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_spam)) }, - onClick = { - expanded = false - onSpam() - }, - ) - } if (canMove) { DropdownMenuItem( text = { Text(stringResource(R.string.action_move)) },