Merge remote-tracking branch 'origin/main' into feat-screen-unlock

Brings the screen-lock app gate (#22) up to date with 25 commits of main
(signatures, backup opt-in, battery optimization, rich compose, reporting).

Conflicts resolved as a union of both features:
- SettingsRepository: adopt main's top-level Keys + shared toAppSettings()
  refactor and thread appLock through it; keep both appLock and includeInBackup
- DatabaseModule: keep provideSignatureDao; keep DatabaseFiles.NAME for DB_NAME
- MainActivity: wrap LibreMailApp(pendingCompose=...) inside AppLockGateHost
- SettingsViewModel/SettingsScreen: union app-lock and battery state/effects;
  keep LocalResources for the app-lock toast (LocalContextGetResourceValueCall lint)
- SettingsScreenTest: construct SettingsViewModel with the merged 5 args
- strings.xml: keep both the app-lock and battery/diagnostics string blocks

Fast gate green with JDK 21: assembleDebug + testDebugUnitTest + lintDebug +
compileDebugAndroidTestKotlin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 13:17:22 -05:00
co-authored by Claude Opus 4.8
120 changed files with 8344 additions and 455 deletions
+68 -40
View File
@@ -5,28 +5,37 @@ 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: **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).
> Status: **in development.** Material You shell; **onboarding** — a first-run flow from a
> welcome screen through a vendor picker (Outlook/Hotmail, Gmail, Yahoo, iCloud, or Other) and
> per-vendor setup to your first account's inbox; **account setup** — Outlook/Microsoft via
> OAuth 2.0 (AppAuth/PKCE), Gmail/Yahoo/iCloud via app password, and generic IMAP/SMTP, all
> with a live connection test and Keystore-encrypted credentials; **IMAP receive** — background
> sync (WorkManager) into a local Room cache with pull-to-refresh, backfilling your **entire**
> mail history (resumable) with an optional device-only retention cap; **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; **composing** — a rich-text HTML
> editor with a formatting toolbar and per-account signatures that sends
> `multipart/alternative` (HTML with a plaintext fallback) through a reliable background
> **outbox** (WorkManager-queued and retried, with a viewable outbox folder), plus
> device-contacts autocomplete, reply, and **drafts**; **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;
> **search** across cached mail and the server (IMAP SEARCH); Outlook/Microsoft send via
> Microsoft **Graph** with an SMTP/XOAUTH2 fallback; an opt-in **app lock**
> (biometric/device-credential) that binds the encrypted cache key to your unlock; **mailto:**
> link handling with optional default-mail-app registration; and opt-in, F-Droid-safe **debug
> reporting** — local crash/error capture that you review (with a PII disclaimer) and submit
> only on an explicit action.
## Features (target MVP)
- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider.
- 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 → your inbox.
- 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.
- Rich-text compose with a formatting toolbar, per-account signatures, and phone/account contacts integration.
- Offline-first: a local Room cache with full-history backfill and an optional device-only retention limit.
- Modern, opt-in security: OAuth 2.0 (Authorization Code + PKCE) for Outlook, Keystore-encrypted credentials, optional SQLCipher cache encryption, and a biometric/device-credential app lock.
## Tech stack
@@ -67,27 +76,27 @@ sdkmanager "platforms;android-37.0" "build-tools;37.0.0"
`local.properties` (git-ignored) must point `sdk.dir` at your Android SDK; Android
Studio creates it automatically.
## Gmail account setup (OAuth client)
## Accounts and onboarding
Gmail IMAP/SMTP requires the restricted `https://mail.google.com/` scope. While the
app is unpublished you can use it in **Testing** mode with up to 100 test users and
no security assessment; a public Play Store release later requires a Google CASA
assessment for the restricted scope.
On first launch LibreMail runs a short onboarding flow: a welcome screen, a **vendor picker**
(Outlook/Hotmail, Gmail, Yahoo, iCloud, or **Other**), per-vendor setup, and an "add another
account?" prompt before it drops you on your first account's inbox. You can add more accounts
later from settings; a unified inbox merges them with per-account filtering.
1. In the [Google Cloud Console](https://console.cloud.google.com/), create a
project (e.g. *LibreMail*).
2. **APIs & Services → Library →** enable the **Gmail API**.
3. **OAuth consent screen:** user type *External*; add the scope
`https://mail.google.com/`; under **Test users**, add your Google address.
Leave the app in **Testing**.
4. **Credentials → Create credentials → OAuth client ID → Android.** Use package
name `org.libremail.app` and your debug keystore SHA-1:
```bash
keytool -list -v -keystore "$HOME/.android/debug.keystore" \
-alias androiddebugkey -storepass android -keypass android
```
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`.
LibreMail supports three kinds of account:
- **Outlook / Hotmail** — signs in with **OAuth 2.0** through Microsoft (AppAuth); no password
is stored. See [Outlook / Microsoft account setup](#outlook--microsoft-account-setup-oauth-client)
below.
- **Gmail, Yahoo and iCloud** — preconfigured IMAP/SMTP that authenticate with a provider
**app password** (not your normal account password), preferring STARTTLS where the provider
supports it. Onboarding links you to each vendor's app-password page. **Gmail requires
2-Step Verification to be enabled** before Google will issue an app password.
- **Other** — a manual IMAP/SMTP form (host, port, security, and credentials) for any other
provider.
App passwords and OAuth tokens are held in a credential store encrypted with the Android
Keystore, and every account runs a live connection test before it is saved.
## Outlook / Microsoft account setup (OAuth client)
@@ -108,6 +117,25 @@ token. A working client ID ships with the build; to use your own Azure app regis
4. Copy the **Application (client) ID** into `secrets.properties` as
`OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default).
## Privacy and data flow
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**:
- **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
your authentication, so the database is only decrypted after you unlock the app.
- **Debug reporting** — **off by default.** When enabled, crashes and errors are captured
**locally**; you review the full report — shown with a plain-language **PII disclaimer** —
and it is sent only when you explicitly submit it, to a configurable (optional) endpoint.
There is no hosted crash pipeline collecting reports in the background.
- **Android Backup** — **off by default.** When you turn it on, only safe app settings are
backed up; the encrypted-database key, account credentials, and the mail cache are
**excluded**. Because Android's backup transport can route data through Google, it stays
disabled unless you opt in — the kind of optional behavior F-Droid lists as an anti-feature.
## Architecture
Offline-first, unidirectional, layered:
@@ -119,9 +147,9 @@ data/ Room (entities, DAOs, database) + repository implementation (source o
di/ Hilt modules
```
The UI observes Room via `Flow`; later increments add a sync engine (Angus Mail
over IMAP/SMTP) that writes into Room, and an auth layer (AppAuth + an Android
Keystore-backed credential store).
The UI observes Room via `Flow`; a sync engine (Angus Mail over IMAP/SMTP, plus Microsoft
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.
## License
+17 -16
View File
@@ -14,21 +14,12 @@ plugins {
alias(libs.plugins.detekt)
}
// Read the Gmail OAuth client id from secrets.properties (git-ignored). Empty when absent.
// Read optional build secrets (Outlook client id, release signing) from secrets.properties
// (git-ignored). Absent values fall back to the defaults below.
val secretsFile = rootProject.file("secrets.properties")
val secrets = Properties().apply {
if (secretsFile.exists()) secretsFile.inputStream().use { load(it) }
}
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.
@@ -37,6 +28,15 @@ val outlookOAuthClientId: String = secrets.getProperty(
"04e4aa5e-ed1f-47f9-b567-b99a0b29b3df",
)
// Custom URI scheme AppAuth uses to capture the Outlook OAuth redirect. Must match the scheme of
// OUTLOOK_OAUTH_REDIRECT_URI and the redirect URI registered in the Azure app registration.
val outlookRedirectScheme = "org.libremail.outlook"
// Debug-report ingest endpoint (issue #34, out of scope for this repo). Empty by default: the debug
// reporting client is strictly opt-in and never sends anything unless the user taps Submit AND an
// endpoint is configured here (overridable via git-ignored secrets.properties).
val debugReportEndpoint: String = secrets.getProperty("DEBUG_REPORT_ENDPOINT", "")
// 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")
@@ -55,12 +55,12 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"")
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
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"$outlookRedirectScheme://oauth2redirect\"")
buildConfigField("String", "DEBUG_REPORT_ENDPOINT", "\"$debugReportEndpoint\"")
// AppAuth's bundled manifest requires this placeholder; it registers the redirect scheme on
// RedirectUriReceiverActivity so the Outlook sign-in redirect returns to the app.
manifestPlaceholders["appAuthRedirectScheme"] = outlookRedirectScheme
}
signingConfigs {
@@ -198,6 +198,7 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.espresso.intents)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
@@ -0,0 +1,523 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "aff2afe197ff1b0b41630e5a165d09f3",
"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": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `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": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"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, `bccAddresses` TEXT NOT NULL DEFAULT '', `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": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aff2afe197ff1b0b41630e5a165d09f3')"
]
}
}
@@ -0,0 +1,599 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "a31c7e2934217228a837ab5453025da4",
"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": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` 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": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"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"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"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, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, 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": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"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
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentHtml",
"columnName": "contentHtml",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"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, 'a31c7e2934217228a837ab5453025da4')"
]
}
}
@@ -41,6 +41,11 @@ class FakeAccountRepository(
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> {
addedAccount = account
addedPassword = password
// Mirror the real repository: a successful add makes the account observable, so screens that
// react to the account list (e.g. the mailbox after onboarding) see it appear.
if (result.isSuccess) {
accountsFlow.value = accountsFlow.value.filterNot { it.id == account.id } + account
}
return result
}
@@ -35,7 +35,7 @@ class ManualSetupScreenTest {
// Build the view model once and capture it, so recomposition doesn't recreate it.
private fun setContent(
repository: FakeAccountRepository = FakeAccountRepository(),
onAccountAdded: () -> Unit = {},
onAccountAdded: (String) -> Unit = {},
) {
val viewModel = ManualSetupViewModel(repository)
composeTestRule.setContent {
@@ -24,6 +24,7 @@ import org.libremail.R
import org.libremail.contacts.ContactsRepository
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
@@ -81,6 +82,7 @@ class ComposeScreenTest {
accountRepository = FakeAccountRepository(accounts = listOf(account)),
contactsRepository = ContactsRepository(context),
accountSettingsRepository = AccountSettingsRepository(database.accountSettingsDao()),
signatureRepository = SignatureRepository(database.signatureDao()),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
@@ -13,6 +13,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
@@ -71,7 +72,12 @@ class MailboxScreenTest {
)
private fun setContent(repo: FakeMailRepository) {
val viewModel = MailboxViewModel(repo, FakeAccountRepository(accounts = listOf(account)), FakeMailSyncer())
val viewModel = MailboxViewModel(
repo,
FakeAccountRepository(accounts = listOf(account)),
FakeMailSyncer(),
SavedStateHandle(),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
MailboxScreen(
@@ -0,0 +1,186 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import android.app.Activity
import android.app.Instrumentation
import android.net.Uri
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.ui.test.assertIsDisplayed
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.allOf
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.data.settings.SettingsRepository
import org.libremail.push.BatteryOptimizationManager
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end test for the onboarding battery opt-in step (#49). It drives the real
* [BatteryOptimizationScreen] + graph-scoped [OnboardingViewModel] through a NavHost that mirrors the
* production "add another? → (optional) battery → inbox" tail (see
* `LibreMailApp.onboardingFinishDestinations`).
*
* Battery status comes from the real [BatteryOptimizationManager]: a fresh emulator is never on the
* battery allowlist, so the step is offered. The "already unrestricted" skip can't be forced from a
* test (there's no API to set it) and is covered by the view-model unit tests; the "already handled"
* skip is exercised here through the real settings DataStore.
*/
@RunWith(AndroidJUnit4::class)
class BatteryOptimizationStepTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var settingsRepository: SettingsRepository
private lateinit var onboarding: OnboardingViewModel
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun waitForText(text: String) = composeTestRule.waitUntil(10_000) {
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
/**
* Renders the "add another? → battery → inbox" tail with one account already added this session,
* starting on the add-another prompt. [handled] seeds the persisted "prompt handled" flag so the
* skip path can be exercised through the real repository.
*/
private fun setContent(handled: Boolean) {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
settingsRepository = SettingsRepository(context)
runBlocking { settingsRepository.setBatteryPromptHandled(handled) }
onboarding = OnboardingViewModel(BatteryOptimizationManager(context), settingsRepository)
onboarding.onAccountAdded(FIRST_ACCOUNT_ID)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Routes.ONBOARDING_ADD_ANOTHER) {
composable(Routes.ONBOARDING_ADD_ANOTHER) {
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
AddAnotherAccountScreen(
onAddAnother = {},
onFinish = {
if (batteryPromptNeeded == true) {
navController.navigate(Routes.ONBOARDING_BATTERY)
} else {
navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
}
}
},
)
}
composable(Routes.ONBOARDING_BATTERY) {
BatteryOptimizationScreen(
viewModel = onboarding,
onFinish = {
onboarding.markBatteryPromptHandled()
navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
}
},
)
}
composable(
route = Routes.MAILBOX_PATTERN,
arguments = listOf(
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
type = NavType.StringType
defaultValue = ""
},
),
) {
Text(INBOX_MARKER)
}
}
}
}
}
@Test
fun batteryStep_isOffered_thenNotNow_landsOnInbox() {
setContent(handled = false)
// The decision resolves asynchronously (a DataStore read); wait before driving the finish tap.
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
// The battery opt-in step is shown...
waitForText(string(R.string.onboarding_battery_title))
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed()
// ...and "Not now" continues to the inbox and records the prompt as handled (so it won't nag).
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick()
waitForText(INBOX_MARKER)
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } }
}
@Test
fun batteryStep_takeMeThere_opensThisAppsSystemSettings() {
setContent(handled = false)
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
waitForText(string(R.string.onboarding_battery_title))
val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName
Intents.init()
try {
// Stub the match so the real system settings screen never actually launches mid-test.
Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick()
// Deep-links to *this app's* details screen (where Battery → Unrestricted lives).
Intents.intended(
allOf(
hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS),
hasData(Uri.fromParts("package", packageName, null)),
),
)
} finally {
Intents.release()
}
}
@Test
fun finish_skipsBatteryStep_whenAlreadyHandled() {
setContent(handled = true)
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == false }
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
// Straight to the inbox — the opt-in step is skipped entirely.
waitForText(INBOX_MARKER)
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertDoesNotExist()
}
private companion object {
const val INBOX_MARKER = "INBOX-REACHED"
const val FIRST_ACCOUNT_ID = "imap:e2e@example.com"
}
}
@@ -0,0 +1,224 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import androidx.activity.ComponentActivity
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.assertIsDisplayed
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.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.auth.OutlookAuthManager
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Message
import org.libremail.push.BatteryOptimizationManager
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.FakeMailRepository
import org.libremail.ui.FakeMailSyncer
import org.libremail.ui.accountsetup.AccountPickerScreen
import org.libremail.ui.accountsetup.AccountSetupViewModel
import org.libremail.ui.accountsetup.AppPasswordSetupScreen
import org.libremail.ui.accountsetup.AppPasswordViewModel
import org.libremail.ui.mailbox.MailboxScreen
import org.libremail.ui.mailbox.MailboxViewModel
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end test of the onboarding flow: a fresh install (no accounts) walks welcome → vendor
* picker → app-password setup → "add another?" → the first account's inbox.
*
* It drives the real onboarding screens + ViewModels through a real [NavHost]. The account backend is
* the in-memory [FakeAccountRepository] (a successful add makes the account observable) rather than a
* live server — GreenMail-backed connection behaviour is covered by the repository unit tests; this
* test owns the cross-screen navigation contract.
*/
@RunWith(AndroidJUnit4::class)
class OnboardingFlowTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
// Generous cap for the slow, animation-disabled CI matrix emulators; waitUntil returns as soon
// as the text appears, so the happy path is unaffected.
private fun waitForText(text: String) = composeTestRule.waitUntil(15_000) {
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
private fun inboxMessage() = Message(
id = "imap:e2e@gmail.com:INBOX:1",
accountId = "imap:e2e@gmail.com",
sender = "Welcome",
senderEmail = "welcome@gmail.com",
subject = "E2E first message",
snippet = "",
body = "",
isHtml = false,
timestampMillis = 1_000L,
isRead = false,
isStarred = false,
folder = "INBOX",
inInbox = true,
bodyFetched = false,
)
private fun setOnboardingContent(accountRepo: FakeAccountRepository, mailRepo: FakeMailRepository) {
// Real collaborators are cheap here: the manager just wraps PowerManager and the repository
// reads the on-device settings DataStore. This test drives its own nav graph (without the
// battery step), so the onboarding view model's battery decision is inert for this flow.
val appContext = composeTestRule.activity.applicationContext
val onboarding = OnboardingViewModel(
BatteryOptimizationManager(appContext),
SettingsRepository(appContext),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
val navController = rememberNavController()
val context = LocalContext.current
val outlookAuthManager = remember { OutlookAuthManager(context) }
NavHost(navController = navController, startDestination = Routes.ONBOARDING_WELCOME) {
composable(Routes.ONBOARDING_WELCOME) {
OnboardingWelcomeScreen(
onAddAccount = { navController.navigate(Routes.ONBOARDING_PICKER) },
)
}
composable(Routes.ONBOARDING_PICKER) {
val viewModel = remember { AccountSetupViewModel(outlookAuthManager, accountRepo) }
AccountPickerScreen(
onBack = {},
onAccountAdded = { id ->
onboarding.onAccountAdded(id)
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER)
},
onPickProvider = { provider ->
navController.navigate(Routes.onboardingAppPassword(provider.key))
},
onManualSetup = {},
viewModel = viewModel,
)
}
composable(
route = Routes.ONBOARDING_APP_PASSWORD_PATTERN,
arguments = listOf(
navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType },
),
) { entry ->
val key = entry.arguments?.getString(Routes.APP_PASSWORD_ARG_PROVIDER).orEmpty()
val viewModel = remember {
AppPasswordViewModel(
SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to key)),
accountRepo,
)
}
AppPasswordSetupScreen(
onBack = {},
onAccountAdded = { id ->
onboarding.onAccountAdded(id)
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) {
popUpTo(Routes.ONBOARDING_PICKER)
}
},
viewModel = viewModel,
)
}
composable(Routes.ONBOARDING_ADD_ANOTHER) {
AddAnotherAccountScreen(
onAddAnother = {
navController.navigate(Routes.ONBOARDING_PICKER) {
popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true }
}
},
onFinish = {
val id = onboarding.firstAddedAccountId
val dest = if (id != null) Routes.mailboxForAccount(id) else Routes.MAILBOX
navController.navigate(dest) {
popUpTo(Routes.ONBOARDING_WELCOME) { inclusive = true }
}
},
)
}
composable(
route = Routes.MAILBOX_PATTERN,
arguments = listOf(
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
val account = entry.arguments?.getString(Routes.MAILBOX_ARG_ACCOUNT).orEmpty()
val viewModel = remember {
MailboxViewModel(
mailRepo,
accountRepo,
FakeMailSyncer(),
SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to account)),
)
}
MailboxScreen(
onOpenMessage = {},
onCompose = {},
onOpenDrafts = {},
onOpenOutbox = {},
onAddAccount = {},
onOpenCompose = {},
onSelectTab = {},
viewModel = viewModel,
)
}
}
}
}
}
@Test
fun onboarding_addsAppPasswordAccount_thenLandsOnFirstAccountInbox() {
val accountRepo = FakeAccountRepository()
val mailRepo = FakeMailRepository(messages = listOf(inboxMessage()))
setOnboardingContent(accountRepo, mailRepo)
// Welcome → picker.
composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
// Picker → Gmail app-password setup.
waitForText("Gmail")
composeTestRule.onNodeWithText("Gmail").performClick()
// App-password setup: email + app password come from the user; servers come from the preset.
// performScrollTo first — on the short default matrix emulator the fields and the "Test and
// add" button sit below the fold of this scrolling screen, and a positional click on an
// off-screen button is a silent no-op (which is why this passed only on API 37's taller AVD).
waitForText(string(R.string.app_password_email))
composeTestRule.onNodeWithText(string(R.string.app_password_email))
.performScrollTo().performTextInput("e2e@gmail.com")
composeTestRule.onNodeWithText(string(R.string.app_password_field))
.performScrollTo().performTextInput("app-pass")
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add))
.performScrollTo().performClick()
// "Add another?" prompt → No.
waitForText(string(R.string.onboarding_add_another_prompt))
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
// Landed on the first (and only) account's inbox.
waitForText("E2E first message")
composeTestRule.onNodeWithText("E2E first message").assertIsDisplayed()
}
}
@@ -6,7 +6,6 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
@@ -19,6 +18,7 @@ import org.libremail.R
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.toEntity
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
@@ -48,6 +48,8 @@ class AccountSettingsScreenTest {
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private var manageSignaturesClicked = false
private fun setContent(): AccountSettingsRepository {
val context = ApplicationProvider.getApplicationContext<Context>()
// Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by
@@ -64,24 +66,27 @@ class AccountSettingsScreenTest {
savedStateHandle = SavedStateHandle(mapOf(Routes.ACCOUNT_SETTINGS_ARG_ID to account.id)),
accountRepository = FakeAccountRepository(accounts = listOf(account)),
accountSettingsRepository = repository,
signatureRepository = SignatureRepository(db.signatureDao()),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AccountSettingsScreen(onBack = {}, viewModel = viewModel)
AccountSettingsScreen(
onBack = {},
onManageSignatures = { manageSignaturesClicked = true },
viewModel = viewModel,
)
}
}
return repository
}
@Test
fun editingSignature_persistsThroughTheRepository() {
val repository = setContent()
fun manageSignatures_opensTheSignaturesScreen() {
setContent()
composeTestRule.onNodeWithText(string(R.string.settings_signature_hint)).performTextInput("Cheers")
composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performClick()
composeTestRule.waitUntil(5_000) {
runBlocking { repository.get(account.id).signature } == "Cheers"
}
composeTestRule.waitUntil(5_000) { manageSignaturesClicked }
}
@Test
@@ -21,6 +21,7 @@ import org.libremail.data.security.KeystoreCrypto
import org.libremail.data.security.PassphraseSession
import org.libremail.data.settings.FetchPolicy
import org.libremail.data.settings.SettingsRepository
import org.libremail.push.BatteryOptimizationManager
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.theme.LibreMailTheme
@@ -46,11 +47,23 @@ class SettingsScreenTest {
override fun availability() = AppLockAvailability.NONE_ENROLLED
}
val keyStore = DatabaseKeyStore(context, KeystoreCrypto(), DatabaseKeyCipher(), PassphraseSession())
val viewModel = SettingsViewModel(FakeAccountRepository(), settingsRepository, appLockManager, keyStore)
val viewModel = SettingsViewModel(
FakeAccountRepository(),
settingsRepository,
appLockManager,
keyStore,
BatteryOptimizationManager(context),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SettingsScreen(onAddAccount = {}, onOpenAccount = {}, onSelectTab = {}, viewModel = viewModel)
SettingsScreen(
onAddAccount = {},
onOpenAccount = {},
onSelectTab = {},
onReportProblem = {},
viewModel = viewModel,
)
}
}
+53 -17
View File
@@ -10,10 +10,19 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android Backup is opt-in and OFF by default (issue #21). allowBackup can't be toggled at
runtime, so LibreMailBackupAgent gates it on the "Include settings in Android Backup"
preference: with backup disabled the agent ships nothing. When enabled, only the settings
DataStore is backed up per the allowlist in data_extraction_rules (API 31+) /
backup_rules (API 29-30) — never credentials, the mail cache, or the Keystore-sealed cache
passphrase. fullBackupOnly keeps this to Auto Backup (full-data) only. -->
<application
android:name=".LibreMailApplication"
android:allowBackup="false"
android:allowBackup="true"
android:backupAgent=".backup.LibreMailBackupAgent"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -29,6 +38,38 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Handle mailto: links tapped in browsers and other apps (ACTION_VIEW), which is also
what makes LibreMail appear on the system "Open by default" / default-apps screen
where the platform exposes an email association. Android has no public RoleManager
email role, so becoming the system default is OEM-dependent — hence "where supported". -->
<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="mailto" />
</intent-filter>
<!-- "Send email to <address>" targets from other apps (contacts, dialer, etc.). -->
<intent-filter>
<action android:name="android.intent.action.SENDTO" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mailto" />
</intent-filter>
<!-- Share-to-email: text and RFC-822 email payloads with recipients/subject/body extras. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="message/rfc822" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="message/rfc822" />
</intent-filter>
</activity>
<!-- WorkManager is initialized on-demand via Configuration.Provider, so remove the
@@ -61,25 +102,20 @@
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.
AppAuth's RedirectUriReceiverActivity extends AppCompatActivity, so it needs an
AppCompat theme; without this it inherits the app's Theme.Material shell and crashes
("You need to use a Theme.AppCompat theme") when the redirect launches it. We reuse the
translucent theme AppAuth itself applies to AuthorizationManagementActivity. -->
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth's bundled manifest
already declares RedirectUriReceiverActivity with an intent-filter for
${appAuthRedirectScheme}, which build.gradle.kts sets to the Outlook scheme
(org.libremail.outlook); the redirect URI org.libremail.outlook://oauth2redirect must
be registered as a public-client (mobile/desktop) redirect in the Azure app
registration. We only merge a theme onto that activity here: AppAuth declares no theme
and RedirectUriReceiverActivity extends AppCompatActivity, so without an AppCompat theme
it inherits the app's Theme.Material shell and crashes ("You need to use a Theme.AppCompat
theme") when the redirect launches it. We reuse the translucent theme AppAuth itself
applies to AuthorizationManagementActivity. -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Translucent.NoTitleBar"
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>
tools:node="merge" />
</application>
</manifest>
@@ -16,6 +16,10 @@ import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.domain.repository.AccountRepository
import org.libremail.push.IdlePushManager
import org.libremail.reporting.AppLog
import org.libremail.reporting.CrashReporter
import org.libremail.reporting.DiagnosticsCollector
import org.libremail.reporting.RingLogBuffer
import javax.inject.Inject
@HiltAndroidApp
@@ -33,6 +37,12 @@ class LibreMailApplication :
@Inject lateinit var idlePushManager: IdlePushManager
@Inject lateinit var ringLogBuffer: RingLogBuffer
@Inject lateinit var crashReporter: CrashReporter
@Inject lateinit var diagnosticsCollector: DiagnosticsCollector
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
@@ -46,6 +56,13 @@ class LibreMailApplication :
override fun onCreate() {
super.onCreate()
// Wire up debug reporting first so crashes during the rest of startup are still captured.
AppLog.install(ringLogBuffer)
crashReporter.install()
AppLog.i(TAG, "Application created")
// Warm the settings cache so a later crash report can include non-PII settings without
// touching DataStore on the crashing thread.
appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } }
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
@@ -72,4 +89,8 @@ class LibreMailApplication :
fun ensurePushStarted() {
if (pushShouldBeActive) idlePushManager.start()
}
private companion object {
const val TAG = "LibreMail"
}
}
@@ -2,6 +2,7 @@
package org.libremail
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -12,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
@@ -19,6 +21,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import org.libremail.data.settings.SettingsRepository
import org.libremail.ui.LibreMailApp
import org.libremail.ui.compose.ComposePrefill
import org.libremail.ui.compose.IntentComposeParser
import org.libremail.ui.lock.AppLockGateHost
import org.libremail.ui.theme.LibreMailTheme
import javax.inject.Inject
@@ -32,6 +36,12 @@ class MainActivity : FragmentActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
/**
* A pending compose request parsed from a `mailto:` / share intent, consumed once by the NavHost.
* Held as Compose state so [onNewIntent] can re-trigger it while the activity is alive.
*/
private val pendingCompose = mutableStateOf<ComposePrefill?>(null)
override fun onStart() {
super.onStart()
// Foreground: recover IDLE push if a background start was previously blocked.
@@ -41,6 +51,11 @@ class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Only on a fresh launch — on a config-change recreation the NavHost restores the compose
// destination itself, so re-parsing the (unchanged) intent would open a duplicate.
if (savedInstanceState == null) {
pendingCompose.value = IntentComposeParser.parse(intent)
}
setContent {
val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
LibreMailTheme(dynamicColor = dynamicColor) {
@@ -48,11 +63,20 @@ class MainActivity : FragmentActivity() {
// Gate the whole app behind the screen-lock when app-lock is enabled. When it is off
// the gate resolves straight to the content, so this is a no-op for most users.
AppLockGateHost {
LibreMailApp()
LibreMailApp(
pendingCompose = pendingCompose.value,
onComposeHandled = { pendingCompose.value = null },
)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
IntentComposeParser.parse(intent)?.let { pendingCompose.value = it }
}
}
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
@@ -1,111 +0,0 @@
// 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 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 org.json.JSONObject
import org.libremail.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* 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,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import org.libremail.data.settings.AppSettings
/**
* Single source of truth for what LibreMail is willing to hand to Android Backup. Kept in lockstep
* with `res/xml/data_extraction_rules.xml` (API 31+) and `res/xml/backup_rules.xml` (API 29-30); the
* path constants here are asserted against those resources by `DataExtractionRulesTest`.
*
* Only re-creatable user preferences are eligible. The mail cache re-downloads on the next sync, and
* the credentials plus the Keystore-sealed cache passphrase are device-bound secrets that would only
* ever restore as undecryptable ciphertext — so they are never backed up.
*/
object BackupPolicy {
/** `filesDir`-relative DataStore file holding user preferences — the only data we back up. */
const val SAFE_SETTINGS_FILE: String = "datastore/libremail_settings.preferences_pb"
/** `filesDir`-relative paths that must never leave the device. */
val EXCLUDED_FILE_PATHS: List<String> = listOf(
// Keystore-sealed SQLCipher passphrase for the encrypted cache: the wrapping key is
// non-exportable and device-bound, so this ciphertext is useless anywhere else.
"datastore/libremail_dbkey.preferences_pb",
)
/** `databases`-dir-relative names that must never leave the device (encrypted credentials + mail cache). */
val EXCLUDED_DATABASE_PATHS: List<String> = listOf(
"libremail.db",
"libremail.db-wal",
"libremail.db-shm",
"libremail.db-journal",
)
/**
* Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up
* (or transferred device-to-device) unless the user has explicitly enabled it in Settings.
*/
fun shouldBackUp(settings: AppSettings): Boolean = settings.includeInBackup
}
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import android.app.backup.BackupAgentHelper
import android.app.backup.FullBackupDataOutput
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.libremail.data.settings.settingsDataStore
import org.libremail.data.settings.toAppSettings
/**
* Enforces the runtime backup opt-in on top of Android Auto Backup.
*
* `android:allowBackup` is a manifest flag that can't be toggled at runtime, so the "include settings
* in Android Backup" preference is enforced here instead: [onFullBackup] runs the backup only when the
* user has opted in (the default is off, so no app data leaves the device). When opted in, it defers to
* the framework, which applies the allowlist in `res/xml/data_extraction_rules.xml` (and
* `res/xml/backup_rules.xml` on API < 31) — backing up the user-preferences DataStore only, never the
* mail cache, the encrypted credentials, or the Keystore-sealed cache passphrase.
*
* Extends [BackupAgentHelper] (rather than raw `BackupAgent`) so the unused key/value backup/restore
* paths inherit safe no-op implementations; only full-data backup is used (`fullBackupOnly=true`), and
* full-data restore uses the default `onRestoreFile` handling.
*
* The opt-in flag is read directly from the shared [settingsDataStore] singleton so it does not depend
* on Hilt or `Application.onCreate` having run in the framework's restricted backup mode.
*/
class LibreMailBackupAgent : BackupAgentHelper() {
override fun onFullBackup(data: FullBackupDataOutput) {
if (backupOptedIn()) {
super.onFullBackup(data)
}
}
/** Reads the opt-in flag; any failure defaults to "not opted in" so we never back up by accident. */
private fun backupOptedIn(): Boolean = runCatching {
runBlocking {
BackupPolicy.shouldBackUp(applicationContext.settingsDataStore.data.first().toAppSettings())
}
}.getOrDefault(false)
}
@@ -2,40 +2,55 @@
package org.libremail.data
import org.libremail.domain.model.ReplyMode
import org.libremail.mail.HtmlToText
import org.libremail.mail.ReplyContext
import org.libremail.richtext.RichTextContent
import org.libremail.richtext.RichTextHtml
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** The pre-filled compose fields for a reply/forward (everything else the user fills in). */
data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String)
/**
* The pre-filled compose fields for a reply/forward. [body] is the plaintext form; [bodyHtml] is the
* matching HTML (the quote rendered as a `<blockquote>`), so the reply can go out as
* `multipart/alternative` without the user having to re-format the quote.
*/
data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String, val bodyHtml: String)
/**
* Pure builder that turns an original message ([ReplyContext]) into the pre-filled compose fields for a
* reply, reply-all, or forward. Kept free of Android/IMAP dependencies so it can be unit-tested directly.
*
* HTML originals are quoted by first reducing them to readable text (via [HtmlToText]) and then
* quoting that — never by prefixing "> " onto raw tags — so the quote can never corrupt the markup.
* The plaintext quote's "> " / attribution structure is then rendered to a clean `<blockquote>` for
* the HTML alternative.
*/
object ReplyBuilder {
fun build(context: ReplyContext, mode: ReplyMode, selfEmail: String): ReplyContent = when (mode) {
ReplyMode.REPLY -> ReplyContent(
to = context.fromEmail,
cc = "",
subject = prefixedSubject(context.subject, "Re:"),
body = quotedReply(context),
)
ReplyMode.REPLY -> reply(context, cc = "")
ReplyMode.REPLY_ALL -> reply(context, cc = replyAllCc(context, selfEmail).joinToString(", "))
ReplyMode.FORWARD -> {
val body = forwardedBody(context)
ReplyContent(
to = "",
cc = "",
subject = prefixedSubject(context.subject, "Fwd:"),
body = body,
bodyHtml = htmlOf(body),
)
}
}
ReplyMode.REPLY_ALL -> ReplyContent(
private fun reply(context: ReplyContext, cc: String): ReplyContent {
val body = quotedReply(context)
return ReplyContent(
to = context.fromEmail,
cc = replyAllCc(context, selfEmail).joinToString(", "),
cc = cc,
subject = prefixedSubject(context.subject, "Re:"),
body = quotedReply(context),
)
ReplyMode.FORWARD -> ReplyContent(
to = "",
cc = "",
subject = prefixedSubject(context.subject, "Fwd:"),
body = forwardedBody(context),
body = body,
bodyHtml = htmlOf(body),
)
}
@@ -68,15 +83,12 @@ object ReplyBuilder {
append(bodyText(context))
}
/** The original body as plain text (HTML stripped), suitable for quoting in a plain-text compose. */
private fun bodyText(context: ReplyContext): String = if (context.isHtml) htmlToText(context.body) else context.body
/** The original body as plain text (HTML stripped), suitable for quoting in a compose field. */
private fun bodyText(context: ReplyContext): String =
if (context.isHtml) HtmlToText.convert(context.body) else context.body
private fun htmlToText(html: String): String = html
.replace(Regex("(?i)<br\\s*/?>"), "\n")
.replace(Regex("(?i)</p\\s*>"), "\n\n")
.replace(Regex("<[^>]*>"), "")
.replace(Regex("[ \\t]+"), " ")
.trim()
/** Renders the plaintext quote (with its "> " markers) to the equivalent clean HTML. */
private fun htmlOf(body: String): String = RichTextHtml.toHtml(RichTextContent(body))
private fun formatDate(millis: Long): String =
SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis))
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data
import org.libremail.domain.model.Signature
import org.libremail.richtext.RichTextContent
import org.libremail.richtext.RichTextHtml
/**
* A signature rendered into both forms the composer needs: [plain] for the plaintext body/fallback
* and [html] for the HTML body. The two are kept in sync — parsing [html] back through the rich-text
* model yields exactly [plain] — so the editor shows the same content whichever it seeds from, and a
* From-account swap can strip the previously applied block from either representation.
*
* The block opens with the RFC 3676 "-- " delimiter so receiving clients recognize it as a signature.
*/
data class SignatureBlock(val plain: String, val html: String) {
val isEmpty: Boolean get() = plain.isEmpty() && html.isEmpty()
companion object {
val EMPTY = SignatureBlock("", "")
/** The block for [signature], or [EMPTY] when there is none / it is blank. */
fun of(signature: Signature?): SignatureBlock {
if (signature == null) return EMPTY
val plainSig = signature.plainText().trimEnd()
if (plainSig.isBlank() && signature.html.isBlank()) return EMPTY
return SignatureBlock(
plain = "$DELIMITER_PLAIN$plainSig",
html = RichTextHtml.toHtml(RichTextContent(DELIMITER_PLAIN)) + signature.html,
)
}
private const val DELIMITER_PLAIN = "\n\n-- \n"
}
}
@@ -11,6 +11,7 @@ import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.dao.SignatureDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AccountSettingsEntity
import org.libremail.data.local.entity.AttachmentEntity
@@ -19,6 +20,7 @@ import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.entity.SignatureEntity
@Database(
entities = [
@@ -30,8 +32,9 @@ import org.libremail.data.local.entity.OutboxEntity
OutboxEntity::class,
DraftEntity::class,
FolderEntity::class,
SignatureEntity::class,
],
version = 9,
version = 11,
exportSchema = true,
)
abstract class LibreMailDatabase : RoomDatabase() {
@@ -43,4 +46,5 @@ abstract class LibreMailDatabase : RoomDatabase() {
abstract fun outboxDao(): OutboxDao
abstract fun draftDao(): DraftDao
abstract fun folderDao(): FolderDao
abstract fun signatureDao(): SignatureDao
}
@@ -159,9 +159,11 @@ internal fun DraftEntity.toDomain(): Draft = Draft(
accountId = accountId,
to = toAddresses,
cc = ccAddresses,
bcc = bccAddresses,
subject = subject,
body = body,
updatedAt = updatedAt,
bodyHtml = bodyHtml,
attachments = attachments.toOutgoingAttachments(),
)
@@ -170,10 +172,12 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity(
accountId = accountId,
toAddresses = to,
ccAddresses = cc,
bccAddresses = bcc,
subject = subject,
body = body,
updatedAt = updatedAt,
attachments = attachments.toJson(),
bodyHtml = bodyHtml,
)
/** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */
@@ -202,6 +206,7 @@ internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage(
body = body,
createdAt = createdAt,
lastError = lastError,
bodyHtml = bodyHtml,
)
private fun String.toMailSecurity(): MailSecurity = runCatching {
@@ -171,3 +171,48 @@ val MIGRATION_8_9 = object : Migration(8, 9) {
)
}
}
/**
* v9 -> v10: bcc support for outgoing mail (preserves existing data). Adds a `bccAddresses` column
* to `outbox` and `drafts` so a `mailto:`-launched (or manually addressed) blind-copy recipient
* survives being queued and saved. The `DEFAULT ''` matches the entities' `@ColumnInfo(defaultValue)`
* so the fresh-install schema validates identically to the migrated one (the MIGRATION_7_8 pattern).
*/
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''")
}
}
/**
* v10 -> v11: rich composition (preserves existing data).
* - `drafts`/`outbox`: add a nullable `bodyHtml` column carrying the HTML form of the body when a
* message was composed with formatting (null = plaintext-only, sent/kept exactly as before).
* - add the `signatures` table (multiple named signatures per account, one default), with a
* cascading foreign key to `accounts`, and backfill each account's existing per-account settings
* signature as its default signature so nobody loses one on upgrade.
*/
val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bodyHtml` TEXT")
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bodyHtml` TEXT")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `signatures` (" +
"`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
"`contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), " +
"FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE)",
)
db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `signatures` (`accountId`)")
// Preserve any existing plain-text per-account signature as that account's default signature.
// Newlines become <br> so the HTML keeps the original line breaks; other characters are rare
// in signatures and pass through unescaped.
db.execSQL(
"INSERT INTO `signatures` (`id`, `accountId`, `name`, `contentHtml`, `isDefault`) " +
"SELECT `accountId` || ':default-signature', `accountId`, 'Signature', " +
"replace(`signature`, char(10), '<br>'), 1 " +
"FROM `account_settings` WHERE `signature` <> ''",
)
}
}
@@ -0,0 +1,47 @@
// 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.SignatureEntity
@Dao
interface SignatureDao {
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY isDefault DESC, name COLLATE NOCASE")
fun observeForAccount(accountId: String): Flow<List<SignatureEntity>>
@Query("SELECT * FROM signatures WHERE id = :id LIMIT 1")
suspend fun getById(id: String): SignatureEntity?
@Query("SELECT * FROM signatures WHERE accountId = :accountId AND isDefault = 1 LIMIT 1")
suspend fun getDefault(accountId: String): SignatureEntity?
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY name COLLATE NOCASE LIMIT 1")
suspend fun firstForAccount(accountId: String): SignatureEntity?
@Query("SELECT COUNT(*) FROM signatures WHERE accountId = :accountId")
suspend fun countForAccount(accountId: String): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(signature: SignatureEntity)
@Query("DELETE FROM signatures WHERE id = :id")
suspend fun delete(id: String)
@Query("UPDATE signatures SET isDefault = 0 WHERE accountId = :accountId")
suspend fun clearDefault(accountId: String)
@Query("UPDATE signatures SET isDefault = 1 WHERE id = :id")
suspend fun markDefault(id: String)
/** Makes [id] the account's sole default in one transaction (clears the others first). */
@Transaction
suspend fun setDefault(accountId: String, id: String) {
clearDefault(accountId)
markDefault(id)
}
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -11,9 +12,12 @@ data class DraftEntity(
val accountId: String?,
val toAddresses: String,
val ccAddresses: String,
@ColumnInfo(defaultValue = "") val bccAddresses: 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 = "",
/** HTML form of [body] when the draft carries formatting; null for plaintext drafts. */
val bodyHtml: String? = null,
)
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -11,8 +12,11 @@ data class OutboxEntity(
val accountId: String,
val toAddresses: String,
val ccAddresses: String,
@ColumnInfo(defaultValue = "") val bccAddresses: String = "",
val subject: String,
val body: String,
val createdAt: Long,
val lastError: String? = null,
/** HTML form of [body] when composed with formatting; null sends `text/plain` only. */
val bodyHtml: String? = null,
)
@@ -0,0 +1,32 @@
// 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
import androidx.room.PrimaryKey
/**
* One saved signature. The [accountId] foreign key cascades on delete, so removing an account drops
* its signatures. [isDefault] marks the one auto-inserted when composing from the account; the
* repository keeps at most one default per account.
*/
@Entity(
tableName = "signatures",
foreignKeys = [
ForeignKey(
entity = AccountEntity::class,
parentColumns = ["id"],
childColumns = ["accountId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("accountId")],
)
data class SignatureEntity(
@PrimaryKey val id: String,
val accountId: String,
val name: String,
val contentHtml: String,
val isDefault: Boolean = false,
)
@@ -8,6 +8,7 @@ import jakarta.mail.Flags
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.data.ReplyBuilder
import org.libremail.data.SignatureBlock
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.DraftDao
@@ -20,6 +21,7 @@ import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SignatureRepository
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.data.sync.SendScheduler
import org.libremail.domain.model.Attachment
@@ -52,6 +54,7 @@ class MailRepositoryImpl @Inject constructor(
private val connectionFactory: MailConnectionFactory,
private val sendScheduler: SendScheduler,
private val accountSettingsRepository: AccountSettingsRepository,
private val signatureRepository: SignatureRepository,
) : MailRepository {
override fun observeMessages(): Flow<List<Message>> = messageDao.observeAll().map { rows ->
@@ -191,9 +194,15 @@ class MailRepositoryImpl @Inject constructor(
val params = connectionFactory.imapParamsFor(account)
val context = imapClient.fetchForReply(params, entity.folder, uidOf(messageId))
val content = ReplyBuilder.build(context, mode, account.email)
// Bake the sending account's signature into the reply/forward body so it round-trips as part
// of the draft (compose won't re-append for drafts).
val signature = accountSettingsRepository.get(entity.accountId).signatureBlock()
// Bake the sending account's default signature into the reply/forward body — above the quoted
// original — so it round-trips as part of the draft (compose won't re-append for drafts). Both
// the plaintext and HTML forms are stored so the reply can go out as multipart/alternative.
val settings = accountSettingsRepository.get(entity.accountId)
val sig = if (settings.signatureEnabled) {
SignatureBlock.of(signatureRepository.getDefault(entity.accountId))
} else {
SignatureBlock.EMPTY
}
val draftId = UUID.randomUUID().toString()
saveDraft(
Draft(
@@ -202,8 +211,9 @@ class MailRepositoryImpl @Inject constructor(
to = content.to,
cc = content.cc,
subject = content.subject,
body = content.body + signature,
body = sig.plain + content.body,
updatedAt = System.currentTimeMillis(),
bodyHtml = sig.html + content.bodyHtml,
attachments = emptyList(),
),
)
@@ -266,9 +276,11 @@ class MailRepositoryImpl @Inject constructor(
accountId = outgoing.accountId,
toAddresses = outgoing.to,
ccAddresses = outgoing.cc,
bccAddresses = outgoing.bcc,
subject = outgoing.subject,
body = outgoing.body,
createdAt = System.currentTimeMillis(),
bodyHtml = outgoing.bodyHtml,
),
)
sendScheduler.sendNow()
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.settings
import android.app.backup.BackupManager
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
@@ -15,7 +16,15 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "libremail_settings")
/**
* User-preferences DataStore. Exposed as `internal` (not `private`) and read via [toAppSettings] so
* that [org.libremail.backup.LibreMailBackupAgent] can consult the backup opt-in flag through the
* exact same singleton instance. The system instantiates the backup agent in the app process while
* the app may already hold this DataStore open; constructing a second DataStore for the same file
* would crash with "There are multiple DataStores active for the same file", so both sides must go
* through this one delegate.
*/
internal val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "libremail_settings")
/**
* How aggressively the app downloads message content during sync.
@@ -34,24 +43,43 @@ data class AppSettings(
val loadRemoteImages: Boolean = false,
val encryptCache: Boolean = false,
val appLock: Boolean = false,
val includeInBackup: Boolean = false,
val fetchPolicy: FetchPolicy = FetchPolicy.ALWAYS,
)
private object Keys {
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")
val APP_LOCK = booleanPreferencesKey("app_lock")
val INCLUDE_IN_BACKUP = booleanPreferencesKey("include_in_backup")
val FETCH_POLICY = stringPreferencesKey("fetch_policy")
val BATTERY_PROMPT_HANDLED = booleanPreferencesKey("battery_prompt_handled")
}
/**
* Maps persisted preferences to [AppSettings]. Shared with the backup agent so it reads the opt-in
* flag (and its default) through exactly the same logic the app uses.
*/
internal fun Preferences.toAppSettings(): AppSettings = AppSettings(
dynamicColor = this[Keys.DYNAMIC_COLOR] ?: true,
newMailNotifications = this[Keys.NEW_MAIL_NOTIFICATIONS] ?: true,
pushIdle = this[Keys.PUSH_IDLE] ?: true,
allowStartTls = this[Keys.ALLOW_STARTTLS] ?: false,
loadRemoteImages = this[Keys.LOAD_REMOTE_IMAGES] ?: false,
encryptCache = this[Keys.ENCRYPT_CACHE] ?: false,
appLock = this[Keys.APP_LOCK] ?: false,
includeInBackup = this[Keys.INCLUDE_IN_BACKUP] ?: false,
fetchPolicy = this[Keys.FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
?: FetchPolicy.ALWAYS,
)
@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,
appLock = prefs[APP_LOCK] ?: false,
fetchPolicy = prefs[FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
?: FetchPolicy.ALWAYS,
)
}
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { it.toAppSettings() }
val dynamicColor: Flow<Boolean> = settings.map { it.dynamicColor }
@@ -59,29 +87,41 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
suspend fun fetchPolicy(): FetchPolicy = settings.first().fetchPolicy
suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value)
suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value)
suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value)
suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value)
suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value)
suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, value)
suspend fun setAppLock(value: Boolean) = put(APP_LOCK, value)
/**
* One-time onboarding flag: whether the user has already seen/acted on the "unrestricted battery"
* opt-in, so onboarding asks at most once (see #49). Not part of [AppSettings] — it is internal
* onboarding state, not a user-facing preference. It rides along in Android Backup (the whole
* settings DataStore is one file); a restore therefore may skip the prompt on a device that isn't
* yet allowlisted — the Advanced Settings battery row is the recovery path there.
*/
suspend fun isBatteryPromptHandled(): Boolean =
context.settingsDataStore.data.map { it[Keys.BATTERY_PROMPT_HANDLED] ?: false }.first()
suspend fun setBatteryPromptHandled(value: Boolean) = put(Keys.BATTERY_PROMPT_HANDLED, value)
suspend fun setDynamicColor(value: Boolean) = put(Keys.DYNAMIC_COLOR, value)
suspend fun setNewMailNotifications(value: Boolean) = put(Keys.NEW_MAIL_NOTIFICATIONS, value)
suspend fun setPushIdle(value: Boolean) = put(Keys.PUSH_IDLE, value)
suspend fun setAllowStartTls(value: Boolean) = put(Keys.ALLOW_STARTTLS, value)
suspend fun setLoadRemoteImages(value: Boolean) = put(Keys.LOAD_REMOTE_IMAGES, value)
suspend fun setEncryptCache(value: Boolean) = put(Keys.ENCRYPT_CACHE, value)
suspend fun setAppLock(value: Boolean) = put(Keys.APP_LOCK, value)
/**
* Opts this app in/out of system Android Backup. Off by default. After persisting, nudges the
* framework so the change takes effect on the next backup pass — enabling schedules a backup of
* the safe settings, disabling schedules one that ships nothing (clearing any prior cloud copy).
*/
suspend fun setIncludeInBackup(value: Boolean) {
put(Keys.INCLUDE_IN_BACKUP, value)
runCatching { BackupManager(context).dataChanged() }
}
suspend fun setFetchPolicy(value: FetchPolicy) {
context.settingsDataStore.edit { it[FETCH_POLICY] = value.name }
context.settingsDataStore.edit { it[Keys.FETCH_POLICY] = value.name }
}
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")
val APP_LOCK = booleanPreferencesKey("app_lock")
val FETCH_POLICY = stringPreferencesKey("fetch_policy")
}
}
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.settings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.data.local.dao.SignatureDao
import org.libremail.data.local.entity.SignatureEntity
import org.libremail.domain.model.Signature
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* CRUD for per-account signatures, keeping the "at most one default per account" invariant. The
* first signature created for an account becomes its default automatically; deleting the default
* promotes another (if any) so an account with signatures always has one to auto-insert.
*/
@Singleton
class SignatureRepository @Inject constructor(private val dao: SignatureDao) {
fun observeForAccount(accountId: String): Flow<List<Signature>> =
dao.observeForAccount(accountId).map { rows -> rows.map { it.toDomain() } }
suspend fun get(id: String): Signature? = dao.getById(id)?.toDomain()
suspend fun getDefault(accountId: String): Signature? = dao.getDefault(accountId)?.toDomain()
/** Creates a signature; makes it the default when it is the account's first. Returns its id. */
suspend fun create(accountId: String, name: String, html: String): String {
val id = UUID.randomUUID().toString()
val isFirst = dao.countForAccount(accountId) == 0
dao.upsert(SignatureEntity(id, accountId, name, html, isDefault = isFirst))
return id
}
suspend fun update(id: String, name: String, html: String) {
val existing = dao.getById(id) ?: return
dao.upsert(existing.copy(name = name, contentHtml = html))
}
suspend fun delete(id: String) {
val existing = dao.getById(id) ?: return
dao.delete(id)
// If we removed the default, promote the account's first remaining signature.
if (existing.isDefault) {
dao.firstForAccount(existing.accountId)?.let { dao.markDefault(it.id) }
}
}
suspend fun setDefault(accountId: String, id: String) = dao.setDefault(accountId, id)
private fun SignatureEntity.toDomain() = Signature(id, accountId, name, contentHtml, isDefault)
}
@@ -5,7 +5,6 @@ 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
@@ -23,7 +22,6 @@ import javax.inject.Singleton
@Singleton
class MailConnectionFactory @Inject constructor(
private val credentialStore: CredentialStore,
private val gmailAuthManager: GmailAuthManager,
private val outlookAuthManager: OutlookAuthManager,
private val settingsRepository: SettingsRepository,
) {
@@ -54,8 +52,6 @@ class MailConnectionFactory @Inject constructor(
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)
}
@@ -91,7 +87,6 @@ class MailConnectionFactory @Inject constructor(
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
@@ -54,8 +54,10 @@ class SendWorker @AssistedInject constructor(
accountId = entity.accountId,
to = entity.toAddresses,
cc = entity.ccAddresses,
bcc = entity.bccAddresses,
subject = entity.subject,
body = entity.body,
bodyHtml = entity.bodyHtml,
)
val files = orderedAttachments(attachmentDir)
if (account.authType == AuthType.OAUTH_OUTLOOK) {
@@ -14,6 +14,7 @@ import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import org.libremail.data.local.DatabaseEncryption
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.MIGRATION_10_11
import org.libremail.data.local.MIGRATION_1_2
import org.libremail.data.local.MIGRATION_2_3
import org.libremail.data.local.MIGRATION_3_4
@@ -22,6 +23,7 @@ import org.libremail.data.local.MIGRATION_5_6
import org.libremail.data.local.MIGRATION_6_7
import org.libremail.data.local.MIGRATION_7_8
import org.libremail.data.local.MIGRATION_8_9
import org.libremail.data.local.MIGRATION_9_10
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AccountSettingsDao
import org.libremail.data.local.dao.AttachmentDao
@@ -30,6 +32,7 @@ import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.dao.SignatureDao
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.security.PassphraseSession
import org.libremail.data.settings.SettingsRepository
@@ -57,6 +60,8 @@ object DatabaseModule {
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
)
// No destructive fallback: the migration chain is complete, and silently dropping the
// accounts/credentials/mail tables would lose stored secrets. A missing migration should
@@ -126,5 +131,8 @@ object DatabaseModule {
@Provides
fun provideFolderDao(database: LibreMailDatabase): FolderDao = database.folderDao()
@Provides
fun provideSignatureDao(database: LibreMailDatabase): SignatureDao = database.signatureDao()
private const val DB_NAME = DatabaseFiles.NAME
}
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.libremail.reporting.ReportStore
import java.io.File
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ReportingModule {
@Provides
@Singleton
fun provideReportStore(@ApplicationContext context: Context): ReportStore =
ReportStore(File(context.filesDir, "debug_reports"))
}
@@ -3,9 +3,6 @@ package org.libremail.domain.model
/** How an account authenticates with its mail server. */
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,
@@ -6,8 +6,11 @@ data class Draft(
val accountId: String?,
val to: String,
val cc: String,
val bcc: String = "",
val subject: String,
val body: String,
val updatedAt: Long,
/** HTML form of [body] when the draft was composed with formatting; null for plaintext drafts. */
val bodyHtml: String? = null,
val attachments: List<OutgoingAttachment> = emptyList(),
)
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
// Standard mail submission ports, named so the presets below don't read as "magic numbers".
// File-level (not in the companion) so the enum entries can reference them during construction.
private const val IMAPS_PORT = 993
private const val SMTP_SUBMISSION_PORT = 587
private const val SMTPS_PORT = 465
/**
* Preconfigured IMAP/SMTP presets for the app-password vendors (Gmail, Yahoo, iCloud).
*
* These mirror [Account.outlook]: each entry knows its servers so onboarding only has to collect an
* email + app password (see the app-password setup screen). Outlook is intentionally NOT here — it
* uses interactive OAuth, not an app password.
*
* Port / security rationale (verified against current vendor docs):
* - IMAP is implicit TLS on 993 for all three: none of these vendors document a STARTTLS IMAP
* endpoint, so 993/[MailSecurity.SSL_TLS] is the only correct choice.
* - SMTP biases toward STARTTLS on 587 where the vendor documents it (Gmail, iCloud), matching the
* epic's "prefer STARTTLS where supported" guidance. Yahoo documents implicit TLS on 465 as its
* outgoing server, so it keeps 465/[MailSecurity.SSL_TLS].
* - [MailSecurity.NONE] is never used — every path here is encrypted end to end.
*/
enum class MailProvider(
/** Stable lowercase key used as a navigation argument and to look a provider back up. */
val key: String,
/** Brand name shown in the picker and setup screen (a proper noun, not localized). */
val displayName: String,
/** The page where the user creates an app password for this provider. */
val appPasswordHelpUrl: String,
private val imapHost: String,
private val smtpHost: String,
private val smtpPort: Int,
private val smtpSecurity: MailSecurity,
) {
GMAIL(
key = "gmail",
displayName = "Gmail",
appPasswordHelpUrl = "https://myaccount.google.com/apppasswords",
imapHost = "imap.gmail.com",
smtpHost = "smtp.gmail.com",
// Google documents smtp.gmail.com:587 with STARTTLS as the standard submission endpoint.
smtpPort = SMTP_SUBMISSION_PORT,
smtpSecurity = MailSecurity.STARTTLS,
),
YAHOO(
key = "yahoo",
displayName = "Yahoo Mail",
appPasswordHelpUrl = "https://login.yahoo.com/account/security",
imapHost = "imap.mail.yahoo.com",
smtpHost = "smtp.mail.yahoo.com",
// Yahoo documents smtp.mail.yahoo.com:465 with implicit SSL/TLS as its outgoing server.
smtpPort = SMTPS_PORT,
smtpSecurity = MailSecurity.SSL_TLS,
),
ICLOUD(
key = "icloud",
displayName = "iCloud Mail",
appPasswordHelpUrl = "https://appleid.apple.com",
imapHost = "imap.mail.me.com",
smtpHost = "smtp.mail.me.com",
// Apple documents smtp.mail.me.com:587 with STARTTLS for iCloud Mail.
smtpPort = SMTP_SUBMISSION_PORT,
smtpSecurity = MailSecurity.STARTTLS,
),
;
/**
* Builds a [PASSWORD_IMAP][AuthType.PASSWORD_IMAP] [Account] for this provider. The caller
* supplies the address; the servers come from the preset. The id mirrors the manual-setup
* convention (`imap:<email>`) so app-password and manual accounts share one identity scheme.
*/
fun createAccount(email: String, displayName: String = email): Account {
val address = email.trim()
return Account(
id = "imap:$address",
email = address,
displayName = displayName.trim().ifBlank { address },
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig(imapHost, IMAPS_PORT, MailSecurity.SSL_TLS),
smtp = ServerConfig(smtpHost, smtpPort, smtpSecurity),
)
}
companion object {
/** Resolves a provider by its [key], or null if none matches (case-insensitive). */
fun fromKey(key: String): MailProvider? = entries.firstOrNull { it.key.equals(key, ignoreCase = true) }
}
}
@@ -9,4 +9,6 @@ data class OutboxMessage(
val body: String,
val createdAt: Long,
val lastError: String?,
/** HTML form of [body] when composed with formatting; null for plaintext messages. */
val bodyHtml: String? = null,
)
@@ -1,13 +1,21 @@
// 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. */
/**
* A message the user is sending. [to]/[cc]/[bcc] are comma-separated address lists.
*
* [body] is always the plain-text form. [bodyHtml] carries the HTML form when the message was
* composed with formatting; when it is null the message is sent as `text/plain` only (unchanged
* from the plaintext-only path), otherwise as `multipart/alternative` with both parts.
*/
data class OutgoingMessage(
val accountId: String,
val to: String,
val cc: String = "",
val bcc: String = "",
val subject: String,
val body: String,
val bodyHtml: String? = null,
val attachments: List<OutgoingAttachment> = emptyList(),
)
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
import org.libremail.mail.HtmlToText
/**
* A named, rich (HTML) email signature belonging to an account. Each account may have several; at
* most one is its [isDefault], which is the one auto-inserted when composing from that account.
*/
data class Signature(
val id: String,
val accountId: String,
val name: String,
/** The signature body as HTML (authored in the same rich editor as the message body). */
val html: String,
val isDefault: Boolean = false,
) {
/** The signature as plain text, for the `text/plain` alternative and plaintext-only composing. */
fun plainText(): String = HtmlToText.convert(html)
}
@@ -84,13 +84,23 @@ class GraphSender @Inject constructor() {
/** 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 {
// Graph carries a single body object: send HTML when the message was formatted (Outlook renders
// it and derives its own plaintext), otherwise plain text so unformatted mail is unchanged.
val body = if (message.bodyHtml != null) {
JSONObject().put("contentType", "HTML").put("content", message.bodyHtml)
} else {
JSONObject().put("contentType", "Text").put("content", message.body)
}
val mail = JSONObject()
.put("subject", message.subject)
.put("body", JSONObject().put("contentType", "Text").put("content", message.body))
.put("body", body)
.put("toRecipients", recipientsJson(message.to))
if (message.cc.isNotBlank()) {
mail.put("ccRecipients", recipientsJson(message.cc))
}
if (message.bcc.isNotBlank()) {
mail.put("bccRecipients", recipientsJson(message.bcc))
}
if (attachments.isNotEmpty()) {
val items = JSONArray()
attachments.forEach { file ->
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
/**
* Converts an HTML body into readable plain text for the `text/plain` alternative part and for
* quoting HTML originals in replies/forwards. Pure (no Android/Compose), so it is unit-testable on
* the JVM.
*
* It is intentionally lightweight — block elements become line breaks, list items gain a "• "
* marker, tags are dropped and entities decoded — rather than a full HTML renderer. The goal is a
* legible fallback, not a faithful reproduction.
*/
object HtmlToText {
private val BLOCK_BREAK = Regex(
"(?i)</?(p|div|tr|table|ul|ol|h[1-6]|blockquote)\\b[^>]*>|<br\\s*/?>",
)
private val LIST_ITEM = Regex("(?i)<li\\b[^>]*>")
fun convert(html: String): String {
var s = html
// Drop script/style contents outright so their text never leaks into the output.
s = s.replace(Regex("(?is)<(script|style)\\b[^>]*>.*?</\\1>"), "")
s = LIST_ITEM.replace(s, "\n• ")
s = BLOCK_BREAK.replace(s, "\n")
s = s.replace(Regex("<[^>]*>"), "")
s = decodeEntities(s)
// Collapse runs of spaces/tabs, then trim trailing spaces and cap consecutive blank lines.
s = s.replace(Regex("[ \\t]+"), " ")
s = s.lineSequence().joinToString("\n") { it.trim() }
s = s.replace(Regex("\n{3,}"), "\n\n")
return s.trim()
}
private fun decodeEntities(s: String): String = s
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&mdash;", "—")
.replace("&ndash;", "–")
.replace("&amp;", "&")
}
@@ -7,6 +7,7 @@ import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeBodyPart
import jakarta.mail.internet.MimeMessage
import jakarta.mail.internet.MimeMultipart
import jakarta.mail.internet.MimePart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.libremail.domain.model.MailSecurity
@@ -59,19 +60,13 @@ class SmtpSender @Inject constructor() {
if (message.cc.isNotBlank()) {
setRecipients(Message.RecipientType.CC, InternetAddress.parse(message.cc))
}
if (message.bcc.isNotBlank()) {
setRecipients(Message.RecipientType.BCC, InternetAddress.parse(message.bcc))
}
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)
}
applyBody(mime, message, attachments)
val transport = session.getTransport(protocol)
transport.connect(params.host, params.port, params.username, params.secret)
@@ -82,6 +77,40 @@ class SmtpSender @Inject constructor() {
}
}
/**
* Sets the message body:
* - plaintext-only ([OutgoingMessage.bodyHtml] null): a single `text/plain` part, exactly as
* before, so unformatted mail is unchanged on the wire;
* - formatted: a `multipart/alternative` of `text/plain` (fallback, first) + `text/html`
* (preferred, last, per RFC 2046).
* When there are attachments the body is nested inside a `multipart/mixed` as its first part.
*/
private fun applyBody(mime: MimeMessage, message: OutgoingMessage, attachments: List<File>) {
if (attachments.isEmpty()) {
setBody(mime, message)
return
}
val mixed = MimeMultipart("mixed")
mixed.addBodyPart(MimeBodyPart().also { setBody(it, message) })
attachments.forEach { file -> mixed.addBodyPart(MimeBodyPart().apply { attachFile(file) }) }
mime.setContent(mixed)
}
/** Writes the body onto [part]: plain text, or a text/plain + text/html alternative when formatted. */
private fun setBody(part: MimePart, message: OutgoingMessage) {
val html = message.bodyHtml
if (html == null) {
part.setText(message.body, "UTF-8")
} else {
part.setContent(alternative(message.body, html))
}
}
private fun alternative(plain: String, html: String): MimeMultipart = MimeMultipart("alternative").apply {
addBodyPart(MimeBodyPart().apply { setText(plain, "UTF-8") })
addBodyPart(MimeBodyPart().apply { setContent(html, "text/html; charset=UTF-8") })
}
private companion object {
const val TIMEOUT_MS = "15000"
}
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads and deep-links this app's system battery-optimization state, so the user can move LibreMail
* to "Unrestricted" and keep [IdleService]'s push connection (and periodic sync) from being throttled
* or torn down by Doze.
*
* We deliberately do NOT use the restricted `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission or its
* one-tap dialog: Google Play limits that permission to an approved set of use cases (rejection risk,
* see #17). Sending the user to the system screen instead needs no extra permission and is safe on
* both Play and F-Droid (#16).
*
* Note [isIgnoringBatteryOptimizations] reflects the Doze allowlist: it is `true` only for the
* "Unrestricted" setting and `false` for *both* "Optimized" and "Restricted", so it cannot single out
* the (most harmful) "Restricted" state on its own.
*/
@Singleton
class BatteryOptimizationManager @Inject constructor(@ApplicationContext private val context: Context) {
/**
* Whether the platform exposes battery-optimization control. The Doze allowlist has existed since
* API 23, so at our minSdk (29) this is always true; the version check keeps [BatteryPromptDecision]
* honest if the floor ever drops below API 23.
*/
val isSupported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
/** True when this app is currently exempt from battery optimization ("Unrestricted"). */
fun isIgnoringBatteryOptimizations(): Boolean {
val powerManager = context.getSystemService(PowerManager::class.java) ?: return false
return powerManager.isIgnoringBatteryOptimizations(context.packageName)
}
/**
* Intent to this app's system details screen, where the user can open **Battery** and choose
* **Unrestricted**. App-details is targeted (rather than the flat battery-optimization list)
* because it is the only route to the Unrestricted/Optimized/Restricted setting and lands
* directly on LibreMail. Always resolvable since API 9.
*/
fun settingsIntent(): Intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
}
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
/**
* Pure decision for whether onboarding should surface the battery opt-in step. Deliberately free of
* Android types so it is exhaustively unit-testable; the live inputs are read by
* [BatteryOptimizationManager] and [org.libremail.data.settings.SettingsRepository].
*/
object BatteryPromptDecision {
/**
* Prompt only when all three hold:
* - [supported]: the platform exposes battery-optimization control (the Doze allowlist is API 23+,
* so this is always true at our minSdk — kept explicit so the rule stays correct if the floor
* ever drops).
* - not [alreadyUnrestricted]: an app already exempt from battery optimization gains nothing.
* - not [alreadyHandled]: the user has already seen and acted on (or dismissed) the prompt, so we
* don't nag on a later onboarding run.
*/
fun shouldPrompt(supported: Boolean, alreadyUnrestricted: Boolean, alreadyHandled: Boolean): Boolean =
supported && !alreadyUnrestricted && !alreadyHandled
}
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import android.util.Log
/**
* Logging facade that mirrors Logcat output into the process [RingLogBuffer] so recent activity can
* be attached to a user-reviewed [DebugReport]. Call [install] once at startup. Never pass PII
* (email addresses, message content, credentials) to these methods — the buffer can end up in a
* report the user reviews and may submit.
*/
object AppLog {
@Volatile
private var buffer: RingLogBuffer? = null
fun install(buffer: RingLogBuffer) {
this.buffer = buffer
}
fun d(tag: String, message: String) {
Log.d(tag, message)
buffer?.record('D', tag, message)
}
fun i(tag: String, message: String) {
Log.i(tag, message)
buffer?.record('I', tag, message)
}
fun w(tag: String, message: String) {
Log.w(tag, message)
buffer?.record('W', tag, message)
}
fun e(tag: String, message: String, throwable: Throwable? = null) {
Log.e(tag, message, throwable)
buffer?.record('E', tag, message)
}
}
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/** Resolves the app's version once, tolerating any lookup failure. */
@Singleton
class AppVersionProvider @Inject constructor(@ApplicationContext context: Context) {
val versionName: String
val versionCode: Long
init {
var name = UNKNOWN
var code = 0L
runCatching {
@Suppress("DEPRECATION") // getPackageInfo(String, int) is fine for a plain version lookup.
val info = context.packageManager.getPackageInfo(context.packageName, 0)
name = info.versionName ?: UNKNOWN
code = info.longVersionCode
}
versionName = name
versionCode = code
}
private companion object {
const val UNKNOWN = "unknown"
}
}
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import android.os.Process
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.system.exitProcess
/**
* Installs a process-wide uncaught-exception handler that persists a crash [DebugReport] locally for
* review on next launch. Reports are NEVER sent automatically — submission is strictly user-initiated
* (see [ReportSubmitter]). The previous handler is always invoked so the system still shows the crash
* and terminates the process.
*/
@Singleton
class CrashReporter @Inject constructor(
private val collector: DiagnosticsCollector,
private val store: ReportStore,
private val logBuffer: RingLogBuffer,
) {
fun install() {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
persist(throwable)
if (previous != null) {
previous.uncaughtException(thread, throwable)
} else {
terminate()
}
}
}
/**
* Saves a crash report. Wrapped so a failure here can never mask the original crash. Exposed for
* tests; installing the handler simply routes uncaught exceptions here.
*/
fun persist(throwable: Throwable) {
runCatching {
logBuffer.record('E', TAG, "Uncaught exception: ${throwable.javaClass.name}")
store.save(collector.collectCrash(throwable))
}
}
private fun terminate() {
Process.killProcess(Process.myPid())
exitProcess(EXIT_FAILURE)
}
private companion object {
const val TAG = "CrashReporter"
const val EXIT_FAILURE = 10
}
}
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import org.json.JSONArray
import org.json.JSONObject
import java.time.Instant
/** Whether a report was produced by an uncaught crash or requested manually by the user. */
enum class ReportKind { CRASH, MANUAL }
/**
* A locally-stored diagnostic report. It never leaves the device unless the user explicitly submits
* it (see [ReportSubmitter]); [toSubmissionPayload] is the single rendering that is shown for
* review, copied, saved to a file, and POSTed on submit, so what the user reads is exactly what is
* sent. Only the fields assembled by `DiagnosticsCollector` are captured — no message content.
*/
@Suppress("LongParameterList") // A flat diagnostic DTO; grouping fields would only obscure the payload.
data class DebugReport(
val id: String,
val createdAtMillis: Long,
val kind: ReportKind,
val appVersionName: String,
val appVersionCode: Long,
val androidRelease: String,
val androidSdkInt: Int,
val deviceManufacturer: String,
val deviceModel: String,
val stackTrace: String?,
val settings: Map<String, String>,
val logs: List<String>,
val userComment: String = "",
) {
/** The exact text shown for review, copied, saved to a file, and POSTed on submit. */
fun toSubmissionPayload(): String = toJson().toString(JSON_INDENT)
/** Compact form used for on-disk persistence. */
fun toStorageJson(): String = toJson().toString()
private fun toJson(): JSONObject {
val app = JSONObject()
.put("versionName", appVersionName)
.put("versionCode", appVersionCode)
val device = JSONObject()
.put("manufacturer", deviceManufacturer)
.put("model", deviceModel)
.put("androidRelease", androidRelease)
.put("sdkInt", androidSdkInt)
val settingsJson = JSONObject()
settings.forEach { (key, value) -> settingsJson.put(key, value) }
val json = JSONObject()
.put("id", id)
.put("createdAt", Instant.ofEpochMilli(createdAtMillis).toString())
.put("createdAtMillis", createdAtMillis)
.put("kind", kind.name)
.put("app", app)
.put("device", device)
.put("userComment", userComment)
.put("settings", settingsJson)
.put("logs", JSONArray(logs))
if (stackTrace != null) json.put("stackTrace", stackTrace)
return json
}
companion object {
private const val JSON_INDENT = 2
fun fromStorageJson(raw: String): DebugReport {
val json = JSONObject(raw)
val app = json.getJSONObject("app")
val device = json.getJSONObject("device")
val settingsJson = json.getJSONObject("settings")
val settings = LinkedHashMap<String, String>()
settingsJson.keys().forEach { key -> settings[key] = settingsJson.getString(key) }
val logsJson = json.getJSONArray("logs")
val logs = ArrayList<String>(logsJson.length())
for (i in 0 until logsJson.length()) logs.add(logsJson.getString(i))
return DebugReport(
id = json.getString("id"),
createdAtMillis = json.getLong("createdAtMillis"),
kind = ReportKind.valueOf(json.getString("kind")),
appVersionName = app.getString("versionName"),
appVersionCode = app.getLong("versionCode"),
androidRelease = device.getString("androidRelease"),
androidSdkInt = device.getInt("sdkInt"),
deviceManufacturer = device.getString("manufacturer"),
deviceModel = device.getString("model"),
stackTrace = if (json.has("stackTrace")) json.getString("stackTrace") else null,
settings = settings,
logs = logs,
userComment = json.optString("userComment", ""),
)
}
}
}
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import android.os.Build
import kotlinx.coroutines.flow.first
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Assembles a [DebugReport] from app/device metadata, a stack trace (for crashes), a minimal
* non-PII settings summary, and the recent in-app log buffer. Only the fields listed in [summarize]
* are captured — deliberately no account emails, server names, or message content.
*/
@Singleton
class DiagnosticsCollector @Inject constructor(
private val appVersion: AppVersionProvider,
private val settingsRepository: SettingsRepository,
private val logBuffer: RingLogBuffer,
) {
// Cached so a crash report (built synchronously on the crashing thread) can still include
// settings without touching DataStore. Refreshed whenever settings are read for a manual
// report or explicitly warmed at startup.
@Volatile
private var cachedSettings: Map<String, String> = emptyMap()
/** Pre-reads settings so a later crash report can include them. Safe to call and ignore. */
suspend fun warmSettingsCache() {
cachedSettings = summarize(settingsRepository.settings.first())
}
/** Builds a report for a user-initiated ("Report a problem") request; includes live settings. */
suspend fun collectManual(): DebugReport {
val settings = summarize(settingsRepository.settings.first())
cachedSettings = settings
return build(ReportKind.MANUAL, throwable = null, settings = settings)
}
/** Builds a crash report synchronously; it must not block or throw on the crashing thread. */
fun collectCrash(throwable: Throwable): DebugReport =
build(ReportKind.CRASH, throwable = throwable, settings = cachedSettings)
private fun build(kind: ReportKind, throwable: Throwable?, settings: Map<String, String>) = DebugReport(
id = UUID.randomUUID().toString(),
createdAtMillis = System.currentTimeMillis(),
kind = kind,
appVersionName = appVersion.versionName,
appVersionCode = appVersion.versionCode,
androidRelease = Build.VERSION.RELEASE ?: "",
androidSdkInt = Build.VERSION.SDK_INT,
deviceManufacturer = Build.MANUFACTURER ?: "",
deviceModel = Build.MODEL ?: "",
stackTrace = throwable?.stackTraceToString(),
settings = settings,
logs = logBuffer.snapshot().map { it.formatted() },
)
private fun summarize(settings: AppSettings): Map<String, String> = linkedMapOf(
"dynamicColor" to settings.dynamicColor.toString(),
"newMailNotifications" to settings.newMailNotifications.toString(),
"pushIdle" to settings.pushIdle.toString(),
"allowStartTls" to settings.allowStartTls.toString(),
"loadRemoteImages" to settings.loadRemoteImages.toString(),
"encryptCache" to settings.encryptCache.toString(),
"fetchPolicy" to settings.fetchPolicy.name,
)
}
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
/**
* File-backed store of pending [DebugReport]s — one JSON file per report under [directory].
* Deliberately NOT Room-backed: a crash-time save must be simple and robust, independent of the
* (possibly encrypted, possibly mid-migration) app database. Reports persist until the user submits
* or discards them. [reports] is a snapshot that updates on every [save]/[delete].
*/
class ReportStore(private val directory: File) {
private val lock = Any()
private val _reports = MutableStateFlow(scan())
val reports: StateFlow<List<DebugReport>> = _reports.asStateFlow()
fun save(report: DebugReport) {
synchronized(lock) {
directory.mkdirs()
File(directory, fileName(report.id)).writeText(report.toStorageJson())
_reports.value = scan()
}
}
fun find(id: String): DebugReport? = _reports.value.firstOrNull { it.id == id }
fun delete(id: String) {
synchronized(lock) {
File(directory, fileName(id)).delete()
_reports.value = scan()
}
}
private fun scan(): List<DebugReport> {
val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) }
?: return emptyList()
return files
.mapNotNull { file -> runCatching { DebugReport.fromStorageJson(file.readText()) }.getOrNull() }
.sortedByDescending { it.createdAtMillis }
}
private fun fileName(id: String) = "$id$SUFFIX"
private companion object {
const val SUFFIX = ".json"
}
}
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import androidx.work.WorkInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
/** Coarse state of a user-initiated report submission. */
enum class SubmitStatus { IDLE, SUBMITTING, SUCCEEDED, FAILED }
/**
* The single seam through which a report can leave the device, and only when the user taps Submit.
* [isEnabled] is false when no ingest endpoint is configured (the default in this repo), so the UI
* can steer the user to Copy/Save instead of a submission that would only fail.
*/
@Singleton
class ReportSubmitter @Inject constructor(private val scheduler: ReportUploadScheduler) {
val isEnabled: Boolean get() = BuildConfig.DEBUG_REPORT_ENDPOINT.isNotBlank()
fun submit(reportId: String) {
scheduler.enqueue(reportId)
}
fun status(reportId: String): Flow<SubmitStatus> = scheduler.statusFlow(reportId).map(::toStatus)
private fun toStatus(infos: List<WorkInfo>): SubmitStatus {
if (infos.isEmpty()) return SubmitStatus.IDLE
return when {
infos.any { it.state == WorkInfo.State.FAILED || it.state == WorkInfo.State.CANCELLED } ->
SubmitStatus.FAILED
infos.all { it.state == WorkInfo.State.SUCCEEDED } -> SubmitStatus.SUCCEEDED
else -> SubmitStatus.SUBMITTING
}
}
}
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
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.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/** Enqueues a user-initiated report upload as a retrying WorkManager job and observes its state. */
@Singleton
class ReportUploadScheduler @Inject constructor(@ApplicationContext private val context: Context) {
fun enqueue(reportId: String) {
val request = OneTimeWorkRequestBuilder<ReportUploadWorker>()
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
)
.setInputData(workDataOf(ReportUploadWorker.KEY_REPORT_ID to reportId))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.addTag(TAG)
.build()
// REPLACE: a fresh Submit tap starts a clean attempt for this report, overriding any pending
// retry-backoff. Reports are keyed per id, so distinct reports never collide.
WorkManager.getInstance(context)
.enqueueUniqueWork(uniqueName(reportId), ExistingWorkPolicy.REPLACE, request)
}
fun statusFlow(reportId: String): Flow<List<WorkInfo>> =
WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(uniqueName(reportId))
private fun uniqueName(reportId: String) = "$WORK_PREFIX$reportId"
companion object {
const val TAG = "libremail_report_upload"
private const val WORK_PREFIX = "libremail_report_upload_"
private const val BACKOFF_SECONDS = 30L
}
}
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.libremail.BuildConfig
import java.net.HttpURLConnection
import java.net.URL
/**
* Posts a single user-submitted report to the ingest endpoint. It runs ONLY when the user tapped
* Submit (enqueued by [ReportUploadScheduler]); nothing here runs automatically. The endpoint is
* [BuildConfig.DEBUG_REPORT_ENDPOINT] — empty by default, because the ingest server (issue #34) is
* out of scope for this repo, so submissions no-op with a clear failure until an endpoint is set.
*/
@HiltWorker
class ReportUploadWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val store: ReportStore,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val id = inputData.getString(KEY_REPORT_ID) ?: return Result.success()
val report = store.find(id) ?: return Result.success() // discarded before the job ran
val endpoint = BuildConfig.DEBUG_REPORT_ENDPOINT
if (endpoint.isBlank()) return Result.failure() // no ingest server configured in this build
return withContext(Dispatchers.IO) {
runCatching { post(endpoint, report.toSubmissionPayload()) }.fold(
onSuccess = { code -> onResponse(code, id) },
onFailure = { retryOrFail() }, // network error — retry with backoff
)
}
}
private fun onResponse(code: Int, id: String): Result = when {
code in SUCCESS_CODES -> {
store.delete(id) // delivered — drop the local copy
Result.success()
}
code in SERVER_ERROR_CODES -> retryOrFail() // transient server-side failure
else -> Result.failure() // client error (4xx) — retrying won't help
}
private fun retryOrFail(): Result = if (runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
private fun post(endpoint: String, body: String): Int {
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
setRequestProperty("Content-Type", "application/json; charset=utf-8")
}
return try {
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
connection.responseCode
} finally {
connection.disconnect()
}
}
companion object {
const val KEY_REPORT_ID = "report_id"
private val SUCCESS_CODES = 200..299
private val SERVER_ERROR_CODES = 500..599
private const val TIMEOUT_MS = 15_000
private const val MAX_ATTEMPTS = 5
}
}
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/** One captured log line. */
data class LogEntry(val timeMillis: Long, val level: Char, val tag: String, val message: String) {
fun formatted(): String = "${Instant.ofEpochMilli(timeMillis)} $level/$tag: $message"
}
/**
* A small, thread-safe, in-memory ring buffer of recent app log lines. It is held in memory only
* (never written to disk on its own) and copied into a [DebugReport] on request. Callers must never
* record PII (email addresses, message content, credentials) — see [AppLog].
*/
@Singleton
class RingLogBuffer @Inject constructor() {
private val lock = Any()
private val entries = ArrayDeque<LogEntry>()
fun record(level: Char, tag: String, message: String) {
synchronized(lock) {
if (entries.size >= CAPACITY) entries.removeFirst()
entries.addLast(LogEntry(System.currentTimeMillis(), level, tag, message))
}
}
fun snapshot(): List<LogEntry> = synchronized(lock) { entries.toList() }
fun clear() {
synchronized(lock) { entries.clear() }
}
private companion object {
const val CAPACITY = 200
}
}
@@ -0,0 +1,335 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.richtext
/** Inline character styles the compose editor supports. */
enum class RichStyle { BOLD, ITALIC, UNDERLINE }
/** A run of [style] over the half-open range [[start], [end]) of the plain text. */
data class RichSpan(val start: Int, val end: Int, val style: RichStyle)
/** A hyperlink over the half-open range [[start], [end]) pointing at [url]. */
data class RichLink(val start: Int, val end: Int, val url: String)
/**
* The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links].
*
* Block structure (unordered/ordered lists and block quotes) is encoded as recognizable line
* prefixes inside [text] — "• " for bullets, "N. " for numbered items, and "> " for quotes — so
* the editor can stay a single plain-text field (keeping the plaintext-only experience smooth)
* while the model still round-trips cleanly to real HTML and to a readable plaintext fallback.
*/
data class RichTextContent(
val text: String = "",
val spans: List<RichSpan> = emptyList(),
val links: List<RichLink> = emptyList(),
) {
val isBlank: Boolean get() = text.isBlank()
/**
* True when the content carries anything a plaintext field could not represent: inline styling,
* a link, or a block marker. When false, callers should send/persist plaintext only so an
* unformatted message stays byte-for-byte identical to the old plaintext-only path.
*/
fun hasFormatting(): Boolean =
spans.isNotEmpty() || links.isNotEmpty() || text.lineSequence().any { lineMarker(it) != null }
}
/** Recognized block markers and the tags they map to. */
internal const val BULLET_PREFIX = "• "
internal const val QUOTE_PREFIX = "> "
private val ORDERED_PREFIX = Regex("^\\d+\\. ")
private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE }
private data class Line(val kind: Kind, val contentStart: Int, val contentEnd: Int)
/** The block marker prefixing [line], or null for an ordinary paragraph line. */
internal fun lineMarker(line: String): String? = when {
line.startsWith(BULLET_PREFIX) -> BULLET_PREFIX
line.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX
else -> ORDERED_PREFIX.find(line)?.value
}
/**
* Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or
* Compose types), so the whole conversion is unit-testable on the JVM.
*
* The emitted subset — `<b> <i> <u> <a> <ul>/<ol>/<li> <blockquote> <br>` — is deliberately narrow
* so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts round-trip losslessly).
*/
object RichTextHtml {
fun toHtml(content: RichTextContent): String {
if (content.text.isEmpty()) return ""
val lines = classify(content.text)
val sb = StringBuilder()
var i = 0
while (i < lines.size) {
i = when (lines[i].kind) {
Kind.BULLET -> emitList(sb, content, lines, i, Kind.BULLET, "ul")
Kind.ORDERED -> emitList(sb, content, lines, i, Kind.ORDERED, "ol")
Kind.QUOTE -> emitQuote(sb, content, lines, i)
Kind.PARAGRAPH -> emitParagraph(sb, content, lines, i)
}
}
return sb.toString()
}
/** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */
fun toPlainText(content: RichTextContent): String = content.text
fun fromHtml(html: String): RichTextContent = HtmlToRichParser(html).parse()
}
private fun classify(text: String): List<Line> {
val lines = ArrayList<Line>()
var start = 0
while (true) {
val nl = text.indexOf('\n', start)
val end = if (nl == -1) text.length else nl
val marker = lineMarker(text.substring(start, end))
val kind = when (marker) {
BULLET_PREFIX -> Kind.BULLET
QUOTE_PREFIX -> Kind.QUOTE
null -> Kind.PARAGRAPH
else -> Kind.ORDERED
}
lines.add(Line(kind, start + (marker?.length ?: 0), end))
if (nl == -1) break
start = nl + 1
}
return lines
}
private fun emitList(
sb: StringBuilder,
content: RichTextContent,
lines: List<Line>,
from: Int,
kind: Kind,
tag: String,
): Int {
sb.append("<").append(tag).append(">")
var i = from
while (i < lines.size && lines[i].kind == kind) {
sb.append("<li>").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("</li>")
i++
}
sb.append("</").append(tag).append(">")
return i
}
private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
sb.append("<blockquote>")
var i = from
while (i < lines.size && lines[i].kind == Kind.QUOTE) {
if (i > from) sb.append("<br>")
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
i++
}
sb.append("</blockquote>")
return i
}
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
sb.append("<p>")
var i = from
while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) {
if (i > from) sb.append("<br>")
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
i++
}
sb.append("</p>")
return i
}
/** Renders the inline styling/links over [[from], [to]) as nested `<a>/<b>/<i>/<u>` tags. */
private fun renderInline(content: RichTextContent, from: Int, to: Int): String {
if (from >= to) return ""
val points = cutPoints(content, from, to)
val sb = StringBuilder()
for (idx in 0 until points.size - 1) {
val a = points[idx]
val b = points[idx + 1]
if (a < b) appendRun(sb, content, a, b)
}
return sb.toString()
}
/** The sorted set of offsets where a span or link starts/ends within [[from], [to]]. */
private fun cutPoints(content: RichTextContent, from: Int, to: Int): List<Int> {
val cuts = sortedSetOf(from, to)
fun add(start: Int, end: Int) {
if (end > from && start < to) {
cuts.add(start.coerceIn(from, to))
cuts.add(end.coerceIn(from, to))
}
}
content.spans.forEach { add(it.start, it.end) }
content.links.forEach { add(it.start, it.end) }
return cuts.toList()
}
/** Emits one constant-styling run [[a], [b]) with fully-closed tags, so nesting is always valid. */
private fun appendRun(sb: StringBuilder, content: RichTextContent, a: Int, b: Int) {
val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }.toSet()
val link = content.links.firstOrNull { it.start <= a && b <= it.end }
if (link != null) sb.append("<a href=\"").append(escapeAttr(link.url)).append("\">")
if (RichStyle.BOLD in styles) sb.append("<b>")
if (RichStyle.ITALIC in styles) sb.append("<i>")
if (RichStyle.UNDERLINE in styles) sb.append("<u>")
sb.append(escape(content.text.substring(a, b)))
if (RichStyle.UNDERLINE in styles) sb.append("</u>")
if (RichStyle.ITALIC in styles) sb.append("</i>")
if (RichStyle.BOLD in styles) sb.append("</b>")
if (link != null) sb.append("</a>")
}
/**
* Parses the narrow HTML subset [toHtml][RichTextHtml.toHtml] emits (plus `strong`/`em` and
* pretty-printer whitespace) back into a [RichTextContent]. A small state machine keeps the nesting
* shallow: [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text.
*/
private class HtmlToRichParser(private val html: String) {
private val text = StringBuilder()
private val spans = ArrayList<RichSpan>()
private val links = ArrayList<RichLink>()
private var boldStart = -1
private var italicStart = -1
private var underlineStart = -1
private var linkStart = -1
private var linkUrl = ""
private var listType: Char? = null
private var olCount = 0
private var inQuote = false
fun parse(): RichTextContent {
var i = 0
while (i < html.length) {
if (html[i] == '<') {
val gt = html.indexOf('>', i)
if (gt == -1) break
handleTag(html.substring(i + 1, gt).trim())
i = gt + 1
} else {
val lt = html.indexOf('<', i)
val end = if (lt == -1) html.length else lt
handleText(html.substring(i, end))
i = end
}
}
return finish()
}
private fun atLineStart() = text.isEmpty() || text.last() == '\n'
private fun newlineIfNeeded() {
if (!atLineStart()) text.append('\n')
}
private fun handleTag(raw: String) {
val closing = raw.startsWith("/")
val body = raw.removePrefix("/").trim()
when (body.substringBefore(' ').substringBefore('/').lowercase()) {
"br" -> {
text.append('\n')
if (inQuote) text.append(QUOTE_PREFIX)
}
"b", "strong" -> boldStart = toggle(closing, boldStart, RichStyle.BOLD)
"i", "em" -> italicStart = toggle(closing, italicStart, RichStyle.ITALIC)
"u" -> underlineStart = toggle(closing, underlineStart, RichStyle.UNDERLINE)
"a" -> handleAnchor(closing, body)
"ul" -> handleList(closing, 'u')
"ol" -> handleList(closing, 'o')
"li" -> if (!closing) startListItem()
"blockquote" -> handleQuote(closing)
"p", "div" -> newlineIfNeeded()
else -> Unit
}
}
/** Opens a style (returns the current offset) or closes it (records the span, returns -1). */
private fun toggle(closing: Boolean, openOffset: Int, style: RichStyle): Int {
if (!closing) return text.length
if (openOffset >= 0) spans.add(RichSpan(openOffset, text.length, style))
return -1
}
private fun handleAnchor(closing: Boolean, body: String) {
if (closing) {
if (linkStart >= 0) {
links.add(RichLink(linkStart, text.length, linkUrl))
linkStart = -1
linkUrl = ""
}
} else {
linkStart = text.length
linkUrl = extractHref(body)
}
}
private fun handleList(closing: Boolean, type: Char) {
if (closing) {
listType = null
} else {
listType = type
if (type == 'o') olCount = 0
}
newlineIfNeeded()
}
private fun startListItem() {
newlineIfNeeded()
if (listType == 'o') {
olCount++
text.append("$olCount. ")
} else {
text.append(BULLET_PREFIX)
}
}
private fun handleQuote(closing: Boolean) {
newlineIfNeeded()
inQuote = !closing
if (!closing) text.append(QUOTE_PREFIX)
}
private fun handleText(chunk: String) {
// Drop the insignificant whitespace a pretty-printer leaves between block tags (blank runs at
// a line start, or any blank run with a newline), but keep a real space between inline runs.
if (!(chunk.isBlank() && (atLineStart() || chunk.contains('\n')))) text.append(unescape(chunk))
}
private fun finish(): RichTextContent {
val out = text.toString().trimEnd('\n')
val len = out.length
if (boldStart in 0 until len) spans.add(RichSpan(boldStart, len, RichStyle.BOLD))
if (italicStart in 0 until len) spans.add(RichSpan(italicStart, len, RichStyle.ITALIC))
if (underlineStart in 0 until len) spans.add(RichSpan(underlineStart, len, RichStyle.UNDERLINE))
if (linkStart in 0 until len) links.add(RichLink(linkStart, len, linkUrl))
return RichTextContent(
text = out,
spans = spans.filter { it.end <= len && it.start < it.end },
links = links.filter { it.end <= len && it.start < it.end },
)
}
}
private fun extractHref(tagBody: String): String {
val match = Regex("href\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE).find(tagBody) ?: return ""
val (doubleQuoted, singleQuoted) = match.destructured
return unescape(doubleQuoted.ifEmpty { singleQuoted })
}
private fun escape(s: String): String = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
private fun escapeAttr(s: String): String = escape(s).replace("\"", "&quot;")
private fun unescape(s: String): String = s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.richtext
/** A block-level marker the toolbar can toggle over the selected lines. */
enum class BlockMarker { BULLET, ORDERED, QUOTE }
/** The result of an editing op: the new [content] and where the selection should land. */
data class EditResult(val content: RichTextContent, val selectionStart: Int, val selectionEnd: Int)
/**
* Pure editing operations over [RichTextContent] — toggling inline styles/links and block markers —
* shared by the Compose editor's toolbar. Keeping them free of Compose types makes the tricky index
* bookkeeping (splitting spans, shifting offsets when a line marker is inserted/removed) unit-testable
* on the JVM.
*/
object RichTextEditing {
/** Adds [style] over [[start], [end]) if it is not already fully styled, otherwise removes it. */
fun toggleStyle(content: RichTextContent, start: Int, end: Int, style: RichStyle): RichTextContent {
if (start >= end) return content
val others = content.spans.filter { it.style != style }
val same = content.spans.filter { it.style == style }
val updated = if (isFullyStyled(same, start, end)) {
subtractRange(same, start, end)
} else {
mergeSameStyle(same + RichSpan(start, end, style))
}
return content.copy(spans = (others + updated).sortedBy { it.start })
}
/** Links [[start], [end]) to [url], replacing any links that overlap the range. */
fun applyLink(content: RichTextContent, start: Int, end: Int, url: String): RichTextContent {
if (start >= end || url.isBlank()) return content
val kept = content.links.filter { it.end <= start || it.start >= end }
return content.copy(links = (kept + RichLink(start, end, url)).sortedBy { it.start })
}
/** Removes any links overlapping [[start], [end]). */
fun removeLink(content: RichTextContent, start: Int, end: Int): RichTextContent {
if (start >= end) return content
return content.copy(links = content.links.filter { it.end <= start || it.start >= end })
}
/** Whether [[start], [end]) is entirely covered by [style] (drives the toolbar's toggle state). */
fun isStyled(content: RichTextContent, start: Int, end: Int, style: RichStyle): Boolean =
start < end && isFullyStyled(content.spans.filter { it.style == style }, start, end)
/** Whether every line the selection touches carries [marker]. */
fun hasBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): Boolean {
val lineStarts = lineStartsTouching(content.text, start, end)
return lineStarts.isNotEmpty() && lineStarts.all { markerAt(content.text, it) == marker }
}
/**
* Toggles [marker] across every line the selection touches: if all those lines already carry it,
* it is removed; otherwise it is applied (replacing any other block marker already there). Spans,
* links and the selection are shifted to track the inserted/removed prefixes.
*/
fun toggleBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): EditResult {
val text = content.text
val lineStarts = lineStartsTouching(text, start, end)
val allHaveMarker = lineStarts.all { markerAt(text, it) == marker }
val edits = ArrayList<LineEdit>()
var ordinal = 1
for (lineStart in lineStarts) {
val existing = markerLengthAt(text, lineStart)
when {
allHaveMarker && existing > 0 -> edits.add(LineEdit(lineStart, existing, ""))
allHaveMarker -> Unit
else -> edits.add(LineEdit(lineStart, existing, insertFor(marker, ordinal++)))
}
}
val (newText, remap) = applyEdits(text, edits)
val newSpans = content.spans.mapNotNull { remapSpan(it, remap) }
val newLinks = content.links.mapNotNull { remapLink(it, remap) }
return EditResult(RichTextContent(newText, newSpans, newLinks), remap(start), remap(end))
}
}
private fun insertFor(marker: BlockMarker, ordinal: Int): String = when (marker) {
BlockMarker.BULLET -> BULLET_PREFIX
BlockMarker.QUOTE -> QUOTE_PREFIX
BlockMarker.ORDERED -> "$ordinal. "
}
private fun remapSpan(span: RichSpan, remap: (Int) -> Int): RichSpan? {
val s = remap(span.start)
val e = remap(span.end)
return if (s < e) RichSpan(s, e, span.style) else null
}
private fun remapLink(link: RichLink, remap: (Int) -> Int): RichLink? {
val s = remap(link.start)
val e = remap(link.end)
return if (s < e) RichLink(s, e, link.url) else null
}
// --- inline style helpers ---
private fun isFullyStyled(spans: List<RichSpan>, start: Int, end: Int): Boolean {
var pos = start
for (span in spans.filter { it.end > start && it.start < end }.sortedBy { it.start }) {
if (span.start > pos) return false
pos = maxOf(pos, span.end)
if (pos >= end) return true
}
return pos >= end
}
private fun subtractRange(spans: List<RichSpan>, start: Int, end: Int): List<RichSpan> = spans.flatMap { span ->
when {
span.end <= start || span.start >= end -> listOf(span)
else -> buildList {
if (span.start < start) add(span.copy(end = start))
if (span.end > end) add(span.copy(start = end))
}
}
}
private fun mergeSameStyle(spans: List<RichSpan>): List<RichSpan> {
val merged = ArrayList<RichSpan>()
for (span in spans.sortedBy { it.start }) {
val last = merged.lastOrNull()
if (last != null && span.start <= last.end) {
merged[merged.size - 1] = last.copy(end = maxOf(last.end, span.end))
} else {
merged.add(span)
}
}
return merged
}
// --- block marker helpers ---
private val ORDERED = Regex("^\\d+\\. ")
private fun markerAt(text: String, lineStart: Int): BlockMarker? {
val rest = text.substring(lineStart)
return when {
rest.startsWith(BULLET_PREFIX) -> BlockMarker.BULLET
rest.startsWith(QUOTE_PREFIX) -> BlockMarker.QUOTE
// ORDERED is anchored at ^, so find() matches only when this line starts with "N. ".
ORDERED.find(rest) != null -> BlockMarker.ORDERED
else -> null
}
}
private fun markerLengthAt(text: String, lineStart: Int): Int {
val rest = text.substring(lineStart)
return when {
rest.startsWith(BULLET_PREFIX) -> BULLET_PREFIX.length
rest.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX.length
else -> ORDERED.find(rest)?.value?.length ?: 0
}
}
/** Start offsets of every line the range [[start], [end]] intersects (a caret counts as its line). */
private fun lineStartsTouching(text: String, start: Int, end: Int): List<Int> {
val from = start.coerceIn(0, text.length)
val to = end.coerceIn(from, text.length)
val result = ArrayList<Int>()
var lineStart = if (from == 0) 0 else text.lastIndexOf('\n', from - 1).let { if (it == -1) 0 else it + 1 }
while (lineStart <= text.length) {
result.add(lineStart)
val nl = text.indexOf('\n', lineStart)
if (nl == -1 || nl >= to) break
lineStart = nl + 1
}
return result
}
private data class LineEdit(val offset: Int, val deleteLen: Int, val insert: String)
/** Applies line-start [edits] (ascending, non-overlapping) and returns the new text + an index remap. */
private fun applyEdits(text: String, edits: List<LineEdit>): Pair<String, (Int) -> Int> {
if (edits.isEmpty()) return text to { it }
val sorted = edits.sortedBy { it.offset }
val sb = StringBuilder()
var cursor = 0
for (edit in sorted) {
sb.append(text, cursor, edit.offset)
sb.append(edit.insert)
cursor = edit.offset + edit.deleteLen
}
sb.append(text, cursor, text.length)
val remap: (Int) -> Int = { index -> remapIndex(index, sorted).coerceIn(0, sb.length) }
return sb.toString() to remap
}
private fun remapIndex(index: Int, edits: List<LineEdit>): Int {
var delta = 0
for (edit in edits) {
val delEnd = edit.offset + edit.deleteLen
when {
delEnd <= index -> delta += edit.insert.length - edit.deleteLen
edit.offset < index -> delta += edit.insert.length - (index - edit.offset)
}
}
return index + delta
}
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import org.libremail.domain.repository.AccountRepository
import org.libremail.ui.navigation.Routes
import javax.inject.Inject
/**
* Decides the app's start destination from the stored account count: no accounts → the onboarding
* welcome flow; otherwise the mailbox.
*
* [startDestination] is `null` until the first account snapshot loads — the UI holds (renders
* nothing) during that window so a cold start never flashes the wrong screen. Only the *first*
* determination is used ([take]), so adding the first account mid-onboarding does not later flip the
* start destination and tear down the in-progress flow.
*/
@HiltViewModel
class AppViewModel @Inject constructor(accountRepository: AccountRepository) : ViewModel() {
val startDestination: StateFlow<String?> = accountRepository.observeAccounts()
.map { accounts -> if (accounts.isEmpty()) Routes.ONBOARDING else Routes.MAILBOX }
.take(1)
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
}
@@ -1,39 +1,99 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import org.libremail.ui.accountsetup.AccountSetupScreen
import org.libremail.R
import org.libremail.ui.accountsetup.AccountPickerScreen
import org.libremail.ui.accountsetup.AppPasswordSetupScreen
import org.libremail.ui.accountsetup.ManualSetupScreen
import org.libremail.ui.compose.ComposePrefill
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.onboarding.AddAnotherAccountScreen
import org.libremail.ui.onboarding.BatteryOptimizationScreen
import org.libremail.ui.onboarding.OnboardingViewModel
import org.libremail.ui.onboarding.OnboardingWelcomeScreen
import org.libremail.ui.outbox.OutboxScreen
import org.libremail.ui.reader.ReaderScreen
import org.libremail.ui.reporting.ProblemReportsScreen
import org.libremail.ui.reporting.ReportReviewScreen
import org.libremail.ui.reporting.StartupReportViewModel
import org.libremail.ui.settings.AccountSettingsScreen
import org.libremail.ui.settings.SettingsScreen
import org.libremail.ui.settings.SignatureEditScreen
import org.libremail.ui.settings.SignaturesScreen
@Composable
fun LibreMailApp() {
fun LibreMailApp(
appViewModel: AppViewModel = hiltViewModel(),
startupViewModel: StartupReportViewModel = hiltViewModel(),
pendingCompose: ComposePrefill? = null,
onComposeHandled: () -> Unit = {},
) {
val startDestination by appViewModel.startDestination.collectAsStateWithLifecycle()
// Hold (render nothing) until the account count is known, so a cold start never flashes the
// wrong screen before onboarding-vs-mailbox is decided.
val start = startDestination ?: return
val navController = rememberNavController()
val pendingCrash by startupViewModel.pendingCrash.collectAsStateWithLifecycle()
// A mailto:/share intent opens compose on top of the mailbox, pre-filled. Keyed on the request so
// it fires once per intent (and again for a new intent delivered while the app is alive).
LaunchedEffect(pendingCompose) {
val prefill = pendingCompose ?: return@LaunchedEffect
navController.navigate(
Routes.compose(
to = prefill.to,
subject = prefill.subject,
cc = prefill.cc,
bcc = prefill.bcc,
body = prefill.body,
),
)
onComposeHandled()
}
NavHost(
navController = navController,
startDestination = Routes.MAILBOX,
startDestination = start,
) {
composable(Routes.MAILBOX) {
onboardingGraph(navController)
composable(
route = Routes.MAILBOX_PATTERN,
arguments = listOf(
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
type = NavType.StringType
defaultValue = ""
},
),
) {
MailboxScreen(
onOpenMessage = { id -> navController.navigate(Routes.reader(id)) },
onCompose = { navController.navigate(Routes.compose()) },
@@ -60,10 +120,22 @@ fun LibreMailApp() {
type = NavType.StringType
defaultValue = ""
},
navArgument(Routes.COMPOSE_ARG_CC) {
type = NavType.StringType
defaultValue = ""
},
navArgument(Routes.COMPOSE_ARG_BCC) {
type = NavType.StringType
defaultValue = ""
},
navArgument(Routes.COMPOSE_ARG_SUBJECT) {
type = NavType.StringType
defaultValue = ""
},
navArgument(Routes.COMPOSE_ARG_BODY) {
type = NavType.StringType
defaultValue = ""
},
navArgument(Routes.COMPOSE_ARG_FROM) {
type = NavType.StringType
defaultValue = ""
@@ -81,33 +153,78 @@ fun LibreMailApp() {
onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) },
onOpenAccount = { accountId -> navController.navigate(Routes.accountSettings(accountId)) },
onSelectTab = navController::navigateTab,
onReportProblem = { navController.navigate(Routes.PROBLEM_REPORTS) },
)
}
composable(Routes.PROBLEM_REPORTS) {
ProblemReportsScreen(
onBack = navController::popBackStack,
onOpenReport = { reportId -> navController.navigate(Routes.reportReview(reportId)) },
)
}
composable(
route = Routes.REPORT_REVIEW_PATTERN,
arguments = listOf(navArgument(Routes.REPORT_REVIEW_ARG_ID) { type = NavType.StringType }),
) {
ReportReviewScreen(onDone = navController::popBackStack)
}
composable(
route = Routes.ACCOUNT_SETTINGS_PATTERN,
arguments = listOf(navArgument(Routes.ACCOUNT_SETTINGS_ARG_ID) { type = NavType.StringType }),
) {
AccountSettingsScreen(onBack = navController::popBackStack)
}
composable(Routes.ACCOUNT_SETUP) {
AccountSetupScreen(
) { backStackEntry ->
val accountId = backStackEntry.arguments?.getString(Routes.ACCOUNT_SETTINGS_ARG_ID).orEmpty()
AccountSettingsScreen(
onBack = navController::popBackStack,
onManualSetup = { navController.navigate(Routes.MANUAL_SETUP) },
onAccountAdded = {
navController.navigate(Routes.MAILBOX) {
popUpTo(Routes.MAILBOX) { inclusive = true }
}
onManageSignatures = { navController.navigate(Routes.signatures(accountId)) },
)
}
composable(
route = Routes.SIGNATURES_PATTERN,
arguments = listOf(navArgument(Routes.SIGNATURES_ARG_ACCOUNT) { type = NavType.StringType }),
) { backStackEntry ->
val accountId = backStackEntry.arguments?.getString(Routes.SIGNATURES_ARG_ACCOUNT).orEmpty()
SignaturesScreen(
onBack = navController::popBackStack,
onEdit = { signatureId -> navController.navigate(Routes.signatureEdit(accountId, signatureId)) },
onAdd = { navController.navigate(Routes.signatureEdit(accountId)) },
)
}
composable(
route = Routes.SIGNATURE_EDIT_PATTERN,
arguments = listOf(
navArgument(Routes.SIGNATURE_EDIT_ARG_ACCOUNT) { type = NavType.StringType },
navArgument(Routes.SIGNATURE_EDIT_ARG_ID) {
type = NavType.StringType
defaultValue = ""
},
),
) {
SignatureEditScreen(onBack = navController::popBackStack)
}
// "Add account" entry reused by Settings and the mailbox. These reuse the SAME picker/setup
// screens as onboarding, but each pops back to where the user was on success (no "add
// another?" prompt — that is onboarding-only, see #30).
composable(Routes.ACCOUNT_SETUP) {
AccountPickerScreen(
onBack = navController::popBackStack,
onAccountAdded = { navController.popBackStack() },
onPickProvider = { provider -> navController.navigate(Routes.appPassword(provider.key)) },
onManualSetup = { navController.navigate(Routes.MANUAL_SETUP) },
)
}
composable(
route = Routes.APP_PASSWORD_PATTERN,
arguments = listOf(navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType }),
) {
AppPasswordSetupScreen(
onBack = navController::popBackStack,
onAccountAdded = { navController.popBackStack(Routes.ACCOUNT_SETUP, inclusive = true) },
)
}
composable(Routes.MANUAL_SETUP) {
ManualSetupScreen(
onBack = navController::popBackStack,
onAccountAdded = {
navController.navigate(Routes.MAILBOX) {
popUpTo(Routes.MAILBOX) { inclusive = true }
}
},
onAccountAdded = { navController.popBackStack(Routes.ACCOUNT_SETUP, inclusive = true) },
)
}
composable(Routes.DRAFTS) {
@@ -120,6 +237,144 @@ fun LibreMailApp() {
OutboxScreen(onBack = navController::popBackStack)
}
}
// On launch, offer any saved crash report for review — never sent without the user's action.
pendingCrash?.let { crash ->
CrashReportDialog(
onReview = {
startupViewModel.dismiss()
navController.navigate(Routes.reportReview(crash.id))
},
onLater = startupViewModel::dismiss,
onDiscard = { startupViewModel.discard(crash.id) },
)
}
}
@Composable
private fun CrashReportDialog(onReview: () -> Unit, onLater: () -> Unit, onDiscard: () -> Unit) {
AlertDialog(
onDismissRequest = onLater,
title = { Text(stringResource(R.string.crash_prompt_title)) },
text = { Text(stringResource(R.string.crash_prompt_message)) },
confirmButton = {
TextButton(onClick = onReview) { Text(stringResource(R.string.crash_prompt_review)) }
},
dismissButton = {
Row {
TextButton(onClick = onDiscard) { Text(stringResource(R.string.crash_prompt_discard)) }
TextButton(onClick = onLater) { Text(stringResource(R.string.crash_prompt_later)) }
}
},
)
}
/**
* First-run onboarding as a nested graph so a single graph-scoped [OnboardingViewModel] can track the
* first account added this session. The picker/setup screens are the same composables used by the
* top-level "Add account" routes; here, a successful add routes to the "add another?" prompt instead
* of popping back.
*/
private fun NavGraphBuilder.onboardingGraph(navController: NavHostController) {
navigation(startDestination = Routes.ONBOARDING_WELCOME, route = Routes.ONBOARDING) {
composable(Routes.ONBOARDING_WELCOME) {
OnboardingWelcomeScreen(onAddAccount = { navController.navigate(Routes.ONBOARDING_PICKER) })
}
composable(Routes.ONBOARDING_PICKER) { entry ->
val onboarding = onboardingViewModel(navController, entry)
AccountPickerScreen(
onBack = navController::popBackStack,
onAccountAdded = { id ->
onboarding.onAccountAdded(id)
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER)
},
onPickProvider = { provider ->
navController.navigate(Routes.onboardingAppPassword(provider.key))
},
onManualSetup = { navController.navigate(Routes.ONBOARDING_MANUAL) },
)
}
composable(
route = Routes.ONBOARDING_APP_PASSWORD_PATTERN,
arguments = listOf(navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType }),
) { entry ->
val onboarding = onboardingViewModel(navController, entry)
AppPasswordSetupScreen(
onBack = navController::popBackStack,
onAccountAdded = { id -> onboarding.completeAdd(navController, id) },
)
}
composable(Routes.ONBOARDING_MANUAL) { entry ->
val onboarding = onboardingViewModel(navController, entry)
ManualSetupScreen(
onBack = navController::popBackStack,
onAccountAdded = { id -> onboarding.completeAdd(navController, id) },
)
}
onboardingFinishDestinations(navController)
}
}
/**
* The tail of onboarding: the "add another?" prompt and the optional battery opt-in step. Split out of
* [onboardingGraph] so each stays a readable length; both share the graph-scoped [OnboardingViewModel].
*/
private fun NavGraphBuilder.onboardingFinishDestinations(navController: NavHostController) {
composable(Routes.ONBOARDING_ADD_ANOTHER) { entry ->
val onboarding = onboardingViewModel(navController, entry)
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
AddAnotherAccountScreen(
onAddAnother = {
// Return to a fresh picker, clearing the prompt and the prior setup screen.
navController.navigate(Routes.ONBOARDING_PICKER) {
popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true }
}
},
onFinish = {
// Offer the battery opt-in as a final step when it's needed; otherwise go straight to
// the inbox. A still-undecided (null) decision fails open to finishing.
if (batteryPromptNeeded == true) {
navController.navigate(Routes.ONBOARDING_BATTERY)
} else {
navController.finishOnboarding(onboarding.firstAddedAccountId)
}
},
)
}
composable(Routes.ONBOARDING_BATTERY) { entry ->
val onboarding = onboardingViewModel(navController, entry)
BatteryOptimizationScreen(
viewModel = onboarding,
onFinish = {
onboarding.markBatteryPromptHandled()
navController.finishOnboarding(onboarding.firstAddedAccountId)
},
)
}
}
/** Leaves onboarding for the inbox — the first account added this session, or the unfiltered mailbox. */
private fun NavController.finishOnboarding(firstAccountId: String?) {
val dest = if (firstAccountId != null) Routes.mailboxForAccount(firstAccountId) else Routes.MAILBOX
navigate(dest) {
// Leave onboarding entirely; the mailbox becomes the new back-stack root.
popUpTo(Routes.ONBOARDING) { inclusive = true }
}
}
/** Resolves the onboarding-graph-scoped [OnboardingViewModel] shared across the onboarding screens. */
@Composable
private fun onboardingViewModel(navController: NavController, entry: NavBackStackEntry): OnboardingViewModel {
val parentEntry = remember(entry) { navController.getBackStackEntry(Routes.ONBOARDING) }
return hiltViewModel(parentEntry)
}
/** Records the added account, then advances to the "add another?" prompt (dropping the setup form). */
private fun OnboardingViewModel.completeAdd(navController: NavController, accountId: String) {
onAccountAdded(accountId)
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) {
popUpTo(Routes.ONBOARDING_PICKER)
}
}
/** Navigate between top-level tabs, preserving each tab's back stack and state. */
@@ -4,25 +4,31 @@ 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.clickable
import androidx.compose.foundation.layout.Arrangement
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.Button
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.CircularProgressIndicator
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.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -34,19 +40,31 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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
import org.libremail.domain.model.MailProvider
/**
* The single account-vendor picker used both by first-run onboarding and the "Add account" entry
* from Settings/mailbox. It routes each choice to the correct setup path:
* - Outlook/Hotmail → the existing Microsoft OAuth flow, completed inline via [AccountSetupViewModel].
* - Gmail / Yahoo / iCloud → the guided app-password screen with the matching [MailProvider] preset.
* - Other (IMAP/SMTP) → the manual setup screen.
*
* @param onAccountAdded invoked with the new account id when the *inline* Outlook flow completes.
* The app-password and manual paths report their own completion from their own screens.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSetupScreen(
fun AccountPickerScreen(
onBack: () -> Unit,
onAccountAdded: (String) -> Unit,
onPickProvider: (MailProvider) -> Unit,
onManualSetup: () -> Unit,
onAccountAdded: () -> Unit,
viewModel: AccountSetupViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -56,8 +74,10 @@ fun AccountSetupScreen(
ActivityResultContracts.StartActivityForResult(),
) { result -> viewModel.onOutlookResult(result.data) }
LaunchedEffect(state.status) {
if (state.status == SetupStatus.DONE) onAccountAdded()
LaunchedEffect(state.status, state.addedAccountId) {
if (state.status == SetupStatus.DONE) {
state.addedAccountId?.let(onAccountAdded)
}
}
LaunchedEffect(state.error) {
state.error?.let {
@@ -86,24 +106,24 @@ fun AccountSetupScreen(
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
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,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
)
Spacer(Modifier.height(24.dp))
Button(
ProviderRow(
// Recognizable brand logos would need bundled trademarked assets; until those
// exist we use a neutral mail glyph and rely on the visible label for recognition.
icon = Icons.Filled.Email,
label = stringResource(R.string.account_setup_outlook),
enabled = !busy,
onClick = {
viewModel.outlookAuthIntent().fold(
onSuccess = { intent ->
@@ -113,19 +133,22 @@ fun AccountSetupScreen(
onFailure = { viewModel.onOutlookLaunchFailed(it) },
)
},
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.account_setup_outlook))
)
MailProvider.entries.forEach { provider ->
ProviderRow(
icon = Icons.Filled.Email,
label = provider.displayName,
enabled = !busy,
onClick = { onPickProvider(provider) },
)
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
HorizontalDivider(Modifier.padding(vertical = 4.dp))
ProviderRow(
icon = Icons.Filled.Lock,
label = stringResource(R.string.account_setup_other),
enabled = !busy,
onClick = onManualSetup,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.account_setup_other))
}
)
}
if (busy) {
Box(
@@ -140,3 +163,26 @@ fun AccountSetupScreen(
}
}
}
@Composable
private fun ProviderRow(icon: ImageVector, label: String, enabled: Boolean, onClick: () -> Unit) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClickLabel = label, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(16.dp))
Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
@@ -13,13 +13,19 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.auth.OutlookAuthManager
import org.libremail.domain.model.Account
import org.libremail.domain.repository.AccountRepository
import javax.inject.Inject
/** 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)
data class AccountSetupUiState(
val status: SetupStatus = SetupStatus.IDLE,
val error: String? = null,
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
val addedAccountId: String? = null,
)
@HiltViewModel
class AccountSetupViewModel @Inject constructor(
@@ -59,8 +65,11 @@ class AccountSetupViewModel @Inject constructor(
runCatching {
val oauth = outlookAuthManager.exchangeToken(data)
accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow()
Account.outlook(oauth.email).id
}.fold(
onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } },
onSuccess = { accountId ->
_state.update { it.copy(status = SetupStatus.DONE, addedAccountId = accountId) }
},
onFailure = { e ->
// Stripped from release builds by the Log.d ProGuard rule (keeps any account
// address / token detail out of shipped logs); visible in debug for diagnosis.
@@ -0,0 +1,262 @@
// 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.material.icons.filled.Info
import androidx.compose.material.icons.filled.Warning
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.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.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
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 kotlinx.coroutines.launch
import org.libremail.R
import org.libremail.domain.model.MailProvider
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.ServerConfig
/**
* Guided app-password setup for the preset vendors (Gmail/Yahoo/iCloud). Explains what an app
* password is, warns to keep it safe, links out to the provider's app-password page, and collects
* only an email + app password (the servers come from the [MailProvider] preset). Verifies and
* persists via the same repository path as manual setup, surfacing failures as an inline snackbar.
*
* @param onAccountAdded invoked with the new account id after a successful add.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppPasswordSetupScreen(
onBack: () -> Unit,
onAccountAdded: (String) -> Unit,
viewModel: AppPasswordViewModel = hiltViewModel(),
) {
val form by viewModel.form.collectAsStateWithLifecycle()
val provider = viewModel.provider
val snackbarHostState = remember { SnackbarHostState() }
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
// Resolved up front so the failure handler (a non-composable lambda) can use it.
val openFailedMessage = stringResource(R.string.app_password_open_failed)
LaunchedEffect(form.status, form.addedAccountId) {
if (form.status == SetupStatus.DONE) {
form.addedAccountId?.let(onAccountAdded)
}
}
LaunchedEffect(form.error) {
form.error?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeError()
}
}
val busy = form.status == SetupStatus.CONNECTING
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
provider?.let { stringResource(R.string.app_password_title, it.displayName) }
?: stringResource(R.string.title_account_setup),
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
if (provider == null) {
// Defensive: onboarding only ever routes valid provider keys here.
Text(
text = stringResource(R.string.app_password_unknown_provider),
modifier = Modifier.padding(padding).padding(24.dp),
)
return@Scaffold
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
InfoCard(
icon = Icons.Filled.Info,
text = stringResource(providerIntro(provider)),
)
Spacer(Modifier.height(8.dp))
InfoCard(
icon = Icons.Filled.Info,
text = stringResource(R.string.app_password_what_is),
)
Spacer(Modifier.height(8.dp))
InfoCard(
icon = Icons.Filled.Warning,
text = stringResource(R.string.app_password_warning),
)
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = {
// openUri throws if no browser/handler is installed; surface it instead of crashing.
runCatching { uriHandler.openUri(provider.appPasswordHelpUrl) }
.onFailure { scope.launch { snackbarHostState.showSnackbar(openFailedMessage) } }
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.app_password_open_page, provider.displayName))
}
Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = form.email,
onValueChange = viewModel::onEmail,
label = { Text(stringResource(R.string.app_password_email)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = form.appPassword,
onValueChange = viewModel::onAppPassword,
label = { Text(stringResource(R.string.app_password_field)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
val account = remember(provider) { provider.createAccount("") }
AdvancedServers(
expanded = form.advancedExpanded,
onToggle = viewModel::toggleAdvanced,
imap = account.imap,
smtp = account.smtp,
)
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.app_password_test_and_add))
}
}
}
}
@Composable
private fun InfoCard(icon: ImageVector, text: String) {
Row(verticalAlignment = Alignment.Top) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp).padding(top = 2.dp),
)
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}
/** A collapsible, read-only view of the preset servers for users who want to confirm them. */
@Composable
private fun AdvancedServers(expanded: Boolean, onToggle: () -> Unit, imap: ServerConfig, smtp: ServerConfig) {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.app_password_show_servers),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Filled.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (expanded) 180f else 0f),
)
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringResource(R.string.app_password_server_imap, imap.host, imap.port, imap.security.label()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(R.string.app_password_server_smtp, smtp.host, smtp.port, smtp.security.label()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
private fun providerIntro(provider: MailProvider): Int = when (provider) {
MailProvider.GMAIL -> R.string.app_password_intro_gmail
MailProvider.YAHOO -> R.string.app_password_intro_yahoo
MailProvider.ICLOUD -> R.string.app_password_intro_icloud
}
private fun MailSecurity.label(): String = when (this) {
MailSecurity.SSL_TLS -> "SSL/TLS"
MailSecurity.STARTTLS -> "STARTTLS"
MailSecurity.NONE -> "None"
}
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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.MailProvider
import org.libremail.domain.repository.AccountRepository
import org.libremail.ui.navigation.Routes
import javax.inject.Inject
data class AppPasswordForm(
val email: String = "",
val appPassword: String = "",
val advancedExpanded: Boolean = false,
val status: SetupStatus = SetupStatus.IDLE,
val error: String? = null,
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
val addedAccountId: String? = null,
) {
val isValid: Boolean get() = email.isNotBlank() && appPassword.isNotBlank()
}
/**
* Backs the guided app-password setup screen (#29) for the preset vendors (Gmail/Yahoo/iCloud).
*
* The provider is passed as a nav argument and resolved from the [MailProvider] registry, which
* supplies the servers. The user only supplies an email + app password; this builds a
* `PASSWORD_IMAP` [org.libremail.domain.model.Account] from the preset and reuses
* [AccountRepository.addImapAccount] (live connection test + persist), exactly like manual setup.
*/
@HiltViewModel
class AppPasswordViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val accountRepository: AccountRepository,
) : ViewModel() {
/** The provider preset selected in the picker; null only if an unknown key was routed here. */
val provider: MailProvider? =
savedStateHandle.get<String>(Routes.APP_PASSWORD_ARG_PROVIDER)?.let(MailProvider::fromKey)
private val _form = MutableStateFlow(AppPasswordForm())
val form: StateFlow<AppPasswordForm> = _form.asStateFlow()
fun onEmail(value: String) = _form.update { it.copy(email = value) }
fun onAppPassword(value: String) = _form.update { it.copy(appPassword = value) }
fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) }
fun consumeError() = _form.update { it.copy(error = null) }
fun testAndSave() {
val provider = provider
if (provider == null) {
_form.update { it.copy(error = "Unknown email provider") }
return
}
val f = _form.value
if (!f.isValid) {
_form.update { it.copy(error = "Enter your email address and app password") }
return
}
val account = provider.createAccount(f.email)
viewModelScope.launch {
_form.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
accountRepository.addImapAccount(account, f.appPassword).fold(
onSuccess = {
_form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) }
},
onFailure = { e ->
_form.update {
it.copy(
status = SetupStatus.IDLE,
error = e.message ?: "Could not connect to the server",
)
}
},
)
}
}
}
@@ -52,14 +52,16 @@ import org.libremail.domain.model.MailSecurity
@Composable
fun ManualSetupScreen(
onBack: () -> Unit,
onAccountAdded: () -> Unit,
onAccountAdded: (String) -> 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.status, form.addedAccountId) {
if (form.status == SetupStatus.DONE) {
form.addedAccountId?.let(onAccountAdded)
}
}
LaunchedEffect(form.error) {
form.error?.let {
@@ -28,6 +28,8 @@ data class ManualSetupForm(
val advancedExpanded: Boolean = false,
val status: SetupStatus = SetupStatus.IDLE,
val error: String? = null,
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
val addedAccountId: String? = null,
) {
val isValid: Boolean
get() = email.isNotBlank() && password.isNotBlank() && imapHost.isNotBlank() && smtpHost.isNotBlank()
@@ -73,7 +75,7 @@ class ManualSetupViewModel @Inject constructor(private val accountRepository: Ac
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) } },
onSuccess = { _form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) } },
onFailure = { e ->
_form.update {
it.copy(
@@ -145,6 +145,7 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
label = { Text(stringResource(R.string.compose_to)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
)
if (state.suggestions.isNotEmpty()) {
@@ -158,6 +159,16 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
label = { Text(stringResource(R.string.compose_cc)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.bcc,
onValueChange = viewModel::onBccChange,
label = { Text(stringResource(R.string.compose_bcc)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
@@ -166,6 +177,7 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
onValueChange = viewModel::onSubjectChange,
label = { Text(stringResource(R.string.compose_subject)) },
singleLine = true,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
)
AttachmentsSection(
@@ -174,10 +186,11 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
onRemove = viewModel::removeAttachment,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.body,
onValueChange = viewModel::onBodyChange,
label = { Text(stringResource(R.string.compose_body)) },
RichTextBodyField(
body = state.body,
bodyHtml = state.bodyHtml,
onBodyChange = viewModel::onBodyChange,
label = stringResource(R.string.compose_body),
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
@@ -18,13 +18,17 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.contacts.ContactSuggestion
import org.libremail.contacts.ContactsRepository
import org.libremail.data.SignatureBlock
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SignatureRepository
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.richtext.RichTextContent
import org.libremail.richtext.RichTextHtml
import org.libremail.ui.navigation.Routes
import java.util.UUID
import javax.inject.Inject
@@ -32,8 +36,12 @@ import javax.inject.Inject
data class ComposeUiState(
val to: String = "",
val cc: String = "",
val bcc: String = "",
val subject: String = "",
/** The plaintext form of the body (also the `text/plain` fallback when sending). */
val body: String = "",
/** The HTML form of the body, or null when the message carries no formatting (plaintext-only). */
val bodyHtml: String? = null,
val fromAccountId: String? = null,
val attachments: List<OutgoingAttachment> = emptyList(),
val suggestions: List<ContactSuggestion> = emptyList(),
@@ -49,6 +57,7 @@ class ComposeViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val contactsRepository: ContactsRepository,
private val accountSettingsRepository: AccountSettingsRepository,
private val signatureRepository: SignatureRepository,
) : ViewModel() {
private val draftId: String? =
@@ -57,7 +66,10 @@ class ComposeViewModel @Inject constructor(
private val _state = MutableStateFlow(
ComposeUiState(
to = savedStateHandle.get<String>(Routes.COMPOSE_ARG_TO).orEmpty(),
cc = savedStateHandle.get<String>(Routes.COMPOSE_ARG_CC).orEmpty(),
bcc = savedStateHandle.get<String>(Routes.COMPOSE_ARG_BCC).orEmpty(),
subject = savedStateHandle.get<String>(Routes.COMPOSE_ARG_SUBJECT).orEmpty(),
body = savedStateHandle.get<String>(Routes.COMPOSE_ARG_BODY).orEmpty(),
fromAccountId = savedStateHandle.get<String>(Routes.COMPOSE_ARG_FROM)?.takeIf { it.isNotBlank() },
),
)
@@ -76,7 +88,7 @@ class ComposeViewModel @Inject constructor(
@Volatile private var navigated = false
/** The signature block last appended to the body, so a From-change can swap it out cleanly. */
private var appliedSignatureBlock = ""
private var appliedSignatureBlock = SignatureBlock.EMPTY
init {
if (draftId != null) {
@@ -88,6 +100,7 @@ class ComposeViewModel @Inject constructor(
cc = draft.cc,
subject = draft.subject,
body = draft.body,
bodyHtml = draft.bodyHtml,
fromAccountId = draft.accountId ?: it.fromAccountId,
attachments = draft.attachments,
)
@@ -111,28 +124,65 @@ class ComposeViewModel @Inject constructor(
}
fun onCcChange(value: String) = _state.update { it.copy(cc = value) }
fun onBccChange(value: String) = _state.update { it.copy(bcc = value) }
fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) }
fun onBodyChange(value: String) = _state.update { it.copy(body = value) }
/**
* The rich editor reports the current body in both forms: [plain] (also the plaintext fallback)
* and [html], which is null when the content carries no formatting so the message stays
* plaintext-only. Both are held for sending and for saving the draft.
*/
fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) }
fun selectFrom(accountId: String) {
viewModelScope.launch { applySignature(accountId) }
}
/**
* Sets the sending account and swaps its signature into the body: strips the previously-appended
* signature block (when the body still ends with it) and appends the newly-selected account's.
* Sets the sending account and swaps its default signature into the body: strips the
* previously-appended block (when the body still ends with it) and appends the newly-selected
* account's, in both the plaintext and HTML representations. Honors the account's
* "append signature" preference.
*/
private suspend fun applySignature(accountId: String) {
val block = accountSettingsRepository.get(accountId).signatureBlock()
val settings = accountSettingsRepository.get(accountId)
val block = if (settings.signatureEnabled) {
SignatureBlock.of(signatureRepository.getDefault(accountId))
} else {
SignatureBlock.EMPTY
}
_state.update { s ->
val base = if (appliedSignatureBlock.isNotEmpty() && s.body.endsWith(appliedSignatureBlock)) {
s.body.removeSuffix(appliedSignatureBlock)
} else {
s.body
}
s.copy(fromAccountId = accountId, body = base + block)
val basePlain = s.body.stripSuffixIfPresent(appliedSignatureBlock.plain)
val newBody = basePlain + block.plain
s.copy(fromAccountId = accountId, body = newBody, bodyHtml = swapHtmlSignature(s.bodyHtml, newBody, block))
}
appliedSignatureBlock = block
}
/**
* Swaps the signature in the HTML body. When the old block is still a clean suffix (the common
* case — the user changed accounts before editing), it is stripped and the new one appended,
* preserving any formatting the user applied. Otherwise the HTML was re-serialized after editing
* and no longer ends with the old block, so it is rebuilt from the plaintext to avoid ever
* duplicating the signature (inline styling from before the switch is not preserved in that case).
*/
private fun swapHtmlSignature(currentHtml: String?, newBody: String, block: SignatureBlock): String? {
val old = appliedSignatureBlock.html
val cleanlyStrippable = old.isEmpty() || currentHtml == null || currentHtml.endsWith(old)
val combined = if (cleanlyStrippable) {
(currentHtml?.removeSuffix(old) ?: "") + block.html
} else {
RichTextHtml.toHtml(RichTextContent(newBody))
}
return normalizedHtml(combined)
}
private fun String.stripSuffixIfPresent(suffix: String): String =
if (suffix.isNotEmpty() && endsWith(suffix)) removeSuffix(suffix) else this
/** Keeps an HTML body only when it actually carries formatting, so plaintext stays plaintext. */
private fun normalizedHtml(html: String): String? =
if (html.isBlank() || !RichTextHtml.fromHtml(html).hasFormatting()) null else html
fun addAttachments(items: List<OutgoingAttachment>) =
_state.update { it.copy(attachments = it.attachments + items) }
fun removeAttachment(uri: String) = _state.update {
@@ -187,6 +237,7 @@ class ComposeViewModel @Inject constructor(
val s = _state.value
val hasContent = s.to.isNotBlank() ||
s.cc.isNotBlank() ||
s.bcc.isNotBlank() ||
s.subject.isNotBlank() ||
s.body.isNotBlank() ||
s.attachments.isNotEmpty()
@@ -197,9 +248,11 @@ class ComposeViewModel @Inject constructor(
accountId = s.fromAccountId,
to = s.to,
cc = s.cc,
bcc = s.bcc,
subject = s.subject,
body = s.body,
updatedAt = System.currentTimeMillis(),
bodyHtml = s.bodyHtml,
attachments = s.attachments,
),
)
@@ -220,7 +273,16 @@ class ComposeViewModel @Inject constructor(
else -> {
_state.update { it.copy(sending = true, error = null) }
mailRepository.sendMessage(
OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body, s.attachments),
OutgoingMessage(
accountId = account.id,
to = s.to,
cc = s.cc,
bcc = s.bcc,
subject = s.subject,
body = s.body,
bodyHtml = s.bodyHtml,
attachments = s.attachments,
),
).fold(
onSuccess = {
draftId?.let { mailRepository.deleteDraft(it) }
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import android.content.Intent
/**
* Turns an inbound Android intent into a [ComposePrefill], so tapping a `mailto:` link or sharing to
* LibreMail as an email opens a pre-filled compose screen.
*
* Handles:
* - `ACTION_VIEW` / `ACTION_SENDTO` with a `mailto:` URI (delegates to [MailtoParser]).
* - `ACTION_SEND` / `ACTION_SEND_MULTIPLE` email shares, reading the standard `EXTRA_EMAIL`,
* `EXTRA_CC`, `EXTRA_BCC`, `EXTRA_SUBJECT` and `EXTRA_TEXT` extras.
*
* Extras fill in only the fields the URI left blank, so a `mailto:` URI always takes precedence.
* Returns `null` for anything that isn't a mail intent (e.g. the plain launcher intent), or when the
* intent carried nothing to compose.
*/
object IntentComposeParser {
fun parse(intent: Intent?): ComposePrefill? {
if (intent == null) return null
val prefill = when (intent.action) {
Intent.ACTION_VIEW, Intent.ACTION_SENDTO -> fromMailto(intent)
Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> fromShare(intent)
else -> null
}
return prefill?.takeUnless { it.isEmpty }
}
private fun fromMailto(intent: Intent): ComposePrefill? {
val data = intent.dataString
if (data == null || !data.startsWith(MAILTO, ignoreCase = true)) return null
return MailtoParser.parse(data).fillBlanksFrom(intent)
}
private fun fromShare(intent: Intent): ComposePrefill {
// A share may (rarely) also carry a mailto: URI; honour it, then fill from the extras.
val base = intent.dataString
?.takeIf { it.startsWith(MAILTO, ignoreCase = true) }
?.let { MailtoParser.parse(it) }
?: ComposePrefill()
return base.fillBlanksFrom(intent)
}
/** Fills each empty field from the corresponding email intent extra, leaving set fields intact. */
private fun ComposePrefill.fillBlanksFrom(intent: Intent): ComposePrefill = ComposePrefill(
to = to.ifBlank { addressExtra(intent, Intent.EXTRA_EMAIL) },
cc = cc.ifBlank { addressExtra(intent, Intent.EXTRA_CC) },
bcc = bcc.ifBlank { addressExtra(intent, Intent.EXTRA_BCC) },
subject = subject.ifBlank { intent.getStringExtra(Intent.EXTRA_SUBJECT).orEmpty() },
body = body.ifBlank { intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString().orEmpty() },
)
/** Reads an address extra that may be a `String[]` (the documented form) or a single `String`. */
private fun addressExtra(intent: Intent, key: String): String {
intent.getStringArrayExtra(key)?.let { array ->
return array.filter { it.isNotBlank() }.joinToString(", ")
}
return intent.getStringExtra(key).orEmpty()
}
private const val MAILTO = "mailto:"
}
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import java.io.ByteArrayOutputStream
/**
* The fields used to pre-fill the compose screen when it is opened from a `mailto:` link or a
* "send email" share intent. [isEmpty] is true when nothing worth composing was supplied.
*/
data class ComposePrefill(
val to: String = "",
val cc: String = "",
val bcc: String = "",
val subject: String = "",
val body: String = "",
) {
val isEmpty: Boolean
get() = to.isBlank() && cc.isBlank() && bcc.isBlank() && subject.isBlank() && body.isBlank()
}
/**
* Parses a `mailto:` URI (RFC 6068) into a [ComposePrefill].
*
* Pure Kotlin — it deliberately avoids `android.net.Uri` (a no-op stub in JVM unit tests, and its
* opaque-URI handling drops `mailto` query parameters anyway) so it is fully unit-testable. It
* supports multiple recipients, the `to`/`cc`/`bcc`/`subject`/`body` header fields (case-insensitive
* and mergeable with any address list before the `?`), and RFC 3986 percent-encoding.
*
* A literal `+` is preserved rather than decoded to a space: unlike `application/x-www-form-urlencoded`,
* `mailto` encodes spaces as `%20`, so `+` is a real character and addresses like `user+tag@example.com`
* survive intact.
*/
object MailtoParser {
private const val SCHEME = "mailto:"
private const val ESCAPE_LENGTH = 3 // a '%' plus two hex digits
private const val HEX_RADIX = 16
fun parse(uri: String): ComposePrefill {
val afterScheme = stripScheme(uri.trim())
val queryStart = afterScheme.indexOf('?')
val toPart = if (queryStart >= 0) afterScheme.substring(0, queryStart) else afterScheme
val query = if (queryStart >= 0) afterScheme.substring(queryStart + 1) else ""
val params = parseQuery(query)
val to = addresses(toPart) + params["to"].orEmpty().flatMap(::addresses)
return ComposePrefill(
to = to.joinToString(", "),
cc = params["cc"].orEmpty().flatMap(::addresses).joinToString(", "),
bcc = params["bcc"].orEmpty().flatMap(::addresses).joinToString(", "),
// First occurrence wins for these single-valued fields (RFC 6068 leaves duplicates undefined).
subject = params["subject"]?.firstOrNull()?.let(::percentDecode).orEmpty(),
body = params["body"]?.firstOrNull()?.let(::percentDecode).orEmpty(),
)
}
private fun stripScheme(value: String): String =
if (value.regionMatches(0, SCHEME, 0, SCHEME.length, ignoreCase = true)) {
value.substring(SCHEME.length)
} else {
value
}
/** Splits a `k=v&k=v` query into a map of lowercased field name to its raw (still-encoded) values. */
private fun parseQuery(query: String): Map<String, List<String>> {
if (query.isEmpty()) return emptyMap()
val result = mutableMapOf<String, MutableList<String>>()
for (pair in query.split('&')) {
if (pair.isEmpty()) continue
val eq = pair.indexOf('=')
val name = if (eq >= 0) pair.substring(0, eq) else pair
val rawValue = if (eq >= 0) pair.substring(eq + 1) else ""
result.getOrPut(name.lowercase()) { mutableListOf() }.add(rawValue)
}
return result
}
/** Splits a comma-separated, percent-encoded address list into decoded, non-blank addresses. */
private fun addresses(part: String): List<String> = part.split(',')
.map { percentDecode(it).trim() }
.filter { it.isNotEmpty() }
/**
* Decodes RFC 3986 `%XX` escapes as UTF-8 bytes. Leaves `+` untouched (see class doc) and passes
* any malformed escape (`%` not followed by two hex digits) through verbatim.
*/
private fun percentDecode(value: String): String {
if ('%' !in value) return value
val out = ByteArrayOutputStream(value.length)
var i = 0
while (i < value.length) {
val c = value[i]
val decoded = if (c == '%') decodeEscape(value, i) else null
if (decoded != null) {
out.write(decoded)
i += ESCAPE_LENGTH
} else {
val bytes = c.toString().toByteArray(Charsets.UTF_8)
out.write(bytes, 0, bytes.size)
i++
}
}
return out.toString(Charsets.UTF_8.name())
}
/** The byte value of the two hex digits following the `%` at [start], or null if malformed. */
private fun decodeEscape(value: String, start: Int): Int? {
if (start + ESCAPE_LENGTH > value.length) return null
return value.substring(start + 1, start + ESCAPE_LENGTH).toIntOrNull(HEX_RADIX)
}
}
@@ -0,0 +1,322 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
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
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import org.libremail.R
import org.libremail.richtext.BlockMarker
import org.libremail.richtext.RichLink
import org.libremail.richtext.RichSpan
import org.libremail.richtext.RichStyle
import org.libremail.richtext.RichTextContent
import org.libremail.richtext.RichTextEditing
import org.libremail.richtext.RichTextHtml
/** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */
private const val URL_TAG = "libremail:url"
/**
* A rich-text body editor: a formatting toolbar (bold / italic / underline, bulleted + numbered
* lists, block quote, and link) above a rounded [OutlinedTextField]. It converts its
* [AnnotatedString] to the app's [RichTextContent] model and reports both the plaintext form and its
* HTML — or null HTML when nothing is formatted, so an unformatted message stays plaintext-only and
* feels exactly like the old editor.
*
* The field is a normal Compose text field, so TalkBack, text selection, and large system fonts all
* work as usual; the toolbar buttons carry content descriptions and toggle state for accessibility.
*/
@Composable
fun RichTextBodyField(
body: String,
bodyHtml: String?,
onBodyChange: (plain: String, html: String?) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
val linkColor = MaterialTheme.colorScheme.primary
var value by remember { mutableStateOf(seedValue(body, bodyHtml, linkColor)) }
// Tracks the (plain, html) we last pushed up, so an external change (draft load / signature swap)
// re-seeds the field but our own emissions do not fight the user's cursor.
var lastEmitted by remember { mutableStateOf(body to bodyHtml) }
if (body to bodyHtml != lastEmitted) {
value = seedValue(body, bodyHtml, linkColor)
lastEmitted = body to bodyHtml
}
fun emit(newValue: TextFieldValue) {
value = newValue
val content = newValue.annotatedString.toRichContent()
val html = if (content.hasFormatting()) RichTextHtml.toHtml(content) else null
lastEmitted = content.text to html
onBodyChange(content.text, html)
}
var showLinkDialog by remember { mutableStateOf(false) }
Column(modifier) {
FormattingToolbar(
value = value,
onToggleStyle = { style -> emit(applyStyle(value, style, linkColor)) },
onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor)) },
onLink = { showLinkDialog = true },
)
OutlinedTextField(
value = value,
onValueChange = ::emit,
label = { Text(label) },
shape = MaterialTheme.shapes.large,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
if (showLinkDialog) {
val hasSelection = value.selection.min < value.selection.max
LinkDialog(
enabled = hasSelection,
onDismiss = { showLinkDialog = false },
onConfirm = { url ->
emit(applyLink(value, url, linkColor))
showLinkDialog = false
},
)
}
}
@Composable
private fun FormattingToolbar(
value: TextFieldValue,
onToggleStyle: (RichStyle) -> Unit,
onToggleBlock: (BlockMarker) -> Unit,
onLink: () -> Unit,
) {
val content = value.annotatedString.toRichContent()
val start = value.selection.min
val end = value.selection.max
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FormatButton(
label = "B",
description = stringResource(R.string.format_bold),
active = RichTextEditing.isStyled(content, start, end, RichStyle.BOLD),
fontWeight = FontWeight.Bold,
onClick = { onToggleStyle(RichStyle.BOLD) },
)
FormatButton(
label = "I",
description = stringResource(R.string.format_italic),
active = RichTextEditing.isStyled(content, start, end, RichStyle.ITALIC),
fontStyle = FontStyle.Italic,
onClick = { onToggleStyle(RichStyle.ITALIC) },
)
FormatButton(
label = "U",
description = stringResource(R.string.format_underline),
active = RichTextEditing.isStyled(content, start, end, RichStyle.UNDERLINE),
underline = true,
onClick = { onToggleStyle(RichStyle.UNDERLINE) },
)
FormatButton(
label = "•",
description = stringResource(R.string.format_bullet_list),
active = RichTextEditing.hasBlock(content, start, end, BlockMarker.BULLET),
onClick = { onToggleBlock(BlockMarker.BULLET) },
)
FormatButton(
label = "1.",
description = stringResource(R.string.format_numbered_list),
active = RichTextEditing.hasBlock(content, start, end, BlockMarker.ORDERED),
onClick = { onToggleBlock(BlockMarker.ORDERED) },
)
FormatButton(
label = "❝",
description = stringResource(R.string.format_quote),
active = RichTextEditing.hasBlock(content, start, end, BlockMarker.QUOTE),
onClick = { onToggleBlock(BlockMarker.QUOTE) },
)
FormatButton(
label = "🔗",
description = stringResource(R.string.format_link),
active = false,
onClick = onLink,
)
}
}
@Composable
private fun FormatButton(
label: String,
description: String,
active: Boolean,
onClick: () -> Unit,
fontWeight: FontWeight? = null,
fontStyle: FontStyle? = null,
underline: Boolean = false,
) {
val colors = MaterialTheme.colorScheme
val background = if (active) colors.secondaryContainer else Color.Transparent
val textColor = if (active) colors.onSecondaryContainer else colors.onSurfaceVariant
Box(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(background)
.clickable(onClick = onClick, role = Role.Button, onClickLabel = description)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = textColor,
style = LocalTextStyle.current.copy(
fontWeight = fontWeight,
fontStyle = fontStyle,
textDecoration = if (underline) TextDecoration.Underline else null,
),
)
}
}
@Composable
private fun LinkDialog(enabled: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var url by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.format_link_title)) },
text = {
if (enabled) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
singleLine = true,
label = { Text(stringResource(R.string.format_link_url)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
} else {
Text(stringResource(R.string.format_link_needs_selection))
}
},
confirmButton = {
TextButton(onClick = { onConfirm(url.trim()) }, enabled = enabled && url.isNotBlank()) {
Text(stringResource(R.string.format_link_apply))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
// --- editor-op plumbing (TextFieldValue <-> RichTextContent) ---
private fun applyStyle(value: TextFieldValue, style: RichStyle, linkColor: Color): TextFieldValue {
val updated = RichTextEditing.toggleStyle(
value.annotatedString.toRichContent(),
value.selection.min,
value.selection.max,
style,
)
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
}
private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Color): TextFieldValue {
val result = RichTextEditing.toggleBlock(
value.annotatedString.toRichContent(),
value.selection.min,
value.selection.max,
marker,
)
return TextFieldValue(
result.content.toAnnotatedString(linkColor),
TextRange(result.selectionStart, result.selectionEnd),
)
}
private fun applyLink(value: TextFieldValue, url: String, linkColor: Color): TextFieldValue {
val updated = RichTextEditing.applyLink(
value.annotatedString.toRichContent(),
value.selection.min,
value.selection.max,
url,
)
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
}
private fun seedValue(body: String, bodyHtml: String?, linkColor: Color): TextFieldValue {
val content = if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body)
val annotated = content.toAnnotatedString(linkColor)
return TextFieldValue(annotated, TextRange(annotated.length))
}
/** Maps the app rich-text model onto a Compose [AnnotatedString] for display/editing. */
internal fun RichTextContent.toAnnotatedString(linkColor: Color): AnnotatedString = buildAnnotatedString {
append(text)
spans.forEach { span -> addStyle(spanStyleFor(span.style), span.start, span.end) }
links.forEach { link ->
addStyle(SpanStyle(color = linkColor), link.start, link.end)
addStringAnnotation(URL_TAG, link.url, link.start, link.end)
}
}
/** Maps a Compose [AnnotatedString] back to the app model, reading single-attribute span styles. */
internal fun AnnotatedString.toRichContent(): RichTextContent {
val richSpans = spanStyles.mapNotNull { range ->
styleOf(range.item)?.let { RichSpan(range.start, range.end, it) }
}
val links = getStringAnnotations(URL_TAG, 0, length).map { RichLink(it.start, it.end, it.item) }
return RichTextContent(text, richSpans, links)
}
private fun spanStyleFor(style: RichStyle): SpanStyle = when (style) {
RichStyle.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
RichStyle.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
RichStyle.UNDERLINE -> SpanStyle(textDecoration = TextDecoration.Underline)
}
private fun styleOf(span: SpanStyle): RichStyle? = when {
span.fontWeight == FontWeight.Bold -> RichStyle.BOLD
span.fontStyle == FontStyle.Italic -> RichStyle.ITALIC
span.textDecoration == TextDecoration.Underline -> RichStyle.UNDERLINE
else -> null // e.g. the link color span, which is carried by the URL annotation instead
}
@@ -37,7 +37,6 @@ import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu
@@ -86,6 +85,7 @@ import org.libremail.domain.model.Folder
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import org.libremail.ui.onboarding.WelcomeContent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -240,7 +240,9 @@ fun MailboxScreen(
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
if (!hasAccounts) {
NoAccountState(onAddAccount = onAddAccount)
// Onboarding covers the fresh-install empty case; this is the runtime fallback
// (e.g. the last account was removed). Reuses the same welcome invitation.
WelcomeContent(onAddAccount = onAddAccount, modifier = Modifier.fillMaxSize())
} else {
val accountsById = remember(accounts) { accounts.associateBy { it.id } }
val showAccount = selectedAccountId == null && accounts.size >= 2
@@ -686,34 +688,6 @@ private fun MoveFolderDialog(folders: List<Folder>, onSelect: (Folder) -> Unit,
)
}
@Composable
private fun NoAccountState(onAddAccount: () -> Unit) {
Column(
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(
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.mailbox
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -29,6 +30,7 @@ import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
import javax.inject.Inject
const val INBOX = "INBOX"
@@ -42,8 +44,14 @@ class MailboxViewModel @Inject constructor(
private val mailRepository: MailRepository,
accountRepository: AccountRepository,
private val mailSyncer: Syncer,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
// Optional "open filtered to this account" arg — set when onboarding lands the user on the
// first account they added, so the mailbox opens that account's inbox rather than the unified view.
private val initialAccountId: String? =
savedStateHandle.get<String>(Routes.MAILBOX_ARG_ACCOUNT)?.takeIf { it.isNotBlank() }
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@@ -52,7 +60,7 @@ class MailboxViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
/** null = unified "All inboxes"; otherwise the account whose mail is shown. */
private val _selectedAccountId = MutableStateFlow<String?>(null)
private val _selectedAccountId = MutableStateFlow(initialAccountId)
val selectedAccountId: StateFlow<String?> = _selectedAccountId.asStateFlow()
/** The folder whose mail is shown (always a concrete folder; defaults to the inbox). */
@@ -60,7 +68,7 @@ class MailboxViewModel @Inject constructor(
val selectedFolder: StateFlow<String> = _selectedFolder.asStateFlow()
/** Which account's folders the drawer lists. null follows the mailbox selection / first account. */
private val explicitDrawerAccountId = MutableStateFlow<String?>(null)
private val explicitDrawerAccountId = MutableStateFlow(initialAccountId)
/** The account the drawer is browsing: explicit drawer pick, else the filtered account, else the first. */
val drawerAccount: StateFlow<Account?> =
@@ -241,11 +249,13 @@ class MailboxViewModel @Inject constructor(
}
init {
// Fall back to the unified inbox if the filtered account is removed.
// Fall back to the unified inbox if the filtered account is removed. The list.isNotEmpty()
// guard avoids clobbering a seeded account filter during the initial empty emission (before
// the account list first loads from the database).
viewModelScope.launch {
accounts.collect { list ->
val selected = _selectedAccountId.value
if (selected != null && list.none { it.id == selected }) {
if (selected != null && list.isNotEmpty() && list.none { it.id == selected }) {
_selectedAccountId.value = null
_selectedFolder.value = INBOX
}
@@ -11,6 +11,38 @@ object Routes {
const val MANUAL_SETUP = "manual_setup"
const val DRAFTS = "drafts"
const val OUTBOX = "outbox"
const val PROBLEM_REPORTS = "problem_reports"
// The mailbox can be opened filtered to a single account (used when onboarding lands the user on
// the first account they added). The bare MAILBOX route resolves here with an empty account arg,
// so it stays a valid start destination and bottom-tab target.
const val MAILBOX_ARG_ACCOUNT = "account"
const val MAILBOX_PATTERN = "mailbox?account={$MAILBOX_ARG_ACCOUNT}"
fun mailboxForAccount(accountId: String) = "mailbox?account=${Uri.encode(accountId)}"
// App-password guided setup, parameterized by provider key (see MailProvider). Reused by both
// onboarding and the standalone "Add account" entry.
const val APP_PASSWORD_ARG_PROVIDER = "provider"
const val APP_PASSWORD_PATTERN = "app_password/{$APP_PASSWORD_ARG_PROVIDER}"
fun appPassword(provider: String) = "app_password/${Uri.encode(provider)}"
// Onboarding first-run flow (nested graph). ONBOARDING is the graph route; the rest are its
// destinations. The graph owns the "first account added this session" state via a graph-scoped
// ViewModel, so the picker/setup screens are registered inside it for onboarding and reused as
// the top-level ACCOUNT_SETUP / APP_PASSWORD / MANUAL_SETUP routes for "Add account" later.
const val ONBOARDING = "onboarding"
const val ONBOARDING_WELCOME = "onboarding/welcome"
const val ONBOARDING_PICKER = "onboarding/picker"
const val ONBOARDING_MANUAL = "onboarding/manual"
const val ONBOARDING_ADD_ANOTHER = "onboarding/add_another"
// Optional final onboarding step: invites the user to allow unrestricted background/battery usage
// so push (IMAP IDLE) and periodic sync aren't throttled by Doze (#49). Shown only when the app
// isn't already exempt and the user hasn't handled it before; otherwise onboarding skips straight
// to the inbox.
const val ONBOARDING_BATTERY = "onboarding/battery"
const val ONBOARDING_APP_PASSWORD_PATTERN = "onboarding/app_password/{$APP_PASSWORD_ARG_PROVIDER}"
fun onboardingAppPassword(provider: String) = "onboarding/app_password/${Uri.encode(provider)}"
const val READER_ARG_ID = "messageId"
const val READER_PATTERN = "reader/{$READER_ARG_ID}"
@@ -20,13 +52,47 @@ object Routes {
const val ACCOUNT_SETTINGS_PATTERN = "account_settings/{$ACCOUNT_SETTINGS_ARG_ID}"
fun accountSettings(accountId: String) = "account_settings/${Uri.encode(accountId)}"
const val SIGNATURES_ARG_ACCOUNT = "accountId"
const val SIGNATURES_PATTERN = "signatures/{$SIGNATURES_ARG_ACCOUNT}"
fun signatures(accountId: String) = "signatures/${Uri.encode(accountId)}"
const val SIGNATURE_EDIT_ARG_ACCOUNT = "accountId"
const val SIGNATURE_EDIT_ARG_ID = "signatureId"
const val SIGNATURE_EDIT_PATTERN =
"signature_edit/{$SIGNATURE_EDIT_ARG_ACCOUNT}?$SIGNATURE_EDIT_ARG_ID={$SIGNATURE_EDIT_ARG_ID}"
fun signatureEdit(accountId: String, signatureId: String = ""): String =
"signature_edit/${Uri.encode(accountId)}?$SIGNATURE_EDIT_ARG_ID=${Uri.encode(signatureId)}"
const val REPORT_REVIEW_ARG_ID = "reportId"
const val REPORT_REVIEW_PATTERN = "report_review/{$REPORT_REVIEW_ARG_ID}"
fun reportReview(reportId: String) = "report_review/${Uri.encode(reportId)}"
const val COMPOSE_ARG_TO = "to"
const val COMPOSE_ARG_CC = "cc"
const val COMPOSE_ARG_BCC = "bcc"
const val COMPOSE_ARG_SUBJECT = "subject"
const val COMPOSE_ARG_BODY = "body"
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)}"
"compose?to={$COMPOSE_ARG_TO}&cc={$COMPOSE_ARG_CC}&bcc={$COMPOSE_ARG_BCC}" +
"&subject={$COMPOSE_ARG_SUBJECT}&body={$COMPOSE_ARG_BODY}" +
"&from={$COMPOSE_ARG_FROM}&draft={$COMPOSE_ARG_DRAFT}"
/**
* Builds a compose route. Every field is URL-encoded so recipients, subjects and bodies that
* contain `&`, `=`, spaces or newlines (e.g. from a `mailto:` link) round-trip through the
* NavHost into the compose form intact.
*/
fun compose(
to: String = "",
subject: String = "",
from: String = "",
cc: String = "",
bcc: String = "",
body: String = "",
): String = "compose?to=${Uri.encode(to)}&cc=${Uri.encode(cc)}&bcc=${Uri.encode(bcc)}" +
"&subject=${Uri.encode(subject)}&body=${Uri.encode(body)}&from=${Uri.encode(from)}"
fun composeDraft(draftId: String): String = "compose?draft=${Uri.encode(draftId)}"
}
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 org.libremail.R
/**
* Shown after an account is added *during onboarding*: confirms the add and asks whether to add
* another. Yes returns to the vendor picker; No finishes onboarding and opens the first account's
* inbox. Only part of the onboarding flow — adding an account from Settings later skips this prompt.
*/
@Composable
fun AddAnotherAccountScreen(onAddAnother: () -> Unit, onFinish: () -> Unit) {
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.onboarding_account_added_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.onboarding_add_another_prompt),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
Button(
onClick = onAddAnother,
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_add_another_yes))
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onFinish,
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_add_another_no))
}
}
}
}
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
/**
* Final onboarding step (shown only when needed, see [OnboardingViewModel.batteryPromptNeeded]):
* invites the user to allow unrestricted background/battery usage so push and periodic sync aren't
* throttled by Doze. **Take me there** deep-links to the system screen (no restricted permission);
* **Not now** skips. Either way [onFinish] proceeds to the inbox. On returning from Settings the
* status is re-read and, if the app is now unrestricted, the screen reflects that with a "done" state.
*
* @param viewModel the graph-scoped onboarding view model (holds live battery status + the flag).
* @param onFinish leaves onboarding for the inbox; the caller also marks the prompt handled.
*/
@Composable
fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Unit) {
val unrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
val context = LocalContext.current
// Re-check on every resume so returning from the system settings screen reflects the new state.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() }
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = if (unrestricted) Icons.Filled.CheckCircle else Icons.Filled.Notifications,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(
if (unrestricted) R.string.onboarding_battery_done_title else R.string.onboarding_battery_title,
),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(
if (unrestricted) R.string.onboarding_battery_done_body else R.string.onboarding_battery_body,
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
if (unrestricted) {
Button(
onClick = onFinish,
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_battery_continue))
}
} else {
Text(
text = stringResource(R.string.onboarding_battery_guidance),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = {
// Mark handled up front: the user is leaving for Settings and might not return
// to this screen. Launching app-details always resolves; guard defensively.
viewModel.markBatteryPromptHandled()
runCatching { context.startActivity(viewModel.batterySettingsIntent()) }
},
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_battery_take_me))
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onFinish,
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_battery_not_now))
}
}
}
}
}
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.libremail.data.settings.SettingsRepository
import org.libremail.push.BatteryOptimizationManager
import org.libremail.push.BatteryPromptDecision
import javax.inject.Inject
/**
* Session state for one run of the onboarding flow. Scoped to the onboarding nav graph's back-stack
* entry, so it is created when onboarding starts and cleared when the graph is popped.
*
* It remembers the **first** account added this session (so finishing opens that account's inbox, see
* #30) and decides whether to show the "unrestricted battery" opt-in step before finishing (see #49).
*/
@HiltViewModel
class OnboardingViewModel @Inject constructor(
private val batteryOptimizationManager: BatteryOptimizationManager,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
/** The id of the first account added this session, or null if none has been added yet. */
var firstAddedAccountId: String? = null
private set
private val _batteryPromptNeeded = MutableStateFlow<Boolean?>(null)
/**
* Whether onboarding should show the battery opt-in step before finishing. `null` until decided;
* the finish path treats `null` as "skip", so a slow read can never block the end of onboarding.
* Decided once at graph start — neither input can change until the user reaches the step itself.
*/
val batteryPromptNeeded: StateFlow<Boolean?> = _batteryPromptNeeded.asStateFlow()
private val _batteryUnrestricted = MutableStateFlow(false)
/** Live "Unrestricted" status, re-read when the opt-in step resumes (e.g. back from Settings). */
val batteryUnrestricted: StateFlow<Boolean> = _batteryUnrestricted.asStateFlow()
init {
viewModelScope.launch {
val unrestricted = batteryOptimizationManager.isIgnoringBatteryOptimizations()
_batteryUnrestricted.value = unrestricted
_batteryPromptNeeded.value = BatteryPromptDecision.shouldPrompt(
supported = batteryOptimizationManager.isSupported,
alreadyUnrestricted = unrestricted,
alreadyHandled = settingsRepository.isBatteryPromptHandled(),
)
}
}
/** Records a freshly added account. Only the first one sticks — later adds don't overwrite it. */
fun onAccountAdded(accountId: String) {
if (firstAddedAccountId == null) {
firstAddedAccountId = accountId
}
}
/** Intent to the system screen where the user flips this app to "Unrestricted". */
fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent()
/** Re-read the live battery status; call when the opt-in step resumes. */
fun refreshBatteryStatus() {
viewModelScope.launch {
_batteryUnrestricted.value = batteryOptimizationManager.isIgnoringBatteryOptimizations()
}
}
/** Record that the user has seen/acted on the battery opt-in so onboarding won't ask again. */
fun markBatteryPromptHandled() {
viewModelScope.launch { settingsRepository.setBatteryPromptHandled(true) }
}
}
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 org.libremail.R
/**
* First-run welcome. Invites the user to connect their first mailbox and hands off to the vendor
* picker. Shown as the onboarding start destination when the app launches with no accounts.
*/
@Composable
fun OnboardingWelcomeScreen(onAddAccount: () -> Unit) {
Scaffold { padding ->
WelcomeContent(
onAddAccount = onAddAccount,
modifier = Modifier
.fillMaxSize()
.padding(padding),
)
}
}
/**
* The welcome body: a headline, a short subtitle, and the "Add account" call to action. Extracted so
* the mailbox's empty state (when the last account is removed) reuses the exact same invitation
* instead of a separate blank-inbox screen.
*/
@Composable
fun WelcomeContent(onAddAccount: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Filled.Email,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.onboarding_welcome_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.onboarding_welcome_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
Button(
onClick = onAddAccount,
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_add_account))
}
}
}
@@ -0,0 +1,122 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reporting
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.material3.Button
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.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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.reporting.ReportKind
import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProblemReportsScreen(
onBack: () -> Unit,
onOpenReport: (String) -> Unit,
viewModel: ProblemReportsViewModel = hiltViewModel(),
) {
val reports by viewModel.reports.collectAsStateWithLifecycle()
// A newly created manual report opens straight into review.
LaunchedEffect(Unit) {
viewModel.created.collect { onOpenReport(it) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.reports_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding),
) {
Button(
onClick = viewModel::createManualReport,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(stringResource(R.string.reports_create))
}
if (reports.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
stringResource(R.string.reports_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize()) {
items(reports, key = { it.id }) { report ->
ReportRow(report = report, onClick = { onOpenReport(report.id) })
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun ReportRow(report: ReportSummary, onClick: () -> Unit) {
val kindLabel = when (report.kind) {
ReportKind.CRASH -> stringResource(R.string.report_kind_crash)
ReportKind.MANUAL -> stringResource(R.string.report_kind_manual)
}
val timestamp = remember(report.createdAtMillis) {
DateFormat.getDateTimeInstance().format(Date(report.createdAtMillis))
}
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(kindLabel, style = MaterialTheme.typography.bodyLarge)
Text(
timestamp,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reporting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.libremail.reporting.DebugReport
import org.libremail.reporting.DiagnosticsCollector
import org.libremail.reporting.ReportKind
import org.libremail.reporting.ReportStore
import javax.inject.Inject
/** A row in the problem-reports list. */
data class ReportSummary(val id: String, val kind: ReportKind, val createdAtMillis: Long)
@HiltViewModel
class ProblemReportsViewModel @Inject constructor(
private val store: ReportStore,
private val collector: DiagnosticsCollector,
) : ViewModel() {
val reports: StateFlow<List<ReportSummary>> = store.reports
.map { list -> list.map { it.toSummary() } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), emptyList())
// Emits the id of a freshly created report so the screen can open it for review immediately.
private val _created = MutableSharedFlow<String>(extraBufferCapacity = 1)
val created: SharedFlow<String> = _created
fun createManualReport() {
viewModelScope.launch {
val report = collector.collectManual()
store.save(report)
_created.tryEmit(report.id)
}
}
fun discard(id: String) {
viewModelScope.launch { store.delete(id) }
}
private fun DebugReport.toSummary() = ReportSummary(id, kind, createdAtMillis)
private companion object {
const val SUBSCRIBE_MS = 5_000L
}
}
@@ -0,0 +1,230 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reporting
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.rememberScrollState
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.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremail.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val savedMessage = stringResource(R.string.report_saved)
val copiedMessage = stringResource(R.string.report_copied)
val saveLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json"),
) { uri ->
if (uri != null) {
val text = viewModel.payload()
scope.launch {
withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openOutputStream(uri)?.use {
it.write(text.toByteArray())
}
}
}
snackbarHostState.showSnackbar(savedMessage)
}
}
}
// Once the report has been submitted (deleted by the worker) or discarded, leave the screen.
LaunchedEffect(state.loaded, state.exists) {
if (state.loaded && !state.exists) onDone()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.report_review_title)) },
navigationIcon = {
IconButton(onClick = onDone) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
PiiDisclaimer()
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.comment,
onValueChange = viewModel::updateComment,
label = { Text(stringResource(R.string.report_comment_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
Spacer(Modifier.height(16.dp))
Text(
stringResource(R.string.report_payload_label),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
PayloadBox(payload = state.payload)
SubmitStatusText(state.submit)
Spacer(Modifier.height(16.dp))
Button(
onClick = viewModel::submit,
enabled = state.submit != SubmitUiState.SUBMITTING,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.report_submit))
}
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth()) {
TextButton(
onClick = {
clipboard.setText(AnnotatedString(viewModel.payload()))
scope.launch { snackbarHostState.showSnackbar(copiedMessage) }
},
modifier = Modifier.weight(1f),
) {
Text(stringResource(R.string.report_copy))
}
Spacer(Modifier.width(8.dp))
TextButton(
onClick = { saveLauncher.launch("libremail-report.json") },
modifier = Modifier.weight(1f),
) {
Text(stringResource(R.string.report_save))
}
}
Spacer(Modifier.height(8.dp))
TextButton(
onClick = viewModel::discard,
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringResource(R.string.report_discard),
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun PiiDisclaimer() {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(16.dp)) {
Icon(Icons.Filled.Warning, contentDescription = null)
Spacer(Modifier.width(12.dp))
Column {
Text(
stringResource(R.string.report_pii_disclaimer_title),
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.report_pii_disclaimer),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Composable
private fun PayloadBox(payload: String) {
SelectionContainer {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = payload,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(12.dp),
)
}
}
}
@Composable
private fun SubmitStatusText(state: SubmitUiState) {
if (state == SubmitUiState.IDLE) return
val text = when (state) {
SubmitUiState.SUBMITTING -> stringResource(R.string.report_submitting)
SubmitUiState.SUCCEEDED -> stringResource(R.string.report_submitted)
SubmitUiState.FAILED -> stringResource(R.string.report_submit_failed)
SubmitUiState.UNAVAILABLE -> stringResource(R.string.report_submit_unavailable)
SubmitUiState.IDLE -> ""
}
val color = when (state) {
SubmitUiState.SUCCEEDED -> MaterialTheme.colorScheme.primary
SubmitUiState.FAILED, SubmitUiState.UNAVAILABLE -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Spacer(Modifier.height(8.dp))
Text(text, color = color, style = MaterialTheme.typography.bodyMedium)
}
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reporting
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.libremail.reporting.ReportStore
import org.libremail.reporting.ReportSubmitter
import org.libremail.reporting.SubmitStatus
import org.libremail.ui.navigation.Routes
import javax.inject.Inject
/** UI-facing status of a submission attempt. [UNAVAILABLE] means no endpoint is configured. */
enum class SubmitUiState { IDLE, SUBMITTING, SUCCEEDED, FAILED, UNAVAILABLE }
data class ReportReviewState(
val loaded: Boolean = false,
val exists: Boolean = false,
val payload: String = "",
val comment: String = "",
val canSubmitOnline: Boolean = false,
val submit: SubmitUiState = SubmitUiState.IDLE,
)
@HiltViewModel
class ReportReviewViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val store: ReportStore,
private val submitter: ReportSubmitter,
) : ViewModel() {
private val reportId: String = checkNotNull(savedStateHandle[Routes.REPORT_REVIEW_ARG_ID])
private val comment = MutableStateFlow(store.find(reportId)?.userComment.orEmpty())
private val submitState = MutableStateFlow(SubmitUiState.IDLE)
val state: StateFlow<ReportReviewState> =
combine(store.reports, comment, submitState) { reports, currentComment, submit ->
val report = reports.firstOrNull { it.id == reportId }
ReportReviewState(
loaded = true,
exists = report != null,
// The comment is folded in so the preview is byte-for-byte what a submit would send.
payload = report?.copy(userComment = currentComment)?.toSubmissionPayload().orEmpty(),
comment = currentComment,
canSubmitOnline = submitter.isEnabled,
submit = submit,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), ReportReviewState())
fun updateComment(value: String) {
comment.value = value
}
fun discard() {
viewModelScope.launch { store.delete(reportId) }
}
/**
* The only path that can send a report off-device, and only from an explicit Submit tap. Persists
* the reviewed comment first so the upload matches exactly what was shown, then enqueues the
* worker (unless no endpoint is configured, in which case it steers the user to Copy/Save).
*/
fun submit() {
viewModelScope.launch {
val report = store.find(reportId) ?: return@launch
store.save(report.copy(userComment = comment.value))
if (!submitter.isEnabled) {
submitState.value = SubmitUiState.UNAVAILABLE
return@launch
}
submitter.submit(reportId)
submitState.value = SubmitUiState.SUBMITTING
submitter.status(reportId).collect { submitState.value = it.toUi() }
}
}
/** The exact text shown for review — used for Copy and Save-to-file. */
fun payload(): String = store.find(reportId)?.copy(userComment = comment.value)?.toSubmissionPayload().orEmpty()
private fun SubmitStatus.toUi(): SubmitUiState = when (this) {
SubmitStatus.IDLE, SubmitStatus.SUBMITTING -> SubmitUiState.SUBMITTING
SubmitStatus.SUCCEEDED -> SubmitUiState.SUCCEEDED
SubmitStatus.FAILED -> SubmitUiState.FAILED
}
private companion object {
const val SUBSCRIBE_MS = 5_000L
}
}
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reporting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.libremail.reporting.ReportKind
import org.libremail.reporting.ReportStore
import javax.inject.Inject
/** Surfaces a pending crash report (if any) so the app can offer it for review on launch. */
@HiltViewModel
class StartupReportViewModel @Inject constructor(private val store: ReportStore) : ViewModel() {
private val dismissed = MutableStateFlow(false)
val pendingCrash: StateFlow<ReportSummary?> =
combine(store.reports, dismissed) { reports, isDismissed ->
if (isDismissed) {
null
} else {
reports.firstOrNull { it.kind == ReportKind.CRASH }
?.let { ReportSummary(it.id, it.kind, it.createdAtMillis) }
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), null)
/** Hides the prompt for this launch; the report stays saved and is offered again next launch. */
fun dismiss() {
dismissed.value = true
}
fun discard(id: String) {
dismissed.value = true
viewModelScope.launch { store.delete(id) }
}
private companion object {
const val SUBSCRIBE_MS = 5_000L
}
}
@@ -6,7 +6,6 @@ import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -17,7 +16,6 @@ import androidx.compose.material3.HorizontalDivider
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.Text
import androidx.compose.material3.TopAppBar
@@ -26,17 +24,21 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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 AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewModel = hiltViewModel()) {
fun AccountSettingsScreen(
onBack: () -> Unit,
onManageSignatures: () -> Unit,
viewModel: AccountSettingsViewModel = hiltViewModel(),
) {
val account by viewModel.account.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val signature by viewModel.signature.collectAsStateWithLifecycle()
val signatureCount by viewModel.signatureCount.collectAsStateWithLifecycle()
val defaultSignatureName by viewModel.defaultSignatureName.collectAsStateWithLifecycle()
val context = LocalContext.current
val fallbackTitle = stringResource(R.string.settings_account_title)
@@ -67,15 +69,14 @@ fun AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewMode
checked = settings.signatureEnabled,
onCheckedChange = viewModel::setSignatureEnabled,
)
OutlinedTextField(
value = signature ?: "",
onValueChange = viewModel::onSignatureChange,
enabled = settings.signatureEnabled,
label = { Text(stringResource(R.string.settings_signature_hint)) },
minLines = 3,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
ClickRow(
title = stringResource(R.string.settings_signatures_manage),
subtitle = if (signatureCount == 0) {
stringResource(R.string.settings_signatures_summary_none)
} else {
stringResource(R.string.settings_signatures_summary, signatureCount, defaultSignatureName)
},
onClick = onManageSignatures,
)
HorizontalDivider()
@@ -5,15 +5,13 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AccountSettings
import org.libremail.domain.repository.AccountRepository
@@ -26,6 +24,7 @@ class AccountSettingsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val accountRepository: AccountRepository,
private val accountSettingsRepository: AccountSettingsRepository,
signatureRepository: SignatureRepository,
) : ViewModel() {
private val accountId: String =
@@ -38,26 +37,20 @@ class AccountSettingsViewModel @Inject constructor(
val settings: StateFlow<AccountSettings> = accountSettingsRepository.observe(accountId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AccountSettings(accountId))
// The signature text is edited locally (seeded once from persistence) so the field stays
// responsive — a fully DB-driven value would lag each keystroke and jump the cursor.
private val _signature = MutableStateFlow<String?>(null)
val signature: StateFlow<String?> = _signature.asStateFlow()
private val signaturesFlow = signatureRepository.observeForAccount(accountId)
val signatureCount: StateFlow<Int> = signaturesFlow
.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
/** The name of the account's default signature (for the settings summary line), or "". */
val defaultSignatureName: StateFlow<String> = signaturesFlow
.map { list -> list.firstOrNull { it.isDefault }?.name ?: list.firstOrNull()?.name ?: "" }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "")
/** This account's notification channel id, for deep-linking into Android's system settings. */
val notificationChannelId: String = MailNotifier.channelId(accountId)
init {
viewModelScope.launch {
val loaded = accountSettingsRepository.get(accountId).signature
_signature.update { it ?: loaded } // don't clobber any text typed before the load returned
}
}
fun onSignatureChange(value: String) {
_signature.value = value
viewModelScope.launch { accountSettingsRepository.setSignature(accountId, value) }
}
fun setSignatureEnabled(value: Boolean) {
viewModelScope.launch { accountSettingsRepository.setSignatureEnabled(accountId, value) }
}
@@ -34,6 +34,8 @@ import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.data.settings.FetchPolicy
@@ -46,15 +48,17 @@ fun SettingsScreen(
onAddAccount: () -> Unit,
onOpenAccount: (String) -> Unit,
onSelectTab: (TopDest) -> Unit,
onReportProblem: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val settings by viewModel.settings.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val advancedExpanded by viewModel.advancedExpanded.collectAsStateWithLifecycle()
val appLockMessage by viewModel.appLockMessage.collectAsStateWithLifecycle()
val batteryUnrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
val context = LocalContext.current
val resources = LocalResources.current
LaunchedEffect(appLockMessage) {
appLockMessage?.let {
Toast.makeText(context, resources.getString(it), Toast.LENGTH_LONG).show()
@@ -62,6 +66,9 @@ fun SettingsScreen(
}
}
// Re-read the battery status on resume so it reflects any change made in system settings.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() }
Scaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) },
bottomBar = { LibreMailBottomBar(current = TopDest.SETTINGS, onSelect = onSelectTab) },
@@ -127,6 +134,23 @@ fun SettingsScreen(
)
HorizontalDivider()
SectionHeader(stringResource(R.string.settings_diagnostics))
ClickRow(
title = stringResource(R.string.settings_report_problem),
subtitle = stringResource(R.string.settings_report_problem_summary),
onClick = onReportProblem,
)
HorizontalDivider()
SectionHeader(stringResource(R.string.settings_backup))
SwitchRow(
title = stringResource(R.string.settings_backup_include),
checked = settings.includeInBackup,
onCheckedChange = viewModel::setIncludeInBackup,
subtitle = stringResource(R.string.settings_backup_include_summary),
)
HorizontalDivider()
AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced)
AnimatedVisibility(visible = advancedExpanded) {
Column {
@@ -135,6 +159,17 @@ fun SettingsScreen(
checked = settings.pushIdle,
onCheckedChange = viewModel::setPushIdle,
)
ClickRow(
title = stringResource(R.string.settings_adv_battery),
subtitle = stringResource(
if (batteryUnrestricted) {
R.string.settings_adv_battery_unrestricted
} else {
R.string.settings_adv_battery_optimized
},
),
onClick = { runCatching { context.startActivity(viewModel.batterySettingsIntent()) } },
)
SwitchRow(
title = stringResource(R.string.settings_adv_starttls),
checked = settings.allowStartTls,
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -20,6 +21,7 @@ import org.libremail.data.settings.FetchPolicy
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.repository.AccountRepository
import org.libremail.push.BatteryOptimizationManager
import javax.inject.Inject
@HiltViewModel
@@ -28,6 +30,7 @@ class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val appLockManager: AppLockManager,
private val databaseKeyStore: DatabaseKeyStore,
private val batteryOptimizationManager: BatteryOptimizationManager,
) : ViewModel() {
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
@@ -43,14 +46,28 @@ class SettingsViewModel @Inject constructor(
private val _appLockMessage = MutableStateFlow<Int?>(null)
val appLockMessage: StateFlow<Int?> = _appLockMessage.asStateFlow()
private val _batteryUnrestricted = MutableStateFlow(batteryOptimizationManager.isIgnoringBatteryOptimizations())
/** Whether this app is exempt from battery optimization ("Unrestricted"). */
val batteryUnrestricted: StateFlow<Boolean> = _batteryUnrestricted.asStateFlow()
fun toggleAdvanced() = _advancedExpanded.update { !it }
/** Re-read the battery-optimization status; call when the screen resumes (e.g. back from Settings). */
fun refreshBatteryStatus() {
_batteryUnrestricted.value = batteryOptimizationManager.isIgnoringBatteryOptimizations()
}
/** Intent to the system screen where the user flips this app to "Unrestricted". */
fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent()
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) }
fun setIncludeInBackup(value: Boolean) = update { settingsRepository.setIncludeInBackup(value) }
fun setFetchPolicy(value: FetchPolicy) = update { settingsRepository.setFetchPolicy(value) }
/**
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.ui.compose.RichTextBodyField
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SignatureEditScreen(onBack: () -> Unit, viewModel: SignatureEditViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(
if (viewModel.isNew) R.string.signature_new_title else R.string.signature_edit_title,
),
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
actions = {
TextButton(onClick = { viewModel.save(onBack) }) {
Text(stringResource(R.string.signature_save))
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text(stringResource(R.string.signature_name)) },
singleLine = true,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
// Reuse the compose editor so signatures are rich in exactly the same way message bodies are.
if (state.loaded) {
RichTextBodyField(
body = state.body,
bodyHtml = state.bodyHtml,
onBodyChange = viewModel::onBodyChange,
label = stringResource(R.string.signature_content),
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
}
}
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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.data.settings.SignatureRepository
import org.libremail.richtext.RichTextContent
import org.libremail.richtext.RichTextHtml
import org.libremail.ui.navigation.Routes
import javax.inject.Inject
data class SignatureEditUiState(
val name: String = "",
val body: String = "",
val bodyHtml: String? = null,
val loaded: Boolean = false,
)
@HiltViewModel
class SignatureEditViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val signatureRepository: SignatureRepository,
) : ViewModel() {
private val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURE_EDIT_ARG_ACCOUNT])
private val signatureId: String? =
savedStateHandle.get<String>(Routes.SIGNATURE_EDIT_ARG_ID)?.takeIf { it.isNotBlank() }
val isNew: Boolean = signatureId == null
private val _state = MutableStateFlow(SignatureEditUiState(loaded = isNew))
val state: StateFlow<SignatureEditUiState> = _state.asStateFlow()
init {
if (signatureId != null) {
viewModelScope.launch {
signatureRepository.get(signatureId)?.let { signature ->
_state.update {
it.copy(
name = signature.name,
body = signature.plainText(),
bodyHtml = signature.html.ifBlank { null },
loaded = true,
)
}
}
}
}
}
fun onNameChange(value: String) = _state.update { it.copy(name = value) }
fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) }
/** Persists the signature (create or update), then invokes [onSaved]. */
fun save(onSaved: () -> Unit) {
val s = _state.value
val name = s.name.trim().ifBlank { DEFAULT_NAME }
// Store real HTML so the signature round-trips; derive it from the plaintext when unformatted.
val html = s.bodyHtml ?: RichTextHtml.toHtml(RichTextContent(s.body))
viewModelScope.launch {
if (signatureId == null) {
signatureRepository.create(accountId, name, html)
} else {
signatureRepository.update(signatureId, name, html)
}
onSaved()
}
}
private companion object {
const val DEFAULT_NAME = "Signature"
}
}
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
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.Signature
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SignaturesScreen(
onBack: () -> Unit,
onEdit: (signatureId: String) -> Unit,
onAdd: () -> Unit,
viewModel: SignaturesViewModel = hiltViewModel(),
) {
val signatures by viewModel.signatures.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.signatures_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
)
},
floatingActionButton = {
FloatingActionButton(onClick = onAdd) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.signatures_add))
}
},
) { padding ->
if (signatures.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) {
Text(
stringResource(R.string.signatures_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(signatures, key = { it.id }) { signature ->
SignatureRow(
signature = signature,
onClick = { onEdit(signature.id) },
onMakeDefault = { viewModel.setDefault(signature.id) },
onDelete = { viewModel.delete(signature.id) },
)
}
}
}
}
}
@Composable
private fun SignatureRow(signature: Signature, onClick: () -> Unit, onMakeDefault: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
RadioButton(
selected = signature.isDefault,
onClick = onMakeDefault,
)
Column(Modifier.weight(1f)) {
Text(
signature.name.ifBlank { stringResource(R.string.signature_content) },
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val preview = signature.plainText().replace('\n', ' ').trim()
if (preview.isNotEmpty()) {
Text(
preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (signature.isDefault) {
Text(
stringResource(R.string.signature_default_badge),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.signature_delete))
}
}
}
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Signature
import org.libremail.ui.navigation.Routes
import javax.inject.Inject
@HiltViewModel
class SignaturesViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val signatureRepository: SignatureRepository,
) : ViewModel() {
val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURES_ARG_ACCOUNT])
val signatures: StateFlow<List<Signature>> = signatureRepository.observeForAccount(accountId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setDefault(id: String) {
viewModelScope.launch { signatureRepository.setDefault(accountId, id) }
}
fun delete(id: String) {
viewModelScope.launch { signatureRepository.delete(id) }
}
}
+110 -6
View File
@@ -30,8 +30,6 @@
<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>
<!-- Mailbox multi-select contextual action bar -->
<string name="cab_selected_count">%1$d selected</string>
@@ -71,12 +69,26 @@
<string name="compose_from">From</string>
<string name="compose_to">To</string>
<string name="compose_cc">Cc</string>
<string name="compose_bcc">Bcc</string>
<string name="compose_subject">Subject</string>
<string name="compose_body">Message</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>
<!-- Compose formatting toolbar -->
<string name="format_bold">Bold</string>
<string name="format_italic">Italic</string>
<string name="format_underline">Underline</string>
<string name="format_bullet_list">Bulleted list</string>
<string name="format_numbered_list">Numbered list</string>
<string name="format_quote">Block quote</string>
<string name="format_link">Insert link</string>
<string name="format_link_title">Insert link</string>
<string name="format_link_url">Link address</string>
<string name="format_link_apply">Link</string>
<string name="format_link_needs_selection">Select some text first, then add a link.</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
<string name="drafts_empty">No drafts</string>
@@ -102,10 +114,46 @@
<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_outlook">Sign in with Microsoft</string>
<!-- Onboarding -->
<string name="onboarding_welcome_title">Welcome to LibreMail</string>
<string name="onboarding_welcome_subtitle">A private, open-source home for all your email. Add your first account to get started.</string>
<string name="onboarding_add_account">Add an email account</string>
<string name="onboarding_account_added_title">Account added</string>
<string name="onboarding_add_another_prompt">Would you like to add another email account?</string>
<string name="onboarding_add_another_yes">Add another account</string>
<string name="onboarding_add_another_no">No, take me to my inbox</string>
<!-- Onboarding: unrestricted background/battery opt-in (#49) -->
<string name="onboarding_battery_title">Get mail the instant it arrives</string>
<string name="onboarding_battery_body">LibreMail keeps a lightweight connection open to deliver mail the moment it lands. Android\'s battery optimization can pause or close that connection, so messages may show up late — or only when you open the app.</string>
<string name="onboarding_battery_guidance">On the next screen, open Battery and choose “Unrestricted”.</string>
<string name="onboarding_battery_take_me">Take me there</string>
<string name="onboarding_battery_not_now">Not now</string>
<string name="onboarding_battery_done_title">You\'re all set</string>
<string name="onboarding_battery_done_body">Background usage is unrestricted — new mail will arrive instantly.</string>
<string name="onboarding_battery_continue">Continue to inbox</string>
<!-- Account setup (vendor picker) -->
<string name="account_setup_outlook">Outlook or Hotmail</string>
<string name="account_setup_other">Other (IMAP/SMTP)</string>
<string name="account_setup_subtitle">Choose how you want to connect your mailbox.</string>
<string name="account_setup_subtitle">Choose your email provider to get started.</string>
<!-- App-password guided setup (Gmail/Yahoo/iCloud) -->
<string name="app_password_title">Connect %1$s</string>
<string name="app_password_unknown_provider">Unknown email provider.</string>
<string name="app_password_intro_gmail">To connect Gmail, create an app password in your Google Account. Gmail requires 2-Step Verification to be turned on before you can create one.</string>
<string name="app_password_intro_yahoo">To connect Yahoo Mail, generate an app password from your Yahoo Account security settings.</string>
<string name="app_password_intro_icloud">To connect iCloud Mail, create an app-specific password from your Apple ID account page.</string>
<string name="app_password_what_is">An app password is a one-off password that lets an app sign in to your account without your main password or a two-factor code.</string>
<string name="app_password_warning">Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device.</string>
<string name="app_password_open_page">Create an app password for %1$s</string>
<string name="app_password_open_failed">Couldn\'t open your browser</string>
<string name="app_password_email">Email address</string>
<string name="app_password_field">App password</string>
<string name="app_password_show_servers">Server settings</string>
<string name="app_password_server_imap">Incoming (IMAP): %1$s:%2$d (%3$s)</string>
<string name="app_password_server_smtp">Outgoing (SMTP): %1$s:%2$d (%3$s)</string>
<string name="app_password_test_and_add">Test &amp; add account</string>
<!-- Manual IMAP/SMTP setup -->
<string name="manual_setup_title">IMAP / SMTP</string>
@@ -143,11 +191,33 @@
<string name="settings_no_accounts">No accounts yet</string>
<string name="account_remove">Remove account</string>
<!-- Backup -->
<string name="settings_backup">Backup</string>
<string name="settings_backup_include">Include settings in Android Backup</string>
<string name="settings_backup_include_summary">Let Android back up your LibreMail preferences (Google Auto Backup) so they restore when you set up a new device. Your mail, accounts, passwords, and encryption keys are never backed up — only app settings. Off by default; uses Google infrastructure.</string>
<!-- Per-account settings -->
<string name="settings_account_title">Account</string>
<string name="settings_signature">Signature</string>
<string name="settings_signature_enable">Append signature</string>
<string name="settings_signature_enable">Append signature automatically</string>
<string name="settings_signature_hint">Your signature</string>
<string name="settings_signatures_manage">Manage signatures</string>
<string name="settings_signatures_summary_none">No signatures yet</string>
<string name="settings_signatures_summary">%1$d signature(s), default: %2$s</string>
<!-- Signatures management -->
<string name="signatures_title">Signatures</string>
<string name="signatures_empty">No signatures yet. Add one to append it to your messages.</string>
<string name="signatures_add">Add signature</string>
<string name="signature_new_title">New signature</string>
<string name="signature_edit_title">Edit signature</string>
<string name="signature_name">Name</string>
<string name="signature_content">Signature</string>
<string name="signature_save">Save</string>
<string name="signature_delete">Delete signature</string>
<string name="signature_default">Default</string>
<string name="signature_make_default">Set as default</string>
<string name="signature_default_badge">Default</string>
<string name="settings_account_notifications">Notifications</string>
<string name="settings_account_new_mail">New mail notifications</string>
<string name="settings_account_new_mail_summary">Show notifications for this account</string>
@@ -176,4 +246,38 @@
<string name="app_lock_prompt_subtitle">Confirm your screen lock to decrypt your mail.</string>
<string name="app_lock_needs_device_lock">Set up a device screen lock in Android settings before enabling this.</string>
<string name="app_lock_disable_failed">Couldn\'t turn off the screen lock right now. Please try again.</string>
<string name="settings_adv_battery">Background battery usage</string>
<string name="settings_adv_battery_unrestricted">Unrestricted — instant background mail is allowed.</string>
<string name="settings_adv_battery_optimized">Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage.</string>
<!-- Diagnostics / debug reporting -->
<string name="settings_diagnostics">Diagnostics</string>
<string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_summary">Capture diagnostics you can review and optionally send</string>
<string name="reports_title">Problem reports</string>
<string name="reports_empty">No problem reports yet</string>
<string name="reports_create">Create a report</string>
<string name="report_kind_crash">Crash report</string>
<string name="report_kind_manual">Manual report</string>
<string name="report_review_title">Review report</string>
<string name="report_pii_disclaimer_title">May contain personal information</string>
<string name="report_pii_disclaimer">This report can include email addresses, server names, and other details from your device. Read the whole thing below before sending. Nothing is sent unless you tap Submit.</string>
<string name="report_comment_label">What went wrong? (optional)</string>
<string name="report_payload_label">Exactly what will be sent</string>
<string name="report_submit">Submit</string>
<string name="report_discard">Discard report</string>
<string name="report_copy">Copy</string>
<string name="report_save">Save to file</string>
<string name="report_submitting">Submitting…</string>
<string name="report_submitted">Report sent. Thank you!</string>
<string name="report_submit_failed">Couldn\'t send — it will be retried.</string>
<string name="report_submit_unavailable">Online submission isn\'t available in this build. Use Copy or Save to share the report.</string>
<string name="report_copied">Copied to clipboard</string>
<string name="report_saved">Saved</string>
<string name="crash_prompt_title">LibreMail closed unexpectedly</string>
<string name="crash_prompt_message">A problem report from the last crash is ready for you to review. Nothing is sent automatically.</string>
<string name="crash_prompt_review">Review</string>
<string name="crash_prompt_later">Not now</string>
<string name="crash_prompt_discard">Discard</string>
</resources>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Auto Backup allowlist for API 29-30 (pre-Android 12), mirroring res/xml/data_extraction_rules.xml
which applies on API 31+. Backup is still gated by LibreMailBackupAgent (opt-in, OFF by default).
<include> makes this a strict allowlist: ONLY the libremail_settings DataStore is backed up. The
Keystore-sealed cache passphrase (datastore/libremail_dbkey.preferences_pb) and the encrypted
credentials + mail-cache database (libremail.db and its -wal/-shm/-journal side files) are kept
off-device by being omitted from the allowlist; the mail cache re-downloads on the next sync and
accounts are re-added on a new device. (Lint's FullBackupContent rule forbids <exclude> paths
outside an <include>, so exclusion is expressed by omission rather than explicit <exclude> entries.)
-->
<full-backup-content>
<include domain="file" path="datastore/libremail_settings.preferences_pb" />
</full-backup-content>
+16 -7
View File
@@ -1,17 +1,26 @@
<?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.
Android Backup allowlist for LibreMail (API 31+). Backup is opt-in and OFF by default:
LibreMailBackupAgent only lets a backup or device transfer run once the user enables "Include
settings in Android Backup", so nothing here leaves the device unless explicitly chosen.
<include> makes each section a strict allowlist: ONLY the listed path — the libremail_settings
DataStore of re-creatable user preferences — is ever backed up. Everything else is excluded by
omission, which deliberately keeps these off-device:
- datastore/libremail_dbkey.preferences_pb: the Keystore-sealed SQLCipher passphrase for the
encrypted cache. The wrapping Keystore key is non-exportable and device-bound, so the
ciphertext is useless anywhere else.
- libremail.db (+ -wal/-shm/-journal): encrypted IMAP/OAuth credentials and the cached mail.
The cache re-downloads on the next sync; accounts are re-added on a new device.
(Lint's FullBackupContent rule forbids <exclude> paths outside an <include>, so the exclusions are
expressed by simply not listing those paths rather than as explicit <exclude> entries.)
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<include domain="file" path="datastore/libremail_settings.preferences_pb" />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<include domain="file" path="datastore/libremail_settings.preferences_pb" />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import org.junit.Test
import org.libremail.data.settings.AppSettings
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BackupPolicyTest {
@Test
fun `backup is off by default`() {
assertFalse(AppSettings().includeInBackup, "the opt-in default must be off")
assertFalse(BackupPolicy.shouldBackUp(AppSettings()), "no backup runs without opting in")
}
@Test
fun `backup runs only when the user opts in`() {
assertTrue(BackupPolicy.shouldBackUp(AppSettings(includeInBackup = true)))
assertFalse(BackupPolicy.shouldBackUp(AppSettings(includeInBackup = false)))
}
@Test
fun `only the settings datastore is eligible for backup`() {
assertEquals("datastore/libremail_settings.preferences_pb", BackupPolicy.SAFE_SETTINGS_FILE)
// The safe file must not be, or resemble, a secret store.
assertFalse(BackupPolicy.SAFE_SETTINGS_FILE.contains("dbkey"))
}
@Test
fun `the keystore-sealed db key is never eligible for backup`() {
assertTrue(
BackupPolicy.EXCLUDED_FILE_PATHS.any { it.contains("libremail_dbkey") },
"the sealed cache passphrase DataStore must be excluded",
)
}
@Test
fun `the credentials and mail-cache database is never eligible for backup`() {
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db"))
// WAL/SHM/journal side-files can hold recently written rows too.
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-wal"))
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-shm"))
}
}
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import org.junit.Test
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Validates the shipped Android Backup rule resources directly, so they can't silently drift from
* [BackupPolicy] or from the acceptance criteria of issue #21: only the settings DataStore may be
* eligible, and the Keystore-sealed cache key plus the credentials/mail database must be excluded.
*/
class DataExtractionRulesTest {
private data class Rules(val includes: Set<String>, val excludes: Set<String>)
private fun resource(name: String): File {
// Gradle runs unit tests with the module dir (app/) as the working dir; fall back to the repo
// root in case a runner starts elsewhere.
val candidates = listOf(
File("src/main/res/xml/$name"),
File("app/src/main/res/xml/$name"),
)
return candidates.firstOrNull { it.exists() }
?: error("Could not locate $name; looked in ${candidates.map { it.absolutePath }}")
}
/** Collects the `domain:path` pairs of every <include>/<exclude> under the given section element. */
private fun parseSection(file: File, sectionTag: String): Rules {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
val section = doc.getElementsByTagName(sectionTag).item(0) as Element
fun collect(tag: String): Set<String> {
val nodes = section.getElementsByTagName(tag)
return (0 until nodes.length).map { i ->
val e = nodes.item(i) as Element
"${e.getAttribute("domain")}:${e.getAttribute("path")}"
}.toSet()
}
return Rules(includes = collect("include"), excludes = collect("exclude"))
}
private val safeFile = "file:${BackupPolicy.SAFE_SETTINGS_FILE}"
private val secretPaths: List<String> =
BackupPolicy.EXCLUDED_FILE_PATHS.map { "file:$it" } +
BackupPolicy.EXCLUDED_DATABASE_PATHS.map { "database:$it" }
private fun assertSafe(rules: Rules) {
// Strict allowlist: the settings DataStore is the ONLY thing eligible for backup/transfer.
// Everything else — crucially the Keystore-sealed cache key and the credentials/mail
// database — is excluded simply by not being listed.
assertEquals(setOf(safeFile), rules.includes, "only the settings DataStore may be backed up")
assertTrue(rules.excludes.isEmpty(), "rules are allowlist-only; no <exclude> entries expected")
secretPaths.forEach { secret ->
assertFalse(secret in rules.includes, "$secret must never be eligible for backup")
}
}
@Test
fun `data extraction rules (API 31+) back up only settings for cloud backup`() {
assertSafe(parseSection(resource("data_extraction_rules.xml"), "cloud-backup"))
}
@Test
fun `data extraction rules (API 31+) back up only settings for device transfer`() {
assertSafe(parseSection(resource("data_extraction_rules.xml"), "device-transfer"))
}
@Test
fun `full backup content (API 29-30) mirrors the same exclusions`() {
assertSafe(parseSection(resource("backup_rules.xml"), "full-backup-content"))
}
}
@@ -105,4 +105,26 @@ class ReplyBuilderTest {
assertTrue(result.body.contains("Hello"), "body=${result.body}")
assertTrue(result.body.contains("there"), "body=${result.body}")
}
@Test
fun `html original is quoted into a blockquote without leaking original tags`() {
val result = ReplyBuilder.build(
context(body = "<p>Hello <b>there</b></p>", isHtml = true),
ReplyMode.REPLY,
"me@example.org",
)
// The HTML alternative wraps the (tag-stripped) original in a blockquote — never raw tags.
assertTrue(result.bodyHtml.contains("<blockquote>"), "html=${result.bodyHtml}")
assertTrue(result.bodyHtml.contains("Hello there"), "html=${result.bodyHtml}")
assertFalse(result.bodyHtml.contains("<p>Hello"), "html=${result.bodyHtml}")
}
@Test
fun `plaintext reply also carries an html blockquote alternative`() {
val result = ReplyBuilder.build(context(body = "First line\nSecond line"), ReplyMode.REPLY, "me@example.org")
assertTrue(result.bodyHtml.contains("<blockquote>"), "html=${result.bodyHtml}")
assertTrue(result.bodyHtml.contains("First line"), "html=${result.bodyHtml}")
}
}
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data
import org.junit.Test
import org.libremail.domain.model.Signature
import org.libremail.richtext.RichTextHtml
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SignatureBlockTest {
private fun signature(html: String) =
Signature(id = "s", accountId = "a", name = "Sig", html = html, isDefault = true)
@Test
fun `plain form opens with the RFC 3676 delimiter`() {
val block = SignatureBlock.of(signature("Cheers, Alice"))
assertEquals("\n\n-- \nCheers, Alice", block.plain)
}
@Test
fun `html form parses back to exactly the plain form`() {
val block = SignatureBlock.of(signature("Cheers, Alice"))
assertEquals(block.plain, RichTextHtml.fromHtml(block.html).text)
}
@Test
fun `a rich signature keeps its formatting in the html form`() {
val block = SignatureBlock.of(signature("Cheers, <b>Alice</b>"))
assertTrue(block.html.endsWith("Cheers, <b>Alice</b>"), block.html)
assertEquals("\n\n-- \nCheers, Alice", block.plain)
}
@Test
fun `null or blank signature yields the empty block`() {
assertTrue(SignatureBlock.of(null).isEmpty)
assertTrue(SignatureBlock.of(signature("")).isEmpty)
}
}
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import org.junit.Test
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.domain.model.Draft
import kotlin.test.assertEquals
import kotlin.test.assertNull
class MappersHtmlBodyTest {
@Test
fun `draft html body round-trips through the entity`() {
val draft = Draft(
id = "d1",
accountId = "acct",
to = "a@x.com",
cc = "",
subject = "Hi",
body = "Hello",
updatedAt = 1L,
bodyHtml = "<p>Hello <b>there</b></p>",
)
val restored = draft.toEntity().toDomain()
assertEquals("<p>Hello <b>there</b></p>", restored.bodyHtml)
assertEquals("Hello", restored.body)
}
@Test
fun `a plaintext draft keeps a null html body`() {
val draft = Draft(
id = "d2",
accountId = "acct",
to = "a@x.com",
cc = "",
subject = "Hi",
body = "Hello",
updatedAt = 1L,
)
assertNull(draft.toEntity().toDomain().bodyHtml)
}
@Test
fun `outbox entity maps its html body to the domain`() {
val entity = OutboxEntity(
id = "o1",
accountId = "acct",
toAddresses = "a@x.com",
ccAddresses = "",
subject = "Hi",
body = "Hello",
createdAt = 1L,
bodyHtml = "<p>Hi</p>",
)
assertEquals("<p>Hi</p>", entity.toDomain().bodyHtml)
}
}
@@ -25,6 +25,7 @@ import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SignatureRepository
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.domain.model.AccountSettings
import org.libremail.domain.model.FolderRole
@@ -54,6 +55,7 @@ class MailRepositoryImplTest {
private val connectionFactory = mockk<MailConnectionFactory>()
private val context = mockk<Context>(relaxed = true)
private val accountSettingsRepository = mockk<AccountSettingsRepository>()
private val signatureRepository = mockk<SignatureRepository>()
private val repository = MailRepositoryImpl(
context = context,
messageDao = messageDao,
@@ -66,6 +68,7 @@ class MailRepositoryImplTest {
connectionFactory = connectionFactory,
sendScheduler = mockk(),
accountSettingsRepository = accountSettingsRepository,
signatureRepository = signatureRepository,
)
@Test
@@ -202,6 +205,7 @@ class MailRepositoryImplTest {
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
coEvery { signatureRepository.getDefault(any()) } returns null
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
coEvery { imapClient.fetchForReply(any(), "INBOX", "2") } returns ReplyContext(
fromEmail = "boss@example.org",
@@ -220,6 +224,42 @@ class MailRepositoryImplTest {
assertTrue(result.isSuccess)
assertEquals("boss@example.org", draft.captured.toAddresses)
assertEquals("Re: Plan", draft.captured.subject)
// The reply carries an HTML alternative with the quote rendered as a blockquote.
assertTrue(draft.captured.bodyHtml?.contains("<blockquote>") == true, "html=${draft.captured.bodyHtml}")
}
@Test
fun `buildReplyDraft bakes the account default signature above the quote`() = runTest {
val id = "acct:INBOX:3"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
coEvery { signatureRepository.getDefault("acct") } returns org.libremail.domain.model.Signature(
id = "acct:sig",
accountId = "acct",
name = "Signature",
html = "Regards, Ada",
isDefault = true,
)
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
coEvery { imapClient.fetchForReply(any(), "INBOX", "3") } returns ReplyContext(
fromEmail = "boss@example.org",
toRecipients = listOf("ada@example.org"),
ccRecipients = emptyList(),
subject = "Plan",
sentDateMillis = 0L,
body = "Original",
isHtml = false,
)
val draft = slot<DraftEntity>()
coEvery { draftDao.upsert(capture(draft)) } just Runs
repository.buildReplyDraft(id, ReplyMode.REPLY)
val body = draft.captured.body
// Signature is placed before (above) the quoted original.
assertTrue(body.contains("Regards, Ada"), "body=$body")
assertTrue(body.indexOf("Regards, Ada") < body.indexOf("> Original"), "body=$body")
}
@Test
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.settings
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.data.local.dao.SignatureDao
import org.libremail.data.local.entity.SignatureEntity
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SignatureRepositoryTest {
private val dao = mockk<SignatureDao>(relaxed = true)
private val repository = SignatureRepository(dao)
private fun entity(id: String, isDefault: Boolean) =
SignatureEntity(id, accountId = "acct", name = "N", contentHtml = "<p>x</p>", isDefault = isDefault)
@Test
fun `the first signature for an account becomes its default`() = runTest {
coEvery { dao.countForAccount("acct") } returns 0
val saved = slot<SignatureEntity>()
coEvery { dao.upsert(capture(saved)) } just Runs
repository.create("acct", "Work", "<p>hi</p>")
assertTrue(saved.captured.isDefault)
}
@Test
fun `later signatures are not made default`() = runTest {
coEvery { dao.countForAccount("acct") } returns 2
val saved = slot<SignatureEntity>()
coEvery { dao.upsert(capture(saved)) } just Runs
repository.create("acct", "Personal", "<p>hey</p>")
assertFalse(saved.captured.isDefault)
}
@Test
fun `deleting the default promotes the first remaining signature`() = runTest {
coEvery { dao.getById("s1") } returns entity("s1", isDefault = true)
coEvery { dao.firstForAccount("acct") } returns entity("s2", isDefault = false)
repository.delete("s1")
coVerify { dao.delete("s1") }
coVerify { dao.markDefault("s2") }
}
@Test
fun `deleting a non-default signature promotes nothing`() = runTest {
coEvery { dao.getById("s2") } returns entity("s2", isDefault = false)
repository.delete("s2")
coVerify { dao.delete("s2") }
coVerify(exactly = 0) { dao.firstForAccount(any()) }
coVerify(exactly = 0) { dao.markDefault(any()) }
}
@Test
fun `setDefault delegates to the dao's atomic swap`() = runTest {
repository.setDefault("acct", "s1")
coVerify { dao.setDefault("acct", "s1") }
}
}

Some files were not shown because too many files have changed in this diff Show More