diff --git a/README.md b/README.md
index 6cc4ab4..125c37d 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d9a69eb..f064dac 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
}
diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json
new file mode 100644
index 0000000..1f87ef9
--- /dev/null
+++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/11.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/11.json
new file mode 100644
index 0000000..4c24aa3
--- /dev/null
+++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/11.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt
index 6117e48..c73df14 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt
@@ -41,6 +41,11 @@ class FakeAccountRepository(
override suspend fun addImapAccount(account: Account, password: String): Result> {
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
}
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt
index 41780d3..03d0cd2 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt
@@ -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 {
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
index f79725a..97777c4 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
@@ -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) {
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt
index 17d9312..0be3688 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt
@@ -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(
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt
new file mode 100644
index 0000000..a32112a
--- /dev/null
+++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt
@@ -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
`), 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 `
` 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)
"), "\n")
- .replace(Regex("(?i)
` — 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{ + val lines = ArrayList () + 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 , + 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(" - ").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("
") + i++ + } + sb.append("").append(tag).append(">") + return i +} + +private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List, from: Int): Int { + sb.append(" ") + var i = from + while (i < lines.size && lines[i].kind == Kind.QUOTE) { + if (i > from) sb.append("") + return i +} + +private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List
") + sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)) + i++ + } + sb.append(", from: Int): Int { + sb.append(" ") + var i = from + while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) { + if (i > from) sb.append("
") + return i +} + +/** Renders the inline styling/links over [[from], [to]) as nested `///` 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
") + sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)) + i++ + } + sb.append("{ + 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("") + if (RichStyle.BOLD in styles) sb.append("") + if (RichStyle.ITALIC in styles) sb.append("") + if (RichStyle.UNDERLINE in styles) sb.append("") + sb.append(escape(content.text.substring(a, b))) + if (RichStyle.UNDERLINE in styles) sb.append("") + if (RichStyle.ITALIC in styles) sb.append("") + if (RichStyle.BOLD in styles) sb.append("") + if (link != null) sb.append("") +} + +/** + * 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 () + private val links = ArrayList () + 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("&", "&").replace("<", "<").replace(">", ">") + +private fun escapeAttr(s: String): String = escape(s).replace("\"", """) + +private fun unescape(s: String): String = s + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace(" ", " ") + .replace("&", "&") diff --git a/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt b/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt new file mode 100644 index 0000000..0e7a149 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt @@ -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 () + 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 , 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 , start: Int, end: Int): List = 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 ): List { + val merged = ArrayList () + 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 { + val from = start.coerceIn(0, text.length) + val to = end.coerceIn(from, text.length) + val result = ArrayList () + 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 ): Pair 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 ): 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 +} diff --git a/app/src/main/kotlin/org/libremail/ui/AppViewModel.kt b/app/src/main/kotlin/org/libremail/ui/AppViewModel.kt new file mode 100644 index 0000000..a87ed2a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/AppViewModel.kt @@ -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 = accountRepository.observeAccounts() + .map { accounts -> if (accounts.isEmpty()) Routes.ONBOARDING else Routes.MAILBOX } + .take(1) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 0301b0f..6d44535 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -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. */ diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt similarity index 52% rename from app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt rename to app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt index 4ebec0b..aec4d7e 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt index b2281e1..f6552bf 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -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. diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt new file mode 100644 index 0000000..ff910e9 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt @@ -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" +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt new file mode 100644 index 0000000..9964c0a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt @@ -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 (Routes.APP_PASSWORD_ARG_PROVIDER)?.let(MailProvider::fromKey) + + private val _form = MutableStateFlow(AppPasswordForm()) + val form: StateFlow = _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", + ) + } + }, + ) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt index b06fe85..6d111cb 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt @@ -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 { diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt index 95776e5..01b8237 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt @@ -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( diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 50e748b..3acb89f 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -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), ) } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index d37ca90..2ceff23 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -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 = emptyList(), val suggestions: List = 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 (Routes.COMPOSE_ARG_TO).orEmpty(), + cc = savedStateHandle.get (Routes.COMPOSE_ARG_CC).orEmpty(), + bcc = savedStateHandle.get (Routes.COMPOSE_ARG_BCC).orEmpty(), subject = savedStateHandle.get (Routes.COMPOSE_ARG_SUBJECT).orEmpty(), + body = savedStateHandle.get (Routes.COMPOSE_ARG_BODY).orEmpty(), fromAccountId = savedStateHandle.get (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 ) = _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) } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/IntentComposeParser.kt b/app/src/main/kotlin/org/libremail/ui/compose/IntentComposeParser.kt new file mode 100644 index 0000000..ff58729 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/compose/IntentComposeParser.kt @@ -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:" +} diff --git a/app/src/main/kotlin/org/libremail/ui/compose/MailtoParser.kt b/app/src/main/kotlin/org/libremail/ui/compose/MailtoParser.kt new file mode 100644 index 0000000..5839415 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/compose/MailtoParser.kt @@ -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 > { + if (query.isEmpty()) return emptyMap() + val result = mutableMapOf >() + 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 = 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) + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt new file mode 100644 index 0000000..95a51b2 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index bb7061e..5c87165 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -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 , 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( diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 878f4d9..180b9a4 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -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 (Routes.MAILBOX_ARG_ACCOUNT)?.takeIf { it.isNotBlank() } + val accounts: StateFlow > = 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
(null) + private val _selectedAccountId = MutableStateFlow(initialAccountId) val selectedAccountId: StateFlow = _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 = _selectedFolder.asStateFlow() /** Which account's folders the drawer lists. null follows the mailbox selection / first account. */ - private val explicitDrawerAccountId = MutableStateFlow (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 = @@ -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 } diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index 5ddb2d5..169d8a2 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -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)}" } diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreen.kt new file mode 100644 index 0000000..5740b52 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreen.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt new file mode 100644 index 0000000..0314c5a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt @@ -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)) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..bc8706b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt @@ -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 (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 = _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 = _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) } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt new file mode 100644 index 0000000..22e3631 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt new file mode 100644 index 0000000..71367e1 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt new file mode 100644 index 0000000..0b5c175 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt @@ -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 > = 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
(extraBufferCapacity = 1) + val created: SharedFlow = _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 + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt new file mode 100644 index 0000000..559bf64 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt @@ -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) +} diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt new file mode 100644 index 0000000..e42a8b7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt @@ -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 = + 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 + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt new file mode 100644 index 0000000..89cfdec --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt @@ -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 = + 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 + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt index 07e1c38..bdb68b5 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt @@ -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() diff --git a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt index 2436360..733cd74 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt @@ -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 = 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 (null) - val signature: StateFlow = _signature.asStateFlow() + private val signaturesFlow = signatureRepository.observeForAccount(accountId) + + val signatureCount: StateFlow = 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 = 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) } } diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index e069d61..5d8956a 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt index 1b34561..68872cc 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt @@ -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 > = accountRepository.observeAccounts() @@ -43,14 +46,28 @@ class SettingsViewModel @Inject constructor( private val _appLockMessage = MutableStateFlow
(null) val appLockMessage: StateFlow = _appLockMessage.asStateFlow() + private val _batteryUnrestricted = MutableStateFlow(batteryOptimizationManager.isIgnoringBatteryOptimizations()) + + /** Whether this app is exempt from battery optimization ("Unrestricted"). */ + val batteryUnrestricted: StateFlow = _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) } /** diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt new file mode 100644 index 0000000..b656ebd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt new file mode 100644 index 0000000..3bfe833 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt @@ -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 (Routes.SIGNATURE_EDIT_ARG_ID)?.takeIf { it.isNotBlank() } + + val isNew: Boolean = signatureId == null + + private val _state = MutableStateFlow(SignatureEditUiState(loaded = isNew)) + val state: StateFlow = _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" + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt new file mode 100644 index 0000000..25ef189 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt new file mode 100644 index 0000000..baa6b94 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt @@ -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 > = 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) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7a7a4f..e46121e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,8 +30,6 @@
Search mail No results Pull down to refresh -Welcome to LibreMail -Add an account to start reading your mail. %1$d selected @@ -71,12 +69,26 @@From To Cc +Bcc Subject Message Add an account before composing Attach file Remove attachment + +Bold +Italic +Underline +Bulleted list +Numbered list +Block quote +Insert link +Insert link +Link address +Link +Select some text first, then add a link. +Drafts No drafts @@ -102,10 +114,46 @@Couldn\'t download %1$s No app can open this file - -Sign in with Microsoft + +Welcome to LibreMail +A private, open-source home for all your email. Add your first account to get started. +Add an email account +Account added +Would you like to add another email account? +Add another account +No, take me to my inbox + + +Get mail the instant it arrives +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. +On the next screen, open Battery and choose “Unrestricted”. +Take me there +Not now +You\'re all set +Background usage is unrestricted — new mail will arrive instantly. +Continue to inbox + + +Outlook or Hotmail Other (IMAP/SMTP) -Choose how you want to connect your mailbox. +Choose your email provider to get started. + + +Connect %1$s +Unknown email provider. +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. +To connect Yahoo Mail, generate an app password from your Yahoo Account security settings. +To connect iCloud Mail, create an app-specific password from your Apple ID account page. +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. +Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device. +Create an app password for %1$s +Couldn\'t open your browser +Email address +App password +Server settings +Incoming (IMAP): %1$s:%2$d (%3$s) +Outgoing (SMTP): %1$s:%2$d (%3$s) +Test & add account IMAP / SMTP @@ -143,11 +191,33 @@No accounts yet Remove account + +Backup +Include settings in Android Backup +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. +Account Signature -Append signature +Append signature automatically Your signature +Manage signatures +No signatures yet +%1$d signature(s), default: %2$s + + +Signatures +No signatures yet. Add one to append it to your messages. +Add signature +New signature +Edit signature +Name +Signature +Save +Delete signature +Default +Set as default +Default Notifications New mail notifications Show notifications for this account @@ -176,4 +246,38 @@Confirm your screen lock to decrypt your mail. Set up a device screen lock in Android settings before enabling this. Couldn\'t turn off the screen lock right now. Please try again. + +Background battery usage +Unrestricted — instant background mail is allowed. +Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage. + + +Diagnostics +Report a problem +Capture diagnostics you can review and optionally send +Problem reports +No problem reports yet +Create a report +Crash report +Manual report +Review report +May contain personal information +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. +What went wrong? (optional) +Exactly what will be sent +Submit +Discard report +Copy +Save to file +Submitting… +Report sent. Thank you! +Couldn\'t send — it will be retried. +Online submission isn\'t available in this build. Use Copy or Save to share the report. +Copied to clipboard +Saved +LibreMail closed unexpectedly +A problem report from the last crash is ready for you to review. Nothing is sent automatically. +Review +Not now +Discard diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..0059fd1 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,16 @@ + + + ++ diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 39841b1..0f07896 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,17 +1,26 @@+ diff --git a/app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt b/app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt new file mode 100644 index 0000000..c9d0924 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt @@ -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")) + } +} diff --git a/app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt b/app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt new file mode 100644 index 0000000..2de642c --- /dev/null +++ b/app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt @@ -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 - + - + , val excludes: Set ) + + 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 / 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 { + 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 = + 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 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")) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt index 5475d28..49fc6c2 100644 --- a/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt +++ b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt @@ -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 = " Hello there
", 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(""), "html=${result.bodyHtml}") + assertTrue(result.bodyHtml.contains("Hello there"), "html=${result.bodyHtml}") + assertFalse(result.bodyHtml.contains("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("
"), "html=${result.bodyHtml}") + assertTrue(result.bodyHtml.contains("First line"), "html=${result.bodyHtml}") + } } diff --git a/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt b/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt new file mode 100644 index 0000000..b3c7119 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt @@ -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, Alice")) + assertTrue(block.html.endsWith("Cheers, Alice"), 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) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt b/app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt new file mode 100644 index 0000000..825f8b4 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt @@ -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 = "Hello there
", + ) + + val restored = draft.toEntity().toDomain() + + assertEquals("Hello there
", 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 = "Hi
", + ) + + assertEquals("Hi
", entity.toDomain().bodyHtml) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index 56edec2..a40ed20 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -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() private val context = mockk (relaxed = true) private val accountSettingsRepository = mockk () + private val signatureRepository = mockk () 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(" ") == 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() + 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 diff --git a/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt b/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt new file mode 100644 index 0000000..b38c0df --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt @@ -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 (relaxed = true) + private val repository = SignatureRepository(dao) + + private fun entity(id: String, isDefault: Boolean) = + SignatureEntity(id, accountId = "acct", name = "N", contentHtml = " x
", isDefault = isDefault) + + @Test + fun `the first signature for an account becomes its default`() = runTest { + coEvery { dao.countForAccount("acct") } returns 0 + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.create("acct", "Work", " hi
") + + assertTrue(saved.captured.isDefault) + } + + @Test + fun `later signatures are not made default`() = runTest { + coEvery { dao.countForAccount("acct") } returns 2 + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.create("acct", "Personal", " hey
") + + 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") } + } +} diff --git a/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt new file mode 100644 index 0000000..52a8519 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Locks down the preconfigured server settings for the app-password vendors. These are easy to get + * subtly wrong (a swapped port or the wrong transport security) and painful to debug on-device, so + * they are asserted explicitly here rather than trusted to a code review. + */ +class MailProviderTest { + + @Test + fun `gmail preset uses documented imap and starttls smtp endpoints`() { + val account = MailProvider.GMAIL.createAccount("user@gmail.com") + + assertEquals("imap.gmail.com", account.imap.host) + assertEquals(993, account.imap.port) + assertEquals(MailSecurity.SSL_TLS, account.imap.security) + + assertEquals("smtp.gmail.com", account.smtp.host) + assertEquals(587, account.smtp.port) + assertEquals(MailSecurity.STARTTLS, account.smtp.security) + } + + @Test + fun `yahoo preset uses documented imap and implicit-tls smtp endpoints`() { + val account = MailProvider.YAHOO.createAccount("user@yahoo.com") + + assertEquals("imap.mail.yahoo.com", account.imap.host) + assertEquals(993, account.imap.port) + assertEquals(MailSecurity.SSL_TLS, account.imap.security) + + assertEquals("smtp.mail.yahoo.com", account.smtp.host) + assertEquals(465, account.smtp.port) + assertEquals(MailSecurity.SSL_TLS, account.smtp.security) + } + + @Test + fun `icloud preset uses documented imap and starttls smtp endpoints`() { + val account = MailProvider.ICLOUD.createAccount("user@icloud.com") + + assertEquals("imap.mail.me.com", account.imap.host) + assertEquals(993, account.imap.port) + assertEquals(MailSecurity.SSL_TLS, account.imap.security) + + assertEquals("smtp.mail.me.com", account.smtp.host) + assertEquals(587, account.smtp.port) + assertEquals(MailSecurity.STARTTLS, account.smtp.security) + } + + @Test + fun `no provider ever uses insecure transport`() { + MailProvider.entries.forEach { provider -> + val account = provider.createAccount("user@example.com") + assertTrue( + account.imap.security != MailSecurity.NONE, + "${provider.key} IMAP must not use MailSecurity.NONE", + ) + assertTrue( + account.smtp.security != MailSecurity.NONE, + "${provider.key} SMTP must not use MailSecurity.NONE", + ) + } + } + + @Test + fun `every provider resolves to a password-imap account with a help url`() { + MailProvider.entries.forEach { provider -> + val account = provider.createAccount("user@example.com") + assertEquals(AuthType.PASSWORD_IMAP, account.authType) + assertTrue( + provider.appPasswordHelpUrl.startsWith("https://"), + "${provider.key} must expose an https app-password help URL", + ) + } + } + + @Test + fun `createAccount trims the email and derives a stable id and display name`() { + val account = MailProvider.GMAIL.createAccount(" User@Gmail.com ") + + assertEquals("User@Gmail.com", account.email) + assertEquals("imap:User@Gmail.com", account.id) + assertEquals("User@Gmail.com", account.displayName) + } + + @Test + fun `createAccount keeps an explicit non-blank display name`() { + val account = MailProvider.GMAIL.createAccount("user@gmail.com", displayName = "Work") + + assertEquals("Work", account.displayName) + } + + @Test + fun `fromKey looks up providers case-insensitively and returns null for unknowns`() { + assertEquals(MailProvider.GMAIL, MailProvider.fromKey("gmail")) + assertEquals(MailProvider.YAHOO, MailProvider.fromKey("YAHOO")) + assertEquals(MailProvider.ICLOUD, MailProvider.fromKey("iCloud")) + assertNull(MailProvider.fromKey("outlook")) + assertNull(MailProvider.fromKey("")) + } + + @Test + fun `provider keys are unique and lowercase`() { + val keys = MailProvider.entries.map { it.key } + assertEquals(keys.toSet().size, keys.size, "provider keys must be unique") + keys.forEach { key -> assertEquals(key.lowercase(), key, "provider key must be lowercase") } + } + + @Test + fun `display names are present for the picker`() { + MailProvider.entries.forEach { provider -> + assertNotNull(provider.displayName) + assertTrue(provider.displayName.isNotBlank()) + } + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt index 758f3af..fb6a58a 100644 --- a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt @@ -34,6 +34,28 @@ class GraphSenderTest { assertEquals("c@z.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address")) } + @Test + fun `payload uses the HTML content type when a formatted body is present`() { + val message = OutgoingMessage( + accountId = "outlook:me@example.com", + to = "a@x.com", + subject = "Hi", + body = "Hello world", + bodyHtml = "Hello world
", + ) + val body = JSONObject(buildSendMailPayload(message, emptyList())) + .getJSONObject("message").getJSONObject("body") + assertEquals("HTML", body.getString("contentType")) + assertEquals("Hello world
", body.getString("content")) + } + + @Test + fun `payload falls back to plain text when there is no HTML body`() { + val body = JSONObject(buildSendMailPayload(message(to = "a@x.com"), emptyList())) + .getJSONObject("message").getJSONObject("body") + assertEquals("Text", body.getString("contentType")) + } + @Test fun `recipients parse RFC822 display names into bare addresses`() { val json = JSONObject( @@ -57,11 +79,23 @@ class GraphSenderTest { } @Test - fun `payload omits cc when blank and encodes attachments as base64`() { + fun `payload carries bcc recipients when present`() { + val json = JSONObject( + buildSendMailPayload(message(to = "a@x.com").copy(bcc = "hidden@z.com, more@z.com"), emptyList()), + ) + val bcc = json.getJSONObject("message").getJSONArray("bccRecipients") + assertEquals(2, bcc.length()) + assertEquals("hidden@z.com", bcc.getJSONObject(0).getJSONObject("emailAddress").getString("address")) + assertEquals("more@z.com", bcc.getJSONObject(1).getJSONObject("emailAddress").getString("address")) + } + + @Test + fun `payload omits cc and bcc when blank and encodes attachments as base64`() { val file = File.createTempFile("graph-att", ".txt").apply { writeText("hello") } try { val msg = JSONObject(buildSendMailPayload(message(to = "a@x.com"), listOf(file))).getJSONObject("message") assertFalse(msg.has("ccRecipients")) + assertFalse(msg.has("bccRecipients")) val attachments = msg.getJSONArray("attachments") assertEquals(1, attachments.length()) diff --git a/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt new file mode 100644 index 0000000..a5e67be --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HtmlToTextTest { + + @Test + fun `strips inline tags and keeps the words`() { + assertEquals("Hello world", HtmlToText.convert("Hello world
")) + } + + @Test + fun `br and paragraphs become line breaks`() { + assertEquals("Line one\nLine two", HtmlToText.convert("Line one
Line two")) + assertEquals("A\n\nB", HtmlToText.convert("A
B
")) + } + + @Test + fun `list items gain bullet markers`() { + assertEquals("• One\n• Two", HtmlToText.convert("")) + } + + @Test + fun `decodes entities and drops script and style content`() { + assertEquals("Tom & Jerry", HtmlToText.convert("Tom & Jerry")) + val converted = HtmlToText.convert("Safe") + assertEquals("Safe", converted) + assertFalse(converted.contains("alert")) + } + + @Test + fun `collapses excess whitespace`() { + val converted = HtmlToText.convert("
- One
- Two
Hello there
\n\n\nbye
") + assertTrue(converted.contains("Hello there"), converted) + assertFalse(converted.contains("Hello there"), converted) + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt index 32fa157..424fdf4 100644 --- a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt @@ -13,6 +13,7 @@ import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.model.SmtpParams import java.io.File import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class SmtpSenderTest { @@ -61,6 +62,74 @@ class SmtpSenderTest { assertTrue(GreenMailUtil.getBody(received[0]).contains("Hello there")) } + @Test + fun `a formatted message is sent as multipart alternative with both parts`() = runTest { + val params = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "sender@example.org", + secret = "secret", + useXoauth2 = false, + ) + + sender.send( + params = params, + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + subject = "Rich", + body = "Hello world", + bodyHtml = "Hello world
", + ), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages.single() + assertTrue(received.contentType.contains("multipart/alternative", ignoreCase = true), received.contentType) + val raw = GreenMailUtil.getWholeMessage(received) + assertTrue(raw.contains("text/plain", ignoreCase = true), "missing text/plain") + assertTrue(raw.contains("text/html", ignoreCase = true), "missing text/html") + assertTrue(raw.contains("world"), "missing html body") + assertTrue(raw.contains("Hello world"), "missing plaintext fallback") + } + + @Test + fun `a formatted message with an attachment nests the alternative inside mixed`() = runTest { + val file = File.createTempFile("libremail-note", ".txt").apply { writeText("attached note") } + val params = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "sender@example.org", + secret = "secret", + useXoauth2 = false, + ) + + sender.send( + params = params, + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + subject = "Rich + file", + body = "Body", + bodyHtml = "Body
", + ), + attachments = listOf(file), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages.single() + assertTrue(received.contentType.contains("multipart/mixed", ignoreCase = true), received.contentType) + val raw = GreenMailUtil.getWholeMessage(received) + assertTrue(raw.contains("multipart/alternative", ignoreCase = true), "missing alternative part") + assertTrue(raw.contains(file.name), "missing attachment") + assertTrue(raw.contains("Body"), "missing html body") + file.delete() + } + @Test fun `send delivers a message with an attachment`() = runTest { val file = File.createTempFile("libremail-report", ".txt").apply { writeText("quarterly numbers") } @@ -93,4 +162,35 @@ class SmtpSenderTest { assertTrue(raw.contains(file.name)) file.delete() } + + @Test + fun `send delivers to bcc recipients without leaking them in the headers`() = runTest { + val params = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "sender@example.org", + secret = "secret", + useXoauth2 = false, + ) + + sender.send( + params = params, + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + bcc = "eve@example.org", + subject = "FYI", + body = "Blind copy test.", + ), + ) + + // Both the To and Bcc recipients are in the SMTP envelope, so GreenMail delivers two copies. + greenMail.waitForIncomingEmail(2) + val received = greenMail.receivedMessages + assertEquals(2, received.size) + // Jakarta Mail strips the Bcc header before transmission, so it must not appear on the wire. + received.forEach { assertNull(it.getHeader("Bcc")) } + } } diff --git a/app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt b/app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt new file mode 100644 index 0000000..d6dde8d --- /dev/null +++ b/app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BatteryPromptDecisionTest { + + @Test + fun `prompts when supported, not already unrestricted, and not yet handled`() { + assertTrue( + BatteryPromptDecision.shouldPrompt( + supported = true, + alreadyUnrestricted = false, + alreadyHandled = false, + ), + ) + } + + @Test + fun `never prompts when the app is already unrestricted`() { + assertFalse( + BatteryPromptDecision.shouldPrompt(supported = true, alreadyUnrestricted = true, alreadyHandled = false), + ) + } + + @Test + fun `never prompts once the user has handled it`() { + assertFalse( + BatteryPromptDecision.shouldPrompt(supported = true, alreadyUnrestricted = false, alreadyHandled = true), + ) + } + + @Test + fun `never prompts on an unsupported platform, regardless of the other inputs`() { + for (unrestricted in listOf(false, true)) { + for (handled in listOf(false, true)) { + assertFalse( + BatteryPromptDecision.shouldPrompt( + supported = false, + alreadyUnrestricted = unrestricted, + alreadyHandled = handled, + ), + "supported=false must never prompt (unrestricted=$unrestricted, handled=$handled)", + ) + } + } + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt b/app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt new file mode 100644 index 0000000..b09dd3a --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.libremail.data.settings.SettingsRepository +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CrashReporterTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private val appVersion = mockk{ + every { versionName } returns "0.1.0" + every { versionCode } returns 1L + } + private val settingsRepository = mockk () + + @Test + fun `persisting a forced crash saves a report offered on next launch`() { + val store = ReportStore(tempFolder.root) + val buffer = RingLogBuffer() + val collector = DiagnosticsCollector(appVersion, settingsRepository, buffer) + val reporter = CrashReporter(collector, store, buffer) + + reporter.persist(IllegalStateException("forced crash")) + + // Saved locally, with the stack trace and a crash breadcrumb captured. + val saved = store.reports.value.single() + assertEquals(ReportKind.CRASH, saved.kind) + assertTrue(saved.stackTrace.orEmpty().contains("forced crash")) + assertTrue(saved.logs.any { it.contains("Uncaught exception") }) + + // Still available to a fresh store instance, simulating the next app launch. + val nextLaunch = ReportStore(tempFolder.root) + assertEquals(1, nextLaunch.reports.value.size) + assertEquals(ReportKind.CRASH, nextLaunch.reports.value.single().kind) + } + + @Test + fun `capture only persists — it has no path to transmit`() { + // CrashReporter is constructed without any submitter/scheduler, so a crash can only ever be + // written to the local store. Nothing here can send data off the device. + val store = ReportStore(tempFolder.root) + val buffer = RingLogBuffer() + val collector = DiagnosticsCollector(appVersion, settingsRepository, buffer) + val reporter = CrashReporter(collector, store, buffer) + + reporter.persist(RuntimeException("boom")) + + assertEquals(1, store.reports.value.size) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt b/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt new file mode 100644 index 0000000..b582078 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DebugReportTest { + + private fun sample( + kind: ReportKind = ReportKind.CRASH, + stackTrace: String? = "java.lang.RuntimeException: boom\n\tat Foo.bar(Foo.kt:1)", + comment: String = "", + ) = DebugReport( + id = "abc-123", + createdAtMillis = 1_700_000_000_000L, + kind = kind, + appVersionName = "0.1.0", + appVersionCode = 7, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel 8", + stackTrace = stackTrace, + settings = linkedMapOf("pushIdle" to "true", "fetchPolicy" to "ALWAYS"), + logs = listOf("line 1", "line 2"), + userComment = comment, + ) + + @Test + fun `round-trips through storage json`() { + val original = sample(comment = "please fix") + + val restored = DebugReport.fromStorageJson(original.toStorageJson()) + + assertEquals(original, restored) + } + + @Test + fun `round-trips a manual report with no stack trace`() { + val original = sample(kind = ReportKind.MANUAL, stackTrace = null) + + val restored = DebugReport.fromStorageJson(original.toStorageJson()) + + assertEquals(ReportKind.MANUAL, restored.kind) + assertNull(restored.stackTrace) + assertEquals(original, restored) + } + + @Test + fun `submission payload contains the fields the user should see`() { + val payload = sample(comment = "it froze").toSubmissionPayload() + + assertTrue(payload.contains("\"kind\": \"CRASH\""), payload) + assertTrue(payload.contains("0.1.0")) + assertTrue(payload.contains("Pixel 8")) + assertTrue(payload.contains("it froze")) + assertTrue(payload.contains("boom")) + assertTrue(payload.contains("pushIdle")) + assertTrue(payload.contains("line 1")) + } + + @Test + fun `submission payload reflects the edited comment`() { + val base = sample() + + val edited = base.copy(userComment = "edited note").toSubmissionPayload() + + assertTrue(edited.contains("edited note")) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt b/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt new file mode 100644 index 0000000..d478790 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.FetchPolicy +import org.libremail.data.settings.SettingsRepository +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DiagnosticsCollectorTest { + + private val appVersion = mockk { + every { versionName } returns "1.2.3" + every { versionCode } returns 42L + } + private val settingsRepository = mockk () + private val logBuffer = RingLogBuffer() + private val collector = DiagnosticsCollector(appVersion, settingsRepository, logBuffer) + + @Test + fun `crash report includes stack trace and app version`() = runTest { + val report = collector.collectCrash(RuntimeException("kaboom")) + + assertEquals(ReportKind.CRASH, report.kind) + assertEquals("1.2.3", report.appVersionName) + assertEquals(42L, report.appVersionCode) + assertTrue(report.stackTrace.orEmpty().contains("kaboom")) + } + + @Test + fun `manual report includes a minimal non-PII settings summary and no stack trace`() = runTest { + every { settingsRepository.settings } returns + flowOf(AppSettings(pushIdle = false, fetchPolicy = FetchPolicy.ON_DEMAND)) + + val report = collector.collectManual() + + assertEquals(ReportKind.MANUAL, report.kind) + assertNull(report.stackTrace) + assertEquals("false", report.settings["pushIdle"]) + assertEquals("ON_DEMAND", report.settings["fetchPolicy"]) + // The summary is a fixed allow-list of non-PII flags — no account/server fields. + assertEquals( + setOf( + "dynamicColor", + "newMailNotifications", + "pushIdle", + "allowStartTls", + "loadRemoteImages", + "encryptCache", + "fetchPolicy", + ), + report.settings.keys, + ) + assertTrue(report.settings.values.none { it.contains("@") }) + } + + @Test + fun `crash report includes settings once the cache is warmed`() = runTest { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true)) + collector.warmSettingsCache() + + val report = collector.collectCrash(RuntimeException("x")) + + assertEquals("true", report.settings["encryptCache"]) + } + + @Test + fun `crash report captures recent in-app log lines`() = runTest { + logBuffer.record('I', "Startup", "hello-breadcrumb") + + val report = collector.collectCrash(RuntimeException("x")) + + assertTrue(report.logs.any { it.contains("hello-breadcrumb") }) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt new file mode 100644 index 0000000..02e7072 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReportStoreTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun report(id: String, createdAt: Long = 1L, kind: ReportKind = ReportKind.MANUAL) = DebugReport( + id = id, + createdAtMillis = createdAt, + kind = kind, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = null, + settings = emptyMap(), + logs = emptyList(), + ) + + @Test + fun `save then find and list`() { + val store = ReportStore(tempFolder.root) + + store.save(report("a")) + + assertEquals("a", store.find("a")?.id) + assertEquals(listOf("a"), store.reports.value.map { it.id }) + } + + @Test + fun `lists newest first`() { + val store = ReportStore(tempFolder.root) + + store.save(report("old", createdAt = 1L)) + store.save(report("new", createdAt = 2L)) + + assertEquals(listOf("new", "old"), store.reports.value.map { it.id }) + } + + @Test + fun `delete removes the report`() { + val store = ReportStore(tempFolder.root) + store.save(report("a")) + + store.delete("a") + + assertNull(store.find("a")) + assertTrue(store.reports.value.isEmpty()) + } + + @Test + fun `survives a fresh instance over the same directory (next launch)`() { + ReportStore(tempFolder.root).save(report("persisted")) + + val reopened = ReportStore(tempFolder.root) + + assertEquals("persisted", reopened.find("persisted")?.id) + } + + @Test + fun `ignores unparseable files`() { + File(tempFolder.root, "garbage.json").writeText("not json at all") + val store = ReportStore(tempFolder.root) + + store.save(report("valid")) + + assertEquals(listOf("valid"), store.reports.value.map { it.id }) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt b/app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt new file mode 100644 index 0000000..f9386e0 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RingLogBufferTest { + + @Test + fun `records lines in order`() { + val buffer = RingLogBuffer() + + buffer.record('I', "Tag", "first") + buffer.record('W', "Tag", "second") + + val snapshot = buffer.snapshot() + assertEquals(2, snapshot.size) + assertEquals("first", snapshot[0].message) + assertEquals("second", snapshot[1].message) + assertEquals('W', snapshot[1].level) + } + + @Test + fun `caps capacity and drops the oldest entries`() { + val buffer = RingLogBuffer() + + repeat(TOTAL) { buffer.record('D', "Tag", "msg-$it") } + + val snapshot = buffer.snapshot() + assertEquals(CAPACITY, snapshot.size) + // The most recent entry is retained; the very first was dropped. + assertEquals("msg-${TOTAL - 1}", snapshot.last().message) + assertTrue(snapshot.none { it.message == "msg-0" }) + } + + @Test + fun `clear empties the buffer`() { + val buffer = RingLogBuffer() + buffer.record('I', "Tag", "x") + + buffer.clear() + + assertTrue(buffer.snapshot().isEmpty()) + } + + private companion object { + const val CAPACITY = 200 + const val TOTAL = 250 + } +} diff --git a/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt b/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt new file mode 100644 index 0000000..f0cb15b --- /dev/null +++ b/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.richtext + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RichTextEditingTest { + + @Test + fun `toggleStyle adds then removes a style over the selection`() { + val base = RichTextContent("hello") + val bold = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.BOLD) + assertEquals(listOf(RichSpan(0, 5, RichStyle.BOLD)), bold.spans) + + val plain = RichTextEditing.toggleStyle(bold, 0, 5, RichStyle.BOLD) + assertTrue(plain.spans.isEmpty()) + } + + @Test + fun `toggleStyle over a fully styled sub-range removes just that part`() { + val bold = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.BOLD))) + val result = RichTextEditing.toggleStyle(bold, 1, 3, RichStyle.BOLD) + assertEquals( + listOf(RichSpan(0, 1, RichStyle.BOLD), RichSpan(3, 5, RichStyle.BOLD)), + result.spans.sortedBy { it.start }, + ) + } + + @Test + fun `applyLink links the selection and removes overlapping links`() { + val base = RichTextContent("see here") + val linked = RichTextEditing.applyLink(base, 4, 8, "http://x") + assertEquals(listOf(RichLink(4, 8, "http://x")), linked.links) + + val relinked = RichTextEditing.applyLink(linked, 0, 8, "http://y") + assertEquals(listOf(RichLink(0, 8, "http://y")), relinked.links) + } + + @Test + fun `toggleBlock adds a bullet marker to the caret's line and shifts spans`() { + val base = RichTextContent("ab", spans = listOf(RichSpan(0, 2, RichStyle.BOLD))) + val result = RichTextEditing.toggleBlock(base, 0, 0, BlockMarker.BULLET) + assertEquals("• ab", result.content.text) + assertEquals(listOf(RichSpan(2, 4, RichStyle.BOLD)), result.content.spans) + } + + @Test + fun `toggleBlock numbers each line of a multi-line selection`() { + val base = RichTextContent("a\nb\nc") + val result = RichTextEditing.toggleBlock(base, 0, 5, BlockMarker.ORDERED) + assertEquals("1. a\n2. b\n3. c", result.content.text) + } + + @Test + fun `toggleBlock removes the marker when every selected line already has it`() { + val base = RichTextContent("• a\n• b") + val result = RichTextEditing.toggleBlock(base, 0, base.text.length, BlockMarker.BULLET) + assertEquals("a\nb", result.content.text) + assertFalse(RichTextEditing.hasBlock(result.content, 0, result.content.text.length, BlockMarker.BULLET)) + } + + @Test + fun `toggleBlock replaces a different marker in place`() { + val base = RichTextContent("• a") + val result = RichTextEditing.toggleBlock(base, 0, 3, BlockMarker.QUOTE) + assertEquals("> a", result.content.text) + } +} diff --git a/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt new file mode 100644 index 0000000..7bf7657 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.richtext + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RichTextHtmlTest { + + @Test + fun `plain paragraphs become p with br line breaks`() { + val html = RichTextHtml.toHtml(RichTextContent("Hello\nWorld")) + assertEquals(" Hello
", html) + } + + @Test + fun `inline styles nest as b i u`() { + val content = RichTextContent( + text = "bold italic under", + spans = listOf( + RichSpan(0, 4, RichStyle.BOLD), + RichSpan(5, 11, RichStyle.ITALIC), + RichSpan(12, 17, RichStyle.UNDERLINE), + ), + ) + assertEquals("
Worldbold italic under
", RichTextHtml.toHtml(content)) + } + + @Test + fun `overlapping styles stay valid html`() { + val content = RichTextContent( + text = "abcd", + spans = listOf(RichSpan(0, 3, RichStyle.BOLD), RichSpan(1, 4, RichStyle.ITALIC)), + ) + // b over [0,3), i over [1,4): every run fully closes its tags, so nesting is always valid. + assertEquals("abcd
", RichTextHtml.toHtml(content)) + } + + @Test + fun `bulleted and numbered lists and quotes map to block tags`() { + assertEquals("", RichTextHtml.toHtml(RichTextContent("• Milk\n• Eggs"))) + assertEquals("
- Milk
- Eggs
", RichTextHtml.toHtml(RichTextContent("1. One\n2. Two"))) + assertEquals("
- One
- Two
a", RichTextHtml.toHtml(RichTextContent("> a\n> b"))) + } + + @Test + fun `links render as anchors and text is html escaped`() { + val content = RichTextContent("a&c", links = listOf(RichLink(0, 1, "http://x?y=1&z"))) + val html = RichTextHtml.toHtml(content) + assertTrue(html.contains("a"), html) + assertTrue(html.contains("<b>&c"), html) + } + + @Test + fun `plaintext keeps the readable markers`() { + assertEquals("• Milk\n> quote", RichTextHtml.toPlainText(RichTextContent("• Milk\n> quote"))) + } + + @Test + fun `hasFormatting is false for unstyled markerless text`() { + assertFalse(RichTextContent("just words\nmore words").hasFormatting()) + assertTrue(RichTextContent("• bullet").hasFormatting()) + assertTrue(RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.BOLD))).hasFormatting()) + } + + @Test + fun `fromHtml round-trips paragraphs styles lists quotes and links`() { + listOf( + RichTextContent("Hello\nWorld"), + RichTextContent("bold", spans = listOf(RichSpan(0, 4, RichStyle.BOLD))), + RichTextContent("• Milk\n• Eggs"), + RichTextContent("1. One\n2. Two"), + RichTextContent("> a\n> b"), + RichTextContent("see here", links = listOf(RichLink(4, 8, "http://example.com"))), + RichTextContent("a&c"), + ).forEach { original -> + val restored = RichTextHtml.fromHtml(RichTextHtml.toHtml(original)) + assertEquals(original.text, restored.text, "text: $original") + assertEquals( + original.spans.sortedBy(RichSpan::start), + restored.spans.sortedBy(RichSpan::start), + "spans: $original", + ) + assertEquals( + original.links.sortedBy(RichLink::start), + restored.links.sortedBy(RichLink::start), + "links: $original", + ) + } + } + + @Test + fun `fromHtml tolerates strong em and pretty-printed whitespace`() { + val restored = RichTextHtml.fromHtml("
b\n
") + assertEquals("• One\n• Two", restored.text) + // "Two" occupies [8,11) of "• One\n• Two". + assertEquals(listOf(RichSpan(8, 11, RichStyle.BOLD)), restored.spans) + } + + @Test + fun `empty content produces empty html`() { + assertEquals("", RichTextHtml.toHtml(RichTextContent(""))) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt new file mode 100644 index 0000000..81b13ec --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.lifecycle.SavedStateHandle +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.Account +import org.libremail.domain.model.MailProvider +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.repository.AccountRepository +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class AppPasswordViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(testDispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel(repo: AccountRepository, providerKey: String = MailProvider.GMAIL.key) = AppPasswordViewModel( + SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to providerKey)), + repo, + ) + + @Test + fun `provider is resolved from the nav argument`() { + val vm = viewModel(mockk(relaxed = true), providerKey = "icloud") + assertEquals(MailProvider.ICLOUD, vm.provider) + } + + @Test + fun `valid input builds the preset account and persists it`() = runTest(testDispatcher) { + val repo = mockk- One
\n- Two
\n() + val account = slot () + coEvery { repo.addImapAccount(capture(account), "app-pass") } returns Result.success(listOf("INBOX")) + val vm = viewModel(repo) + + vm.onEmail(" user@gmail.com ") + vm.onAppPassword("app-pass") + vm.testAndSave() + + coVerify { repo.addImapAccount(any(), "app-pass") } + // Servers come from the Gmail preset, not from any user input. + assertEquals("imap.gmail.com", account.captured.imap.host) + assertEquals(993, account.captured.imap.port) + assertEquals(MailSecurity.SSL_TLS, account.captured.imap.security) + assertEquals("smtp.gmail.com", account.captured.smtp.host) + assertEquals(587, account.captured.smtp.port) + assertEquals(MailSecurity.STARTTLS, account.captured.smtp.security) + assertEquals("user@gmail.com", account.captured.email) + + assertEquals(SetupStatus.DONE, vm.form.value.status) + assertEquals("imap:user@gmail.com", vm.form.value.addedAccountId) + } + + @Test + fun `blank email or app password surfaces an error without contacting the server`() = runTest(testDispatcher) { + val repo = mockk (relaxed = true) + val vm = viewModel(repo) + + vm.onEmail("") + vm.onAppPassword("app-pass") + vm.testAndSave() + + assertTrue(vm.form.value.error != null) + assertEquals(SetupStatus.IDLE, vm.form.value.status) + assertNull(vm.form.value.addedAccountId) + coVerify(exactly = 0) { repo.addImapAccount(any(), any()) } + } + + @Test + fun `a connection failure is surfaced inline and the account is not marked added`() = runTest(testDispatcher) { + val repo = mockk () + coEvery { repo.addImapAccount(any(), any()) } returns Result.failure(RuntimeException("Login failed")) + val vm = viewModel(repo) + + vm.onEmail("user@gmail.com") + vm.onAppPassword("wrong") + vm.testAndSave() + + assertEquals("Login failed", vm.form.value.error) + assertEquals(SetupStatus.IDLE, vm.form.value.status) + assertNull(vm.form.value.addedAccountId) + } + + @Test + fun `an unknown provider key surfaces an error and never contacts the server`() = runTest(testDispatcher) { + val repo = mockk (relaxed = true) + val vm = viewModel(repo, providerKey = "bogus") + + assertNull(vm.provider) + vm.onEmail("user@example.com") + vm.onAppPassword("app-pass") + vm.testAndSave() + + assertTrue(vm.form.value.error != null) + coVerify(exactly = 0) { repo.addImapAccount(any(), any()) } + } + + @Test + fun `isValid requires both an email and an app password`() { + val vm = viewModel(mockk(relaxed = true)) + assertTrue(!vm.form.value.isValid) + vm.onEmail("user@gmail.com") + assertTrue(!vm.form.value.isValid) + vm.onAppPassword("app-pass") + assertTrue(vm.form.value.isValid) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt index a2bda85..da82df2 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt @@ -3,8 +3,10 @@ package org.libremail.ui.compose import androidx.lifecycle.SavedStateHandle import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.slot import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -16,16 +18,21 @@ import org.junit.After import org.junit.Before import org.junit.Test 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.model.AuthType import org.libremail.domain.model.Draft import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.model.ServerConfig +import org.libremail.domain.model.Signature import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository import org.libremail.ui.navigation.Routes import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ComposeViewModelTest { @@ -48,10 +55,14 @@ class ComposeViewModelTest { smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), ) + private fun signature(accountId: String, html: String) = + Signature(id = "$accountId:sig", accountId = accountId, name = "Signature", html = html, isDefault = true) + private fun viewModel( accounts: List = listOf(alice), savedState: SavedStateHandle = SavedStateHandle(), - signatures: Map = emptyMap(), + signatures: Map = emptyMap(), + settings: Map = emptyMap(), mailRepository: MailRepository = mockk(relaxed = true), ): ComposeViewModel { val accountRepository = mockk () @@ -59,22 +70,36 @@ class ComposeViewModelTest { val accountSettingsRepository = mockk () coEvery { accountSettingsRepository.get(any()) } answers { val id = firstArg () - signatures[id] ?: AccountSettings(id) + settings[id] ?: AccountSettings(id) } + val signatureRepository = mockk () + coEvery { signatureRepository.getDefault(any()) } answers { signatures[firstArg ()] } return ComposeViewModel( savedStateHandle = savedState, mailRepository = mailRepository, accountRepository = accountRepository, contactsRepository = mockk(relaxed = true), accountSettingsRepository = accountSettingsRepository, + signatureRepository = signatureRepository, ) } @Test - fun `appends the sending account signature to a new message`() = runTest(testDispatcher) { - val vm = viewModel(signatures = mapOf("imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice"))) + fun `appends the sending account default signature to a new message`() = runTest(testDispatcher) { + val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice"))) assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body) + // A plain signature carries no formatting, so the message stays plaintext-only. + assertNull(vm.state.value.bodyHtml) + } + + @Test + fun `a rich signature makes the new message carry an HTML body`() = runTest(testDispatcher) { + val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice"))) + + assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body) + val html = vm.state.value.bodyHtml + assertTrue(html != null && html.contains("Alice"), "html=$html") } @Test @@ -82,8 +107,8 @@ class ComposeViewModelTest { val vm = viewModel( accounts = listOf(alice, bob), signatures = mapOf( - "imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice"), - "imap:b" to AccountSettings("imap:b", signature = "Best, Bob"), + "imap:a" to signature("imap:a", "Cheers, Alice"), + "imap:b" to signature("imap:b", "Best, Bob"), ), ) assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body) @@ -93,6 +118,16 @@ class ComposeViewModelTest { assertEquals("\n\n-- \nBest, Bob", vm.state.value.body) } + @Test + fun `does not append a signature when the account disabled it`() = runTest(testDispatcher) { + val vm = viewModel( + signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")), + settings = mapOf("imap:a" to AccountSettings("imap:a", signatureEnabled = false)), + ) + + assertEquals("", vm.state.value.body) + } + @Test fun `does not append a signature when resuming a draft`() = runTest(testDispatcher) { val mailRepository = mockk (relaxed = true) @@ -104,14 +139,76 @@ class ComposeViewModelTest { subject = "Hi", body = "Draft body", updatedAt = 0L, + bodyHtml = " Draft body
", attachments = emptyList(), ) val vm = viewModel( savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")), - signatures = mapOf("imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice")), + signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")), mailRepository = mailRepository, ) assertEquals("Draft body", vm.state.value.body) + // The draft's HTML body is restored so it round-trips back out on send. + assertEquals("Draft body
", vm.state.value.bodyHtml) + } + + @Test + fun `send carries the HTML body through to the outgoing message`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + val sent = slot () + coEvery { mailRepository.sendMessage(capture(sent)) } returns Result.success(Unit) + val vm = viewModel(mailRepository = mailRepository) + + vm.onToChange("bob@example.org") + vm.onBodyChange("Hello", " Hello world
") + vm.send() + + assertEquals("Hello world
", sent.captured.bodyHtml) + assertEquals("Hello", sent.captured.body) + } + + @Test + fun `prefills the form from mailto navigation arguments`() = runTest(testDispatcher) { + val vm = viewModel( + savedState = SavedStateHandle( + mapOf( + Routes.COMPOSE_ARG_TO to "a@example.org, b@example.org", + Routes.COMPOSE_ARG_CC to "c@example.org", + Routes.COMPOSE_ARG_BCC to "d@example.org", + Routes.COMPOSE_ARG_SUBJECT to "Lunch?", + Routes.COMPOSE_ARG_BODY to "Are you free", + ), + ), + ) + + val state = vm.state.value + assertEquals("a@example.org, b@example.org", state.to) + assertEquals("c@example.org", state.cc) + assertEquals("d@example.org", state.bcc) + assertEquals("Lunch?", state.subject) + // No signature configured, so the mailto body is used verbatim. + assertEquals("Are you free", state.body) + } + + @Test + fun `send carries the bcc recipients to the repository`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit) + val vm = viewModel( + savedState = SavedStateHandle( + mapOf( + Routes.COMPOSE_ARG_TO to "a@example.org", + Routes.COMPOSE_ARG_BCC to "secret@example.org", + ), + ), + mailRepository = mailRepository, + ) + + vm.send() + + val sent = slot () + coVerify { mailRepository.sendMessage(capture(sent)) } + assertEquals("secret@example.org", sent.captured.bcc) } } diff --git a/app/src/test/kotlin/org/libremail/ui/compose/MailtoParserTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/MailtoParserTest.kt new file mode 100644 index 0000000..fd1350a --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/compose/MailtoParserTest.kt @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MailtoParserTest { + + @Test + fun `single recipient with no headers`() { + val prefill = MailtoParser.parse("mailto:alice@example.com") + + assertEquals("alice@example.com", prefill.to) + assertEquals("", prefill.cc) + assertEquals("", prefill.bcc) + assertEquals("", prefill.subject) + assertEquals("", prefill.body) + } + + @Test + fun `multiple recipients before the query are comma-separated`() { + val prefill = MailtoParser.parse("mailto:alice@example.com,bob@example.com,carol@example.com") + + assertEquals("alice@example.com, bob@example.com, carol@example.com", prefill.to) + } + + @Test + fun `parses subject, body, cc and bcc header fields`() { + val prefill = MailtoParser.parse( + "mailto:alice@example.com?subject=Hello&body=How%20are%20you%3F&cc=carol@example.com&bcc=dan@example.com", + ) + + assertEquals("alice@example.com", prefill.to) + assertEquals("carol@example.com", prefill.cc) + assertEquals("dan@example.com", prefill.bcc) + assertEquals("Hello", prefill.subject) + assertEquals("How are you?", prefill.body) + } + + @Test + fun `cc and bcc header fields may list multiple recipients`() { + val prefill = MailtoParser.parse( + "mailto:alice@example.com?cc=carol@example.com,carl@example.com&bcc=dan@example.com,dana@example.com", + ) + + assertEquals("carol@example.com, carl@example.com", prefill.cc) + assertEquals("dan@example.com, dana@example.com", prefill.bcc) + } + + @Test + fun `a to header field merges with recipients before the query`() { + val prefill = MailtoParser.parse("mailto:alice@example.com?to=bob@example.com&subject=Hi") + + assertEquals("alice@example.com, bob@example.com", prefill.to) + assertEquals("Hi", prefill.subject) + } + + @Test + fun `decodes percent-encoded subject and multi-line body`() { + val prefill = MailtoParser.parse( + "mailto:a@example.com?subject=Q1%20%26%20Q2%20review&body=Line%20one%0ALine%20two", + ) + + assertEquals("Q1 & Q2 review", prefill.subject) + assertEquals("Line one\nLine two", prefill.body) + } + + @Test + fun `decodes percent-encoded addresses`() { + // A percent-encoded '@' (%40) in the address must decode. + val prefill = MailtoParser.parse("mailto:alice%40example.com") + + assertEquals("alice@example.com", prefill.to) + } + + @Test + fun `preserves a literal plus in addresses instead of decoding it to a space`() { + // RFC 6068 uses %20 for space, so '+' is a real character (unlike form-encoding). + val prefill = MailtoParser.parse("mailto:alice+newsletter@example.com?body=a+b") + + assertEquals("alice+newsletter@example.com", prefill.to) + assertEquals("a+b", prefill.body) + } + + @Test + fun `is case-insensitive about the scheme and header names`() { + val prefill = MailtoParser.parse("MAILTO:alice@example.com?SUBJECT=Hi&BODY=There&CC=c@example.com") + + assertEquals("alice@example.com", prefill.to) + assertEquals("c@example.com", prefill.cc) + assertEquals("Hi", prefill.subject) + assertEquals("There", prefill.body) + } + + @Test + fun `parses a bare address without the scheme prefix`() { + val prefill = MailtoParser.parse("alice@example.com") + + assertEquals("alice@example.com", prefill.to) + } + + @Test + fun `empty mailto yields an empty prefill`() { + val prefill = MailtoParser.parse("mailto:") + + assertTrue(prefill.isEmpty) + } + + @Test + fun `blank list separators are ignored`() { + val prefill = MailtoParser.parse("mailto:alice@example.com,,bob@example.com,") + + assertEquals("alice@example.com, bob@example.com", prefill.to) + } + + @Test + fun `unknown header fields are ignored`() { + val prefill = MailtoParser.parse("mailto:a@example.com?subject=Hi&in-reply-to=%3Cabc%3E&keywords=x") + + assertEquals("Hi", prefill.subject) + assertEquals("", prefill.body) + assertTrue(prefill.cc.isEmpty()) + } + + @Test + fun `a malformed percent-escape is passed through verbatim`() { + val prefill = MailtoParser.parse("mailto:a@example.com?subject=100%25%20done%20%GG") + + assertEquals("100% done %GG", prefill.subject) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt index e60501c..25cb649 100644 --- a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.mailbox +import androidx.lifecycle.SavedStateHandle import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -29,6 +30,7 @@ import org.libremail.domain.model.ReplyMode import org.libremail.domain.model.ServerConfig import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -374,12 +376,27 @@ class MailboxViewModelTest { coVerify { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.REPLY_ALL) } } + @Test + fun `opens filtered to the account passed as a nav argument`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice, bob), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:b:INBOX:1", "imap:b", "INBOX")), + initialAccountId = "imap:a", + ) + backgroundScope.launch { vm.messages.collect {} } + + assertEquals("imap:a", vm.selectedAccountId.value) + assertEquals("INBOX", vm.selectedFolder.value) + assertEquals(listOf("imap:a:INBOX:1"), vm.messages.value.map { it.id }) + } + private fun createViewModel( accounts: List , messages: List , folders: Map > = emptyMap(), syncer: MailSyncer = mockk(relaxed = true), repo: MailRepository = mockk(relaxed = true), + initialAccountId: String? = null, ): MailboxViewModel { every { repo.observeMessages() } returns MutableStateFlow(messages) every { repo.observeDrafts() } returns flowOf(emptyList()) @@ -389,7 +406,10 @@ class MailboxViewModelTest { } val accountRepository = mockk (relaxed = true) every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts) - return MailboxViewModel(repo, accountRepository, syncer) + val savedState = initialAccountId?.let { + SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to it)) + } ?: SavedStateHandle() + return MailboxViewModel(repo, accountRepository, syncer, savedState) } private fun account(id: String, email: String) = Account( diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt new file mode 100644 index 0000000..4000aa5 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.settings.SettingsRepository +import org.libremail.push.BatteryOptimizationManager +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class OnboardingViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(testDispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun batteryManager(supported: Boolean = true, unrestricted: Boolean = false) = + mockk { + every { isSupported } returns supported + every { isIgnoringBatteryOptimizations() } returns unrestricted + } + + private fun settingsRepository(handled: Boolean = false) = mockk { + coEvery { isBatteryPromptHandled() } returns handled + } + + @Test + fun `battery prompt is needed when not unrestricted and not handled`() = runTest(testDispatcher) { + val vm = OnboardingViewModel(batteryManager(unrestricted = false), settingsRepository(handled = false)) + + assertEquals(true, vm.batteryPromptNeeded.value) + assertFalse(vm.batteryUnrestricted.value) + } + + @Test + fun `battery prompt is skipped when the app is already unrestricted`() = runTest(testDispatcher) { + val vm = OnboardingViewModel(batteryManager(unrestricted = true), settingsRepository(handled = false)) + + assertEquals(false, vm.batteryPromptNeeded.value) + assertTrue(vm.batteryUnrestricted.value) + } + + @Test + fun `battery prompt is skipped once it has been handled`() = runTest(testDispatcher) { + val vm = OnboardingViewModel(batteryManager(unrestricted = false), settingsRepository(handled = true)) + + assertEquals(false, vm.batteryPromptNeeded.value) + } + + @Test + fun `only the first added account id is remembered`() = runTest(testDispatcher) { + val vm = OnboardingViewModel(batteryManager(), settingsRepository()) + + assertNull(vm.firstAddedAccountId) + vm.onAccountAdded("imap:first@example.com") + vm.onAccountAdded("imap:second@example.com") + + assertEquals("imap:first@example.com", vm.firstAddedAccountId) + } + + @Test + fun `marking the prompt handled persists the flag`() = runTest(testDispatcher) { + val repo = settingsRepository() + coEvery { repo.setBatteryPromptHandled(any()) } just Runs + val vm = OnboardingViewModel(batteryManager(), repo) + + vm.markBatteryPromptHandled() + + coVerify { repo.setBatteryPromptHandled(true) } + } + + @Test + fun `refresh re-reads the live battery status`() = runTest(testDispatcher) { + val manager = mockk { + every { isSupported } returns true + // First read (init) is not-unrestricted; the second (refresh) reflects the user's change. + every { isIgnoringBatteryOptimizations() } returnsMany listOf(false, true) + } + val vm = OnboardingViewModel(manager, settingsRepository()) + assertFalse(vm.batteryUnrestricted.value) + + vm.refreshBatteryStatus() + + assertTrue(vm.batteryUnrestricted.value) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt new file mode 100644 index 0000000..79eb032 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import androidx.lifecycle.SavedStateHandle +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.reporting.DebugReport +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import org.libremail.reporting.ReportSubmitter +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ReportReviewViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(testDispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + private val report = DebugReport( + id = "rid", + createdAtMillis = 1L, + kind = ReportKind.CRASH, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = "boom", + settings = emptyMap(), + logs = emptyList(), + ) + + private val store = mockk (relaxed = false) + private val submitter = mockk () + + private fun viewModel(): ReportReviewViewModel { + every { store.reports } returns MutableStateFlow(listOf(report)) + every { store.find("rid") } returns report + return ReportReviewViewModel( + savedStateHandle = SavedStateHandle(mapOf(Routes.REPORT_REVIEW_ARG_ID to "rid")), + store = store, + submitter = submitter, + ) + } + + @Test + fun `editing the comment sends nothing and saves nothing`() = runTest(testDispatcher) { + every { submitter.isEnabled } returns true + val vm = viewModel() + + vm.updateComment("just typing") + + verify(exactly = 0) { submitter.submit(any()) } + verify(exactly = 0) { store.save(any()) } + } + + @Test + fun `payload shown is exactly what would be submitted, including the comment`() = runTest(testDispatcher) { + val vm = viewModel() + + vm.updateComment("my note") + + assertEquals(report.copy(userComment = "my note").toSubmissionPayload(), vm.payload()) + } + + @Test + fun `submit enqueues the upload exactly once and persists the reviewed comment`() = runTest(testDispatcher) { + every { submitter.isEnabled } returns true + every { submitter.submit("rid") } just Runs + every { submitter.status("rid") } returns emptyFlow() + every { store.save(any()) } just Runs + val vm = viewModel() + vm.updateComment("edited before submit") + + vm.submit() + + verify(exactly = 1) { store.save(match { it.userComment == "edited before submit" }) } + verify(exactly = 1) { submitter.submit("rid") } + } + + @Test + fun `submit with no endpoint configured never transmits`() = runTest(testDispatcher) { + every { submitter.isEnabled } returns false + every { store.save(any()) } just Runs + val vm = viewModel() + vm.updateComment("please send") + + vm.submit() + + // The comment is still persisted for Copy/Save, but nothing is enqueued for upload. + verify(exactly = 1) { store.save(match { it.userComment == "please send" }) } + verify(exactly = 0) { submitter.submit(any()) } + } + + @Test + fun `discard deletes the report and does not submit`() = runTest(testDispatcher) { + every { store.delete("rid") } just Runs + val vm = viewModel() + + vm.discard() + + verify(exactly = 1) { store.delete("rid") } + verify(exactly = 0) { submitter.submit(any()) } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index efb55bc..90aa0cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -89,6 +89,7 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } +androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/secrets.properties.example b/secrets.properties.example index 48df0a9..d7f3dd1 100644 --- a/secrets.properties.example +++ b/secrets.properties.example @@ -1,11 +1,6 @@ -# Copy this file to `secrets.properties` (which is git-ignored) and fill in the value. +# Copy this file to `secrets.properties` (which is git-ignored) and fill in the values you need. +# Every value below is optional — the build works with the defaults when this file is absent. # -# Gmail OAuth 2.0 *Android* client ID created in Google Cloud Console. -# See the README ("Gmail account setup") for the exact steps. Used by the app for -# the Authorization Code + PKCE login flow; no client secret is required for an -# installed Android app. -GMAIL_OAUTH_CLIENT_ID= - # Optional: Microsoft (Outlook) OAuth public client id. A working default ships with the build; # set this only to use your own Azure app registration. #OUTLOOK_OAUTH_CLIENT_ID=