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() + + private lateinit var settingsRepository: SettingsRepository + private lateinit var onboarding: OnboardingViewModel + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun waitForText(text: String) = composeTestRule.waitUntil(10_000) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + /** + * Renders the "add another? → battery → inbox" tail with one account already added this session, + * starting on the add-another prompt. [handled] seeds the persisted "prompt handled" flag so the + * skip path can be exercised through the real repository. + */ + private fun setContent(handled: Boolean) { + val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + settingsRepository = SettingsRepository(context) + runBlocking { settingsRepository.setBatteryPromptHandled(handled) } + onboarding = OnboardingViewModel(BatteryOptimizationManager(context), settingsRepository) + onboarding.onAccountAdded(FIRST_ACCOUNT_ID) + + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = Routes.ONBOARDING_ADD_ANOTHER) { + composable(Routes.ONBOARDING_ADD_ANOTHER) { + val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle() + AddAnotherAccountScreen( + onAddAnother = {}, + onFinish = { + if (batteryPromptNeeded == true) { + navController.navigate(Routes.ONBOARDING_BATTERY) + } else { + navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) { + popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true } + } + } + }, + ) + } + composable(Routes.ONBOARDING_BATTERY) { + BatteryOptimizationScreen( + viewModel = onboarding, + onFinish = { + onboarding.markBatteryPromptHandled() + navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) { + popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true } + } + }, + ) + } + composable( + route = Routes.MAILBOX_PATTERN, + arguments = listOf( + navArgument(Routes.MAILBOX_ARG_ACCOUNT) { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { + Text(INBOX_MARKER) + } + } + } + } + } + + @Test + fun batteryStep_isOffered_thenNotNow_landsOnInbox() { + setContent(handled = false) + // The decision resolves asynchronously (a DataStore read); wait before driving the finish tap. + composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true } + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + + // The battery opt-in step is shown... + waitForText(string(R.string.onboarding_battery_title)) + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed() + + // ...and "Not now" continues to the inbox and records the prompt as handled (so it won't nag). + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick() + waitForText(INBOX_MARKER) + composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed() + composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } } + } + + @Test + fun batteryStep_takeMeThere_opensThisAppsSystemSettings() { + setContent(handled = false) + composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true } + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + waitForText(string(R.string.onboarding_battery_title)) + + val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName + Intents.init() + try { + // Stub the match so the real system settings screen never actually launches mid-test. + Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) + + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick() + + // Deep-links to *this app's* details screen (where Battery → Unrestricted lives). + Intents.intended( + allOf( + hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS), + hasData(Uri.fromParts("package", packageName, null)), + ), + ) + } finally { + Intents.release() + } + } + + @Test + fun finish_skipsBatteryStep_whenAlreadyHandled() { + setContent(handled = true) + composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == false } + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + + // Straight to the inbox — the opt-in step is skipped entirely. + waitForText(INBOX_MARKER) + composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertDoesNotExist() + } + + private companion object { + const val INBOX_MARKER = "INBOX-REACHED" + const val FIRST_ACCOUNT_ID = "imap:e2e@example.com" + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt new file mode 100644 index 0000000..b35a7bf --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.auth.OutlookAuthManager +import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.model.Message +import org.libremail.push.BatteryOptimizationManager +import org.libremail.ui.FakeAccountRepository +import org.libremail.ui.FakeMailRepository +import org.libremail.ui.FakeMailSyncer +import org.libremail.ui.accountsetup.AccountPickerScreen +import org.libremail.ui.accountsetup.AccountSetupViewModel +import org.libremail.ui.accountsetup.AppPasswordSetupScreen +import org.libremail.ui.accountsetup.AppPasswordViewModel +import org.libremail.ui.mailbox.MailboxScreen +import org.libremail.ui.mailbox.MailboxViewModel +import org.libremail.ui.navigation.Routes +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end test of the onboarding flow: a fresh install (no accounts) walks welcome → vendor + * picker → app-password setup → "add another?" → the first account's inbox. + * + * It drives the real onboarding screens + ViewModels through a real [NavHost]. The account backend is + * the in-memory [FakeAccountRepository] (a successful add makes the account observable) rather than a + * live server — GreenMail-backed connection behaviour is covered by the repository unit tests; this + * test owns the cross-screen navigation contract. + */ +@RunWith(AndroidJUnit4::class) +class OnboardingFlowTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + // Generous cap for the slow, animation-disabled CI matrix emulators; waitUntil returns as soon + // as the text appears, so the happy path is unaffected. + private fun waitForText(text: String) = composeTestRule.waitUntil(15_000) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + private fun inboxMessage() = Message( + id = "imap:e2e@gmail.com:INBOX:1", + accountId = "imap:e2e@gmail.com", + sender = "Welcome", + senderEmail = "welcome@gmail.com", + subject = "E2E first message", + snippet = "", + body = "", + isHtml = false, + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + folder = "INBOX", + inInbox = true, + bodyFetched = false, + ) + + private fun setOnboardingContent(accountRepo: FakeAccountRepository, mailRepo: FakeMailRepository) { + // Real collaborators are cheap here: the manager just wraps PowerManager and the repository + // reads the on-device settings DataStore. This test drives its own nav graph (without the + // battery step), so the onboarding view model's battery decision is inert for this flow. + val appContext = composeTestRule.activity.applicationContext + val onboarding = OnboardingViewModel( + BatteryOptimizationManager(appContext), + SettingsRepository(appContext), + ) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + val navController = rememberNavController() + val context = LocalContext.current + val outlookAuthManager = remember { OutlookAuthManager(context) } + NavHost(navController = navController, startDestination = Routes.ONBOARDING_WELCOME) { + composable(Routes.ONBOARDING_WELCOME) { + OnboardingWelcomeScreen( + onAddAccount = { navController.navigate(Routes.ONBOARDING_PICKER) }, + ) + } + composable(Routes.ONBOARDING_PICKER) { + val viewModel = remember { AccountSetupViewModel(outlookAuthManager, accountRepo) } + AccountPickerScreen( + onBack = {}, + onAccountAdded = { id -> + onboarding.onAccountAdded(id) + navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) + }, + onPickProvider = { provider -> + navController.navigate(Routes.onboardingAppPassword(provider.key)) + }, + onManualSetup = {}, + viewModel = viewModel, + ) + } + composable( + route = Routes.ONBOARDING_APP_PASSWORD_PATTERN, + arguments = listOf( + navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType }, + ), + ) { entry -> + val key = entry.arguments?.getString(Routes.APP_PASSWORD_ARG_PROVIDER).orEmpty() + val viewModel = remember { + AppPasswordViewModel( + SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to key)), + accountRepo, + ) + } + AppPasswordSetupScreen( + onBack = {}, + onAccountAdded = { id -> + onboarding.onAccountAdded(id) + navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) { + popUpTo(Routes.ONBOARDING_PICKER) + } + }, + viewModel = viewModel, + ) + } + composable(Routes.ONBOARDING_ADD_ANOTHER) { + AddAnotherAccountScreen( + onAddAnother = { + navController.navigate(Routes.ONBOARDING_PICKER) { + popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true } + } + }, + onFinish = { + val id = onboarding.firstAddedAccountId + val dest = if (id != null) Routes.mailboxForAccount(id) else Routes.MAILBOX + navController.navigate(dest) { + popUpTo(Routes.ONBOARDING_WELCOME) { inclusive = true } + } + }, + ) + } + composable( + route = Routes.MAILBOX_PATTERN, + arguments = listOf( + navArgument(Routes.MAILBOX_ARG_ACCOUNT) { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { entry -> + val account = entry.arguments?.getString(Routes.MAILBOX_ARG_ACCOUNT).orEmpty() + val viewModel = remember { + MailboxViewModel( + mailRepo, + accountRepo, + FakeMailSyncer(), + SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to account)), + ) + } + MailboxScreen( + onOpenMessage = {}, + onCompose = {}, + onOpenDrafts = {}, + onOpenOutbox = {}, + onAddAccount = {}, + onOpenCompose = {}, + onSelectTab = {}, + viewModel = viewModel, + ) + } + } + } + } + } + + @Test + fun onboarding_addsAppPasswordAccount_thenLandsOnFirstAccountInbox() { + val accountRepo = FakeAccountRepository() + val mailRepo = FakeMailRepository(messages = listOf(inboxMessage())) + setOnboardingContent(accountRepo, mailRepo) + + // Welcome → picker. + composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick() + + // Picker → Gmail app-password setup. + waitForText("Gmail") + composeTestRule.onNodeWithText("Gmail").performClick() + + // App-password setup: email + app password come from the user; servers come from the preset. + // performScrollTo first — on the short default matrix emulator the fields and the "Test and + // add" button sit below the fold of this scrolling screen, and a positional click on an + // off-screen button is a silent no-op (which is why this passed only on API 37's taller AVD). + waitForText(string(R.string.app_password_email)) + composeTestRule.onNodeWithText(string(R.string.app_password_email)) + .performScrollTo().performTextInput("e2e@gmail.com") + composeTestRule.onNodeWithText(string(R.string.app_password_field)) + .performScrollTo().performTextInput("app-pass") + composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)) + .performScrollTo().performClick() + + // "Add another?" prompt → No. + waitForText(string(R.string.onboarding_add_another_prompt)) + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + + // Landed on the first (and only) account's inbox. + waitForText("E2E first message") + composeTestRule.onNodeWithText("E2E first message").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt index d4f26b7..8bafcf7 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt @@ -6,7 +6,6 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput import androidx.lifecycle.SavedStateHandle import androidx.room.Room import androidx.test.core.app.ApplicationProvider @@ -19,6 +18,7 @@ import org.libremail.R import org.libremail.data.local.LibreMailDatabase import org.libremail.data.local.toEntity import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.SignatureRepository import org.libremail.domain.model.Account import org.libremail.domain.model.AuthType import org.libremail.domain.model.MailSecurity @@ -48,6 +48,8 @@ class AccountSettingsScreenTest { private fun string(resId: Int) = composeTestRule.activity.getString(resId) + private var manageSignaturesClicked = false + private fun setContent(): AccountSettingsRepository { val context = ApplicationProvider.getApplicationContext() // Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by @@ -64,24 +66,27 @@ class AccountSettingsScreenTest { savedStateHandle = SavedStateHandle(mapOf(Routes.ACCOUNT_SETTINGS_ARG_ID to account.id)), accountRepository = FakeAccountRepository(accounts = listOf(account)), accountSettingsRepository = repository, + signatureRepository = SignatureRepository(db.signatureDao()), ) composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { - AccountSettingsScreen(onBack = {}, viewModel = viewModel) + AccountSettingsScreen( + onBack = {}, + onManageSignatures = { manageSignaturesClicked = true }, + viewModel = viewModel, + ) } } return repository } @Test - fun editingSignature_persistsThroughTheRepository() { - val repository = setContent() + fun manageSignatures_opensTheSignaturesScreen() { + setContent() - composeTestRule.onNodeWithText(string(R.string.settings_signature_hint)).performTextInput("Cheers") + composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performClick() - composeTestRule.waitUntil(5_000) { - runBlocking { repository.get(account.id).signature } == "Cheers" - } + composeTestRule.waitUntil(5_000) { manageSignaturesClicked } } @Test diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt index 1296d9f..0ab0e87 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt @@ -21,6 +21,7 @@ import org.libremail.data.security.KeystoreCrypto import org.libremail.data.security.PassphraseSession import org.libremail.data.settings.FetchPolicy import org.libremail.data.settings.SettingsRepository +import org.libremail.push.BatteryOptimizationManager import org.libremail.ui.FakeAccountRepository import org.libremail.ui.theme.LibreMailTheme @@ -46,11 +47,23 @@ class SettingsScreenTest { override fun availability() = AppLockAvailability.NONE_ENROLLED } val keyStore = DatabaseKeyStore(context, KeystoreCrypto(), DatabaseKeyCipher(), PassphraseSession()) - val viewModel = SettingsViewModel(FakeAccountRepository(), settingsRepository, appLockManager, keyStore) + val viewModel = SettingsViewModel( + FakeAccountRepository(), + settingsRepository, + appLockManager, + keyStore, + BatteryOptimizationManager(context), + ) composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { - SettingsScreen(onAddAccount = {}, onOpenAccount = {}, onSelectTab = {}, viewModel = viewModel) + SettingsScreen( + onAddAccount = {}, + onOpenAccount = {}, + onSelectTab = {}, + onReportProblem = {}, + viewModel = viewModel, + ) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ee562c..5d35ca1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,10 +10,19 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + tools:node="merge" /> diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index f7e7d4c..8c24366 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -16,6 +16,10 @@ import org.libremail.data.settings.SettingsRepository import org.libremail.data.sync.SyncScheduler import org.libremail.domain.repository.AccountRepository import org.libremail.push.IdlePushManager +import org.libremail.reporting.AppLog +import org.libremail.reporting.CrashReporter +import org.libremail.reporting.DiagnosticsCollector +import org.libremail.reporting.RingLogBuffer import javax.inject.Inject @HiltAndroidApp @@ -33,6 +37,12 @@ class LibreMailApplication : @Inject lateinit var idlePushManager: IdlePushManager + @Inject lateinit var ringLogBuffer: RingLogBuffer + + @Inject lateinit var crashReporter: CrashReporter + + @Inject lateinit var diagnosticsCollector: DiagnosticsCollector + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) /** Whether the IDLE push service should currently be running (push enabled AND an account exists). */ @@ -46,6 +56,13 @@ class LibreMailApplication : override fun onCreate() { super.onCreate() + // Wire up debug reporting first so crashes during the rest of startup are still captured. + AppLog.install(ringLogBuffer) + crashReporter.install() + AppLog.i(TAG, "Application created") + // Warm the settings cache so a later crash report can include non-PII settings without + // touching DataStore on the crashing thread. + appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } } syncScheduler.schedulePeriodicSync() // Run the IMAP IDLE push service only while it has something to do: the push setting is on // AND at least one account exists. This starts it when the first account is added and stops @@ -72,4 +89,8 @@ class LibreMailApplication : fun ensurePushStarted() { if (pushShouldBeActive) idlePushManager.start() } + + private companion object { + const val TAG = "LibreMail" + } } diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index 13fe88b..203f738 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -2,6 +2,7 @@ package org.libremail import android.Manifest +import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -12,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity @@ -19,6 +21,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint import org.libremail.data.settings.SettingsRepository import org.libremail.ui.LibreMailApp +import org.libremail.ui.compose.ComposePrefill +import org.libremail.ui.compose.IntentComposeParser import org.libremail.ui.lock.AppLockGateHost import org.libremail.ui.theme.LibreMailTheme import javax.inject.Inject @@ -32,6 +36,12 @@ class MainActivity : FragmentActivity() { @Inject lateinit var settingsRepository: SettingsRepository + /** + * A pending compose request parsed from a `mailto:` / share intent, consumed once by the NavHost. + * Held as Compose state so [onNewIntent] can re-trigger it while the activity is alive. + */ + private val pendingCompose = mutableStateOf(null) + override fun onStart() { super.onStart() // Foreground: recover IDLE push if a background start was previously blocked. @@ -41,6 +51,11 @@ class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + // Only on a fresh launch — on a config-change recreation the NavHost restores the compose + // destination itself, so re-parsing the (unchanged) intent would open a duplicate. + if (savedInstanceState == null) { + pendingCompose.value = IntentComposeParser.parse(intent) + } setContent { val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true) LibreMailTheme(dynamicColor = dynamicColor) { @@ -48,11 +63,20 @@ class MainActivity : FragmentActivity() { // Gate the whole app behind the screen-lock when app-lock is enabled. When it is off // the gate resolves straight to the content, so this is a no-op for most users. AppLockGateHost { - LibreMailApp() + LibreMailApp( + pendingCompose = pendingCompose.value, + onComposeHandled = { pendingCompose.value = null }, + ) } } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + IntentComposeParser.parse(intent)?.let { pendingCompose.value = it } + } } /** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */ diff --git a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt deleted file mode 100644 index 080dd36..0000000 --- a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.auth - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.util.Base64 -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.suspendCancellableCoroutine -import net.openid.appauth.AuthState -import net.openid.appauth.AuthorizationException -import net.openid.appauth.AuthorizationRequest -import net.openid.appauth.AuthorizationResponse -import net.openid.appauth.AuthorizationService -import net.openid.appauth.AuthorizationServiceConfiguration -import net.openid.appauth.ResponseTypeValues -import org.json.JSONObject -import org.libremail.BuildConfig -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -/** - * Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted - * `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2. - */ -@Singleton -class GmailAuthManager @Inject constructor(@ApplicationContext private val context: Context) { - private val serviceConfig = AuthorizationServiceConfiguration( - Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"), - Uri.parse("https://oauth2.googleapis.com/token"), - ) - - /** False until a Google OAuth client id is provided in secrets.properties (see README). */ - val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank() - - fun createAuthIntent(): Intent { - val request = AuthorizationRequest.Builder( - serviceConfig, - BuildConfig.GMAIL_OAUTH_CLIENT_ID, - ResponseTypeValues.CODE, - Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI), - ) - .setScope("openid email profile https://mail.google.com/") - .build() - return AuthorizationService(context).getAuthorizationRequestIntent(request) - } - - suspend fun exchangeToken(responseIntent: Intent): OAuthResult { - val response = AuthorizationResponse.fromIntent(responseIntent) - val exception = AuthorizationException.fromIntent(responseIntent) - if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled") - - val service = AuthorizationService(context) - try { - val tokenResponse = suspendCancellableCoroutine { continuation -> - service.performTokenRequest(response.createTokenExchangeRequest()) { token, error -> - if (token != null) { - continuation.resume(token) - } else { - continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed")) - } - } - } - val authState = AuthState(response, exception).apply { update(tokenResponse, null) } - val email = emailFromIdToken(tokenResponse.idToken) - ?: throw IllegalStateException("Could not read the account email from the token") - return OAuthResult( - email = email, - accessToken = tokenResponse.accessToken.orEmpty(), - authStateJson = authState.jsonSerializeString(), - ) - } finally { - service.dispose() - } - } - - /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */ - suspend fun freshAccessToken(authStateJson: String): FreshToken { - val authState = AuthState.jsonDeserialize(authStateJson) - val service = AuthorizationService(context) - try { - val accessToken = suspendCancellableCoroutine { continuation -> - authState.performActionWithFreshTokens(service) { token, _, error -> - if (token != null) { - continuation.resume(token) - } else { - continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed")) - } - } - } - return FreshToken( - accessToken = accessToken, - authStateJson = authState.jsonSerializeString(), - accessTokenExpiry = authState.accessTokenExpirationTime, - ) - } finally { - service.dispose() - } - } - - private fun emailFromIdToken(idToken: String?): String? { - if (idToken.isNullOrBlank()) return null - return runCatching { - val payload = idToken.split(".").getOrNull(1) ?: return null - val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) - JSONObject(json).optString("email").ifBlank { null } - }.getOrNull() - } -} diff --git a/app/src/main/kotlin/org/libremail/backup/BackupPolicy.kt b/app/src/main/kotlin/org/libremail/backup/BackupPolicy.kt new file mode 100644 index 0000000..2ba34a6 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/backup/BackupPolicy.kt @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.backup + +import org.libremail.data.settings.AppSettings + +/** + * Single source of truth for what LibreMail is willing to hand to Android Backup. Kept in lockstep + * with `res/xml/data_extraction_rules.xml` (API 31+) and `res/xml/backup_rules.xml` (API 29-30); the + * path constants here are asserted against those resources by `DataExtractionRulesTest`. + * + * Only re-creatable user preferences are eligible. The mail cache re-downloads on the next sync, and + * the credentials plus the Keystore-sealed cache passphrase are device-bound secrets that would only + * ever restore as undecryptable ciphertext — so they are never backed up. + */ +object BackupPolicy { + + /** `filesDir`-relative DataStore file holding user preferences — the only data we back up. */ + const val SAFE_SETTINGS_FILE: String = "datastore/libremail_settings.preferences_pb" + + /** `filesDir`-relative paths that must never leave the device. */ + val EXCLUDED_FILE_PATHS: List = listOf( + // Keystore-sealed SQLCipher passphrase for the encrypted cache: the wrapping key is + // non-exportable and device-bound, so this ciphertext is useless anywhere else. + "datastore/libremail_dbkey.preferences_pb", + ) + + /** `databases`-dir-relative names that must never leave the device (encrypted credentials + mail cache). */ + val EXCLUDED_DATABASE_PATHS: List = listOf( + "libremail.db", + "libremail.db-wal", + "libremail.db-shm", + "libremail.db-journal", + ) + + /** + * Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up + * (or transferred device-to-device) unless the user has explicitly enabled it in Settings. + */ + fun shouldBackUp(settings: AppSettings): Boolean = settings.includeInBackup +} diff --git a/app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt b/app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt new file mode 100644 index 0000000..c62629d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.backup + +import android.app.backup.BackupAgentHelper +import android.app.backup.FullBackupDataOutput +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.libremail.data.settings.settingsDataStore +import org.libremail.data.settings.toAppSettings + +/** + * Enforces the runtime backup opt-in on top of Android Auto Backup. + * + * `android:allowBackup` is a manifest flag that can't be toggled at runtime, so the "include settings + * in Android Backup" preference is enforced here instead: [onFullBackup] runs the backup only when the + * user has opted in (the default is off, so no app data leaves the device). When opted in, it defers to + * the framework, which applies the allowlist in `res/xml/data_extraction_rules.xml` (and + * `res/xml/backup_rules.xml` on API < 31) — backing up the user-preferences DataStore only, never the + * mail cache, the encrypted credentials, or the Keystore-sealed cache passphrase. + * + * Extends [BackupAgentHelper] (rather than raw `BackupAgent`) so the unused key/value backup/restore + * paths inherit safe no-op implementations; only full-data backup is used (`fullBackupOnly=true`), and + * full-data restore uses the default `onRestoreFile` handling. + * + * The opt-in flag is read directly from the shared [settingsDataStore] singleton so it does not depend + * on Hilt or `Application.onCreate` having run in the framework's restricted backup mode. + */ +class LibreMailBackupAgent : BackupAgentHelper() { + + override fun onFullBackup(data: FullBackupDataOutput) { + if (backupOptedIn()) { + super.onFullBackup(data) + } + } + + /** Reads the opt-in flag; any failure defaults to "not opted in" so we never back up by accident. */ + private fun backupOptedIn(): Boolean = runCatching { + runBlocking { + BackupPolicy.shouldBackUp(applicationContext.settingsDataStore.data.first().toAppSettings()) + } + }.getOrDefault(false) +} diff --git a/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt index ec0164e..a8067c7 100644 --- a/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt +++ b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt @@ -2,40 +2,55 @@ package org.libremail.data import org.libremail.domain.model.ReplyMode +import org.libremail.mail.HtmlToText import org.libremail.mail.ReplyContext +import org.libremail.richtext.RichTextContent +import org.libremail.richtext.RichTextHtml import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** The pre-filled compose fields for a reply/forward (everything else the user fills in). */ -data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String) +/** + * The pre-filled compose fields for a reply/forward. [body] is the plaintext form; [bodyHtml] is the + * matching HTML (the quote rendered as a `
`), 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)"), "\n\n") - .replace(Regex("<[^>]*>"), "") - .replace(Regex("[ \\t]+"), " ") - .trim() + /** Renders the plaintext quote (with its "> " markers) to the equivalent clean HTML. */ + private fun htmlOf(body: String): String = RichTextHtml.toHtml(RichTextContent(body)) private fun formatDate(millis: Long): String = SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis)) diff --git a/app/src/main/kotlin/org/libremail/data/SignatureBlock.kt b/app/src/main/kotlin/org/libremail/data/SignatureBlock.kt new file mode 100644 index 0000000..a46b109 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/SignatureBlock.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data + +import org.libremail.domain.model.Signature +import org.libremail.richtext.RichTextContent +import org.libremail.richtext.RichTextHtml + +/** + * A signature rendered into both forms the composer needs: [plain] for the plaintext body/fallback + * and [html] for the HTML body. The two are kept in sync — parsing [html] back through the rich-text + * model yields exactly [plain] — so the editor shows the same content whichever it seeds from, and a + * From-account swap can strip the previously applied block from either representation. + * + * The block opens with the RFC 3676 "-- " delimiter so receiving clients recognize it as a signature. + */ +data class SignatureBlock(val plain: String, val html: String) { + val isEmpty: Boolean get() = plain.isEmpty() && html.isEmpty() + + companion object { + val EMPTY = SignatureBlock("", "") + + /** The block for [signature], or [EMPTY] when there is none / it is blank. */ + fun of(signature: Signature?): SignatureBlock { + if (signature == null) return EMPTY + val plainSig = signature.plainText().trimEnd() + if (plainSig.isBlank() && signature.html.isBlank()) return EMPTY + return SignatureBlock( + plain = "$DELIMITER_PLAIN$plainSig", + html = RichTextHtml.toHtml(RichTextContent(DELIMITER_PLAIN)) + signature.html, + ) + } + + private const val DELIMITER_PLAIN = "\n\n-- \n" + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt index 0b1b2e5..d65ee85 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -11,6 +11,7 @@ import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.FolderDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.dao.SignatureDao import org.libremail.data.local.entity.AccountEntity import org.libremail.data.local.entity.AccountSettingsEntity import org.libremail.data.local.entity.AttachmentEntity @@ -19,6 +20,7 @@ import org.libremail.data.local.entity.DraftEntity import org.libremail.data.local.entity.FolderEntity import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.OutboxEntity +import org.libremail.data.local.entity.SignatureEntity @Database( entities = [ @@ -30,8 +32,9 @@ import org.libremail.data.local.entity.OutboxEntity OutboxEntity::class, DraftEntity::class, FolderEntity::class, + SignatureEntity::class, ], - version = 9, + version = 11, exportSchema = true, ) abstract class LibreMailDatabase : RoomDatabase() { @@ -43,4 +46,5 @@ abstract class LibreMailDatabase : RoomDatabase() { abstract fun outboxDao(): OutboxDao abstract fun draftDao(): DraftDao abstract fun folderDao(): FolderDao + abstract fun signatureDao(): SignatureDao } diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index bf975f1..a6b444f 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -159,9 +159,11 @@ internal fun DraftEntity.toDomain(): Draft = Draft( accountId = accountId, to = toAddresses, cc = ccAddresses, + bcc = bccAddresses, subject = subject, body = body, updatedAt = updatedAt, + bodyHtml = bodyHtml, attachments = attachments.toOutgoingAttachments(), ) @@ -170,10 +172,12 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity( accountId = accountId, toAddresses = to, ccAddresses = cc, + bccAddresses = bcc, subject = subject, body = body, updatedAt = updatedAt, attachments = attachments.toJson(), + bodyHtml = bodyHtml, ) /** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */ @@ -202,6 +206,7 @@ internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage( body = body, createdAt = createdAt, lastError = lastError, + bodyHtml = bodyHtml, ) private fun String.toMailSecurity(): MailSecurity = runCatching { diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index 0338ce3..dcece0e 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -171,3 +171,48 @@ val MIGRATION_8_9 = object : Migration(8, 9) { ) } } + +/** + * v9 -> v10: bcc support for outgoing mail (preserves existing data). Adds a `bccAddresses` column + * to `outbox` and `drafts` so a `mailto:`-launched (or manually addressed) blind-copy recipient + * survives being queued and saved. The `DEFAULT ''` matches the entities' `@ColumnInfo(defaultValue)` + * so the fresh-install schema validates identically to the migrated one (the MIGRATION_7_8 pattern). + */ +val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''") + } +} + +/** + * v10 -> v11: rich composition (preserves existing data). + * - `drafts`/`outbox`: add a nullable `bodyHtml` column carrying the HTML form of the body when a + * message was composed with formatting (null = plaintext-only, sent/kept exactly as before). + * - add the `signatures` table (multiple named signatures per account, one default), with a + * cascading foreign key to `accounts`, and backfill each account's existing per-account settings + * signature as its default signature so nobody loses one on upgrade. + */ +val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bodyHtml` TEXT") + db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bodyHtml` TEXT") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `signatures` (" + + "`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, " + + "`contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), " + + "FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE)", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `signatures` (`accountId`)") + // Preserve any existing plain-text per-account signature as that account's default signature. + // Newlines become
so the HTML keeps the original line breaks; other characters are rare + // in signatures and pass through unescaped. + db.execSQL( + "INSERT INTO `signatures` (`id`, `accountId`, `name`, `contentHtml`, `isDefault`) " + + "SELECT `accountId` || ':default-signature', `accountId`, 'Signature', " + + "replace(`signature`, char(10), '
'), 1 " + + "FROM `account_settings` WHERE `signature` <> ''", + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt new file mode 100644 index 0000000..ddf12c9 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.libremail.data.local.entity.SignatureEntity + +@Dao +interface SignatureDao { + @Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY isDefault DESC, name COLLATE NOCASE") + fun observeForAccount(accountId: String): Flow> + + @Query("SELECT * FROM signatures WHERE id = :id LIMIT 1") + suspend fun getById(id: String): SignatureEntity? + + @Query("SELECT * FROM signatures WHERE accountId = :accountId AND isDefault = 1 LIMIT 1") + suspend fun getDefault(accountId: String): SignatureEntity? + + @Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY name COLLATE NOCASE LIMIT 1") + suspend fun firstForAccount(accountId: String): SignatureEntity? + + @Query("SELECT COUNT(*) FROM signatures WHERE accountId = :accountId") + suspend fun countForAccount(accountId: String): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(signature: SignatureEntity) + + @Query("DELETE FROM signatures WHERE id = :id") + suspend fun delete(id: String) + + @Query("UPDATE signatures SET isDefault = 0 WHERE accountId = :accountId") + suspend fun clearDefault(accountId: String) + + @Query("UPDATE signatures SET isDefault = 1 WHERE id = :id") + suspend fun markDefault(id: String) + + /** Makes [id] the account's sole default in one transaction (clears the others first). */ + @Transaction + suspend fun setDefault(accountId: String, id: String) { + clearDefault(accountId) + markDefault(id) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt index ed6dd3b..6f9131e 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.local.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -11,9 +12,12 @@ data class DraftEntity( val accountId: String?, val toAddresses: String, val ccAddresses: String, + @ColumnInfo(defaultValue = "") val bccAddresses: String = "", val subject: String, val body: String, val updatedAt: Long, /** JSON array of the draft's attachments ([uri, name] pairs); empty string when there are none. */ val attachments: String = "", + /** HTML form of [body] when the draft carries formatting; null for plaintext drafts. */ + val bodyHtml: String? = null, ) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt index 96d5f44..7481b2d 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.local.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -11,8 +12,11 @@ data class OutboxEntity( val accountId: String, val toAddresses: String, val ccAddresses: String, + @ColumnInfo(defaultValue = "") val bccAddresses: String = "", val subject: String, val body: String, val createdAt: Long, val lastError: String? = null, + /** HTML form of [body] when composed with formatting; null sends `text/plain` only. */ + val bodyHtml: String? = null, ) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt new file mode 100644 index 0000000..23c1c13 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * One saved signature. The [accountId] foreign key cascades on delete, so removing an account drops + * its signatures. [isDefault] marks the one auto-inserted when composing from the account; the + * repository keeps at most one default per account. + */ +@Entity( + tableName = "signatures", + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["accountId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("accountId")], +) +data class SignatureEntity( + @PrimaryKey val id: String, + val accountId: String, + val name: String, + val contentHtml: String, + val isDefault: Boolean = false, +) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index 94657d8..d6ec723 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -8,6 +8,7 @@ import jakarta.mail.Flags import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.libremail.data.ReplyBuilder +import org.libremail.data.SignatureBlock import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.DraftDao @@ -20,6 +21,7 @@ import org.libremail.data.local.entity.OutboxEntity import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.SignatureRepository import org.libremail.data.sync.MailConnectionFactory import org.libremail.data.sync.SendScheduler import org.libremail.domain.model.Attachment @@ -52,6 +54,7 @@ class MailRepositoryImpl @Inject constructor( private val connectionFactory: MailConnectionFactory, private val sendScheduler: SendScheduler, private val accountSettingsRepository: AccountSettingsRepository, + private val signatureRepository: SignatureRepository, ) : MailRepository { override fun observeMessages(): Flow> = messageDao.observeAll().map { rows -> @@ -191,9 +194,15 @@ class MailRepositoryImpl @Inject constructor( val params = connectionFactory.imapParamsFor(account) val context = imapClient.fetchForReply(params, entity.folder, uidOf(messageId)) val content = ReplyBuilder.build(context, mode, account.email) - // Bake the sending account's signature into the reply/forward body so it round-trips as part - // of the draft (compose won't re-append for drafts). - val signature = accountSettingsRepository.get(entity.accountId).signatureBlock() + // Bake the sending account's default signature into the reply/forward body — above the quoted + // original — so it round-trips as part of the draft (compose won't re-append for drafts). Both + // the plaintext and HTML forms are stored so the reply can go out as multipart/alternative. + val settings = accountSettingsRepository.get(entity.accountId) + val sig = if (settings.signatureEnabled) { + SignatureBlock.of(signatureRepository.getDefault(entity.accountId)) + } else { + SignatureBlock.EMPTY + } val draftId = UUID.randomUUID().toString() saveDraft( Draft( @@ -202,8 +211,9 @@ class MailRepositoryImpl @Inject constructor( to = content.to, cc = content.cc, subject = content.subject, - body = content.body + signature, + body = sig.plain + content.body, updatedAt = System.currentTimeMillis(), + bodyHtml = sig.html + content.bodyHtml, attachments = emptyList(), ), ) @@ -266,9 +276,11 @@ class MailRepositoryImpl @Inject constructor( accountId = outgoing.accountId, toAddresses = outgoing.to, ccAddresses = outgoing.cc, + bccAddresses = outgoing.bcc, subject = outgoing.subject, body = outgoing.body, createdAt = System.currentTimeMillis(), + bodyHtml = outgoing.bodyHtml, ), ) sendScheduler.sendNow() diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt index a4eb6ae..2e0342a 100644 --- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.settings +import android.app.backup.BackupManager import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences @@ -15,7 +16,15 @@ import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton -private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "libremail_settings") +/** + * User-preferences DataStore. Exposed as `internal` (not `private`) and read via [toAppSettings] so + * that [org.libremail.backup.LibreMailBackupAgent] can consult the backup opt-in flag through the + * exact same singleton instance. The system instantiates the backup agent in the app process while + * the app may already hold this DataStore open; constructing a second DataStore for the same file + * would crash with "There are multiple DataStores active for the same file", so both sides must go + * through this one delegate. + */ +internal val Context.settingsDataStore: DataStore by preferencesDataStore(name = "libremail_settings") /** * How aggressively the app downloads message content during sync. @@ -34,24 +43,43 @@ data class AppSettings( val loadRemoteImages: Boolean = false, val encryptCache: Boolean = false, val appLock: Boolean = false, + val includeInBackup: Boolean = false, val fetchPolicy: FetchPolicy = FetchPolicy.ALWAYS, ) +private object Keys { + val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") + val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications") + val PUSH_IDLE = booleanPreferencesKey("push_idle") + val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls") + val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images") + val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache") + val APP_LOCK = booleanPreferencesKey("app_lock") + val INCLUDE_IN_BACKUP = booleanPreferencesKey("include_in_backup") + val FETCH_POLICY = stringPreferencesKey("fetch_policy") + val BATTERY_PROMPT_HANDLED = booleanPreferencesKey("battery_prompt_handled") +} + +/** + * Maps persisted preferences to [AppSettings]. Shared with the backup agent so it reads the opt-in + * flag (and its default) through exactly the same logic the app uses. + */ +internal fun Preferences.toAppSettings(): AppSettings = AppSettings( + dynamicColor = this[Keys.DYNAMIC_COLOR] ?: true, + newMailNotifications = this[Keys.NEW_MAIL_NOTIFICATIONS] ?: true, + pushIdle = this[Keys.PUSH_IDLE] ?: true, + allowStartTls = this[Keys.ALLOW_STARTTLS] ?: false, + loadRemoteImages = this[Keys.LOAD_REMOTE_IMAGES] ?: false, + encryptCache = this[Keys.ENCRYPT_CACHE] ?: false, + appLock = this[Keys.APP_LOCK] ?: false, + includeInBackup = this[Keys.INCLUDE_IN_BACKUP] ?: false, + fetchPolicy = this[Keys.FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() } + ?: FetchPolicy.ALWAYS, +) + @Singleton class SettingsRepository @Inject constructor(@ApplicationContext private val context: Context) { - val settings: Flow = context.settingsDataStore.data.map { prefs -> - AppSettings( - dynamicColor = prefs[DYNAMIC_COLOR] ?: true, - newMailNotifications = prefs[NEW_MAIL_NOTIFICATIONS] ?: true, - pushIdle = prefs[PUSH_IDLE] ?: true, - allowStartTls = prefs[ALLOW_STARTTLS] ?: false, - loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false, - encryptCache = prefs[ENCRYPT_CACHE] ?: false, - appLock = prefs[APP_LOCK] ?: false, - fetchPolicy = prefs[FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() } - ?: FetchPolicy.ALWAYS, - ) - } + val settings: Flow = context.settingsDataStore.data.map { it.toAppSettings() } val dynamicColor: Flow = settings.map { it.dynamicColor } @@ -59,29 +87,41 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con suspend fun fetchPolicy(): FetchPolicy = settings.first().fetchPolicy - suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value) - suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value) - suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value) - suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value) - suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value) - suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, value) - suspend fun setAppLock(value: Boolean) = put(APP_LOCK, value) + /** + * One-time onboarding flag: whether the user has already seen/acted on the "unrestricted battery" + * opt-in, so onboarding asks at most once (see #49). Not part of [AppSettings] — it is internal + * onboarding state, not a user-facing preference. It rides along in Android Backup (the whole + * settings DataStore is one file); a restore therefore may skip the prompt on a device that isn't + * yet allowlisted — the Advanced Settings battery row is the recovery path there. + */ + suspend fun isBatteryPromptHandled(): Boolean = + context.settingsDataStore.data.map { it[Keys.BATTERY_PROMPT_HANDLED] ?: false }.first() + + suspend fun setBatteryPromptHandled(value: Boolean) = put(Keys.BATTERY_PROMPT_HANDLED, value) + + suspend fun setDynamicColor(value: Boolean) = put(Keys.DYNAMIC_COLOR, value) + suspend fun setNewMailNotifications(value: Boolean) = put(Keys.NEW_MAIL_NOTIFICATIONS, value) + suspend fun setPushIdle(value: Boolean) = put(Keys.PUSH_IDLE, value) + suspend fun setAllowStartTls(value: Boolean) = put(Keys.ALLOW_STARTTLS, value) + suspend fun setLoadRemoteImages(value: Boolean) = put(Keys.LOAD_REMOTE_IMAGES, value) + suspend fun setEncryptCache(value: Boolean) = put(Keys.ENCRYPT_CACHE, value) + suspend fun setAppLock(value: Boolean) = put(Keys.APP_LOCK, value) + + /** + * Opts this app in/out of system Android Backup. Off by default. After persisting, nudges the + * framework so the change takes effect on the next backup pass — enabling schedules a backup of + * the safe settings, disabling schedules one that ships nothing (clearing any prior cloud copy). + */ + suspend fun setIncludeInBackup(value: Boolean) { + put(Keys.INCLUDE_IN_BACKUP, value) + runCatching { BackupManager(context).dataChanged() } + } + suspend fun setFetchPolicy(value: FetchPolicy) { - context.settingsDataStore.edit { it[FETCH_POLICY] = value.name } + context.settingsDataStore.edit { it[Keys.FETCH_POLICY] = value.name } } private suspend fun put(key: Preferences.Key, value: Boolean) { context.settingsDataStore.edit { it[key] = value } } - - private companion object { - val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") - val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications") - val PUSH_IDLE = booleanPreferencesKey("push_idle") - val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls") - val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images") - val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache") - val APP_LOCK = booleanPreferencesKey("app_lock") - val FETCH_POLICY = stringPreferencesKey("fetch_policy") - } } diff --git a/app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt new file mode 100644 index 0000000..5bc3955 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.settings + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.libremail.data.local.dao.SignatureDao +import org.libremail.data.local.entity.SignatureEntity +import org.libremail.domain.model.Signature +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * CRUD for per-account signatures, keeping the "at most one default per account" invariant. The + * first signature created for an account becomes its default automatically; deleting the default + * promotes another (if any) so an account with signatures always has one to auto-insert. + */ +@Singleton +class SignatureRepository @Inject constructor(private val dao: SignatureDao) { + + fun observeForAccount(accountId: String): Flow> = + dao.observeForAccount(accountId).map { rows -> rows.map { it.toDomain() } } + + suspend fun get(id: String): Signature? = dao.getById(id)?.toDomain() + + suspend fun getDefault(accountId: String): Signature? = dao.getDefault(accountId)?.toDomain() + + /** Creates a signature; makes it the default when it is the account's first. Returns its id. */ + suspend fun create(accountId: String, name: String, html: String): String { + val id = UUID.randomUUID().toString() + val isFirst = dao.countForAccount(accountId) == 0 + dao.upsert(SignatureEntity(id, accountId, name, html, isDefault = isFirst)) + return id + } + + suspend fun update(id: String, name: String, html: String) { + val existing = dao.getById(id) ?: return + dao.upsert(existing.copy(name = name, contentHtml = html)) + } + + suspend fun delete(id: String) { + val existing = dao.getById(id) ?: return + dao.delete(id) + // If we removed the default, promote the account's first remaining signature. + if (existing.isDefault) { + dao.firstForAccount(existing.accountId)?.let { dao.markDefault(it.id) } + } + } + + suspend fun setDefault(accountId: String, id: String) = dao.setDefault(accountId, id) + + private fun SignatureEntity.toDomain() = Signature(id, accountId, name, contentHtml, isDefault) +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt index beacac7..26bdf62 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.libremail.auth.FreshToken -import org.libremail.auth.GmailAuthManager import org.libremail.auth.OutlookAuthManager import org.libremail.data.local.toImapParams import org.libremail.data.local.toSmtpParams @@ -23,7 +22,6 @@ import javax.inject.Singleton @Singleton class MailConnectionFactory @Inject constructor( private val credentialStore: CredentialStore, - private val gmailAuthManager: GmailAuthManager, private val outlookAuthManager: OutlookAuthManager, private val settingsRepository: SettingsRepository, ) { @@ -54,8 +52,6 @@ class MailConnectionFactory @Inject constructor( private suspend fun resolveSecret(account: Account): String = when (account.authType) { AuthType.PASSWORD_IMAP -> credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}") - AuthType.OAUTH_GMAIL -> - cachedAccessToken(account.id, SCOPE_GMAIL, gmailAuthManager::freshAccessToken) AuthType.OAUTH_OUTLOOK -> cachedAccessToken(account.id, SCOPE_OUTLOOK, outlookAuthManager::freshOutlookToken) } @@ -91,7 +87,6 @@ class MailConnectionFactory @Inject constructor( private suspend fun strictStartTls(): Boolean = !settingsRepository.settings.first().allowStartTls private companion object { - const val SCOPE_GMAIL = "gmail" const val SCOPE_OUTLOOK = "outlook" const val SCOPE_GRAPH = "graph" const val EXPIRY_BUFFER_MS = 60_000L diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt index 840182c..8122fde 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -54,8 +54,10 @@ class SendWorker @AssistedInject constructor( accountId = entity.accountId, to = entity.toAddresses, cc = entity.ccAddresses, + bcc = entity.bccAddresses, subject = entity.subject, body = entity.body, + bodyHtml = entity.bodyHtml, ) val files = orderedAttachments(attachmentDir) if (account.authType == AuthType.OAUTH_OUTLOOK) { diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index 1f159b3..566cab2 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -14,6 +14,7 @@ import net.zetetic.database.sqlcipher.SupportOpenHelperFactory import org.libremail.data.local.DatabaseEncryption import org.libremail.data.local.DatabaseFiles import org.libremail.data.local.LibreMailDatabase +import org.libremail.data.local.MIGRATION_10_11 import org.libremail.data.local.MIGRATION_1_2 import org.libremail.data.local.MIGRATION_2_3 import org.libremail.data.local.MIGRATION_3_4 @@ -22,6 +23,7 @@ import org.libremail.data.local.MIGRATION_5_6 import org.libremail.data.local.MIGRATION_6_7 import org.libremail.data.local.MIGRATION_7_8 import org.libremail.data.local.MIGRATION_8_9 +import org.libremail.data.local.MIGRATION_9_10 import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AccountSettingsDao import org.libremail.data.local.dao.AttachmentDao @@ -30,6 +32,7 @@ import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.FolderDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.dao.SignatureDao import org.libremail.data.security.DatabaseKeyStore import org.libremail.data.security.PassphraseSession import org.libremail.data.settings.SettingsRepository @@ -57,6 +60,8 @@ object DatabaseModule { MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + MIGRATION_9_10, + MIGRATION_10_11, ) // No destructive fallback: the migration chain is complete, and silently dropping the // accounts/credentials/mail tables would lose stored secrets. A missing migration should @@ -126,5 +131,8 @@ object DatabaseModule { @Provides fun provideFolderDao(database: LibreMailDatabase): FolderDao = database.folderDao() + @Provides + fun provideSignatureDao(database: LibreMailDatabase): SignatureDao = database.signatureDao() + private const val DB_NAME = DatabaseFiles.NAME } diff --git a/app/src/main/kotlin/org/libremail/di/ReportingModule.kt b/app/src/main/kotlin/org/libremail/di/ReportingModule.kt new file mode 100644 index 0000000..cf79ebd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/di/ReportingModule.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.libremail.reporting.ReportStore +import java.io.File +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ReportingModule { + + @Provides + @Singleton + fun provideReportStore(@ApplicationContext context: Context): ReportStore = + ReportStore(File(context.filesDir, "debug_reports")) +} diff --git a/app/src/main/kotlin/org/libremail/domain/model/Account.kt b/app/src/main/kotlin/org/libremail/domain/model/Account.kt index 3110427..3ccb80e 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt @@ -3,9 +3,6 @@ package org.libremail.domain.model /** How an account authenticates with its mail server. */ enum class AuthType { - /** Gmail via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */ - OAUTH_GMAIL, - /** Outlook / Microsoft via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */ OAUTH_OUTLOOK, diff --git a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt index e6cecd9..a29acb2 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt @@ -6,8 +6,11 @@ data class Draft( val accountId: String?, val to: String, val cc: String, + val bcc: String = "", val subject: String, val body: String, val updatedAt: Long, + /** HTML form of [body] when the draft was composed with formatting; null for plaintext drafts. */ + val bodyHtml: String? = null, val attachments: List = emptyList(), ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt b/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt new file mode 100644 index 0000000..c5a7188 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +// Standard mail submission ports, named so the presets below don't read as "magic numbers". +// File-level (not in the companion) so the enum entries can reference them during construction. +private const val IMAPS_PORT = 993 +private const val SMTP_SUBMISSION_PORT = 587 +private const val SMTPS_PORT = 465 + +/** + * Preconfigured IMAP/SMTP presets for the app-password vendors (Gmail, Yahoo, iCloud). + * + * These mirror [Account.outlook]: each entry knows its servers so onboarding only has to collect an + * email + app password (see the app-password setup screen). Outlook is intentionally NOT here — it + * uses interactive OAuth, not an app password. + * + * Port / security rationale (verified against current vendor docs): + * - IMAP is implicit TLS on 993 for all three: none of these vendors document a STARTTLS IMAP + * endpoint, so 993/[MailSecurity.SSL_TLS] is the only correct choice. + * - SMTP biases toward STARTTLS on 587 where the vendor documents it (Gmail, iCloud), matching the + * epic's "prefer STARTTLS where supported" guidance. Yahoo documents implicit TLS on 465 as its + * outgoing server, so it keeps 465/[MailSecurity.SSL_TLS]. + * - [MailSecurity.NONE] is never used — every path here is encrypted end to end. + */ +enum class MailProvider( + /** Stable lowercase key used as a navigation argument and to look a provider back up. */ + val key: String, + /** Brand name shown in the picker and setup screen (a proper noun, not localized). */ + val displayName: String, + /** The page where the user creates an app password for this provider. */ + val appPasswordHelpUrl: String, + private val imapHost: String, + private val smtpHost: String, + private val smtpPort: Int, + private val smtpSecurity: MailSecurity, +) { + GMAIL( + key = "gmail", + displayName = "Gmail", + appPasswordHelpUrl = "https://myaccount.google.com/apppasswords", + imapHost = "imap.gmail.com", + smtpHost = "smtp.gmail.com", + // Google documents smtp.gmail.com:587 with STARTTLS as the standard submission endpoint. + smtpPort = SMTP_SUBMISSION_PORT, + smtpSecurity = MailSecurity.STARTTLS, + ), + YAHOO( + key = "yahoo", + displayName = "Yahoo Mail", + appPasswordHelpUrl = "https://login.yahoo.com/account/security", + imapHost = "imap.mail.yahoo.com", + smtpHost = "smtp.mail.yahoo.com", + // Yahoo documents smtp.mail.yahoo.com:465 with implicit SSL/TLS as its outgoing server. + smtpPort = SMTPS_PORT, + smtpSecurity = MailSecurity.SSL_TLS, + ), + ICLOUD( + key = "icloud", + displayName = "iCloud Mail", + appPasswordHelpUrl = "https://appleid.apple.com", + imapHost = "imap.mail.me.com", + smtpHost = "smtp.mail.me.com", + // Apple documents smtp.mail.me.com:587 with STARTTLS for iCloud Mail. + smtpPort = SMTP_SUBMISSION_PORT, + smtpSecurity = MailSecurity.STARTTLS, + ), + ; + + /** + * Builds a [PASSWORD_IMAP][AuthType.PASSWORD_IMAP] [Account] for this provider. The caller + * supplies the address; the servers come from the preset. The id mirrors the manual-setup + * convention (`imap:`) so app-password and manual accounts share one identity scheme. + */ + fun createAccount(email: String, displayName: String = email): Account { + val address = email.trim() + return Account( + id = "imap:$address", + email = address, + displayName = displayName.trim().ifBlank { address }, + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig(imapHost, IMAPS_PORT, MailSecurity.SSL_TLS), + smtp = ServerConfig(smtpHost, smtpPort, smtpSecurity), + ) + } + + companion object { + /** Resolves a provider by its [key], or null if none matches (case-insensitive). */ + fun fromKey(key: String): MailProvider? = entries.firstOrNull { it.key.equals(key, ignoreCase = true) } + } +} diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt index bfa25bb..89104de 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt @@ -9,4 +9,6 @@ data class OutboxMessage( val body: String, val createdAt: Long, val lastError: String?, + /** HTML form of [body] when composed with formatting; null for plaintext messages. */ + val bodyHtml: String? = null, ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt index 559720d..7ee8892 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt @@ -1,13 +1,21 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.domain.model -/** A message the user is sending. [to]/[cc] are comma-separated address lists. */ +/** + * A message the user is sending. [to]/[cc]/[bcc] are comma-separated address lists. + * + * [body] is always the plain-text form. [bodyHtml] carries the HTML form when the message was + * composed with formatting; when it is null the message is sent as `text/plain` only (unchanged + * from the plaintext-only path), otherwise as `multipart/alternative` with both parts. + */ data class OutgoingMessage( val accountId: String, val to: String, val cc: String = "", + val bcc: String = "", val subject: String, val body: String, + val bodyHtml: String? = null, val attachments: List = emptyList(), ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/Signature.kt b/app/src/main/kotlin/org/libremail/domain/model/Signature.kt new file mode 100644 index 0000000..2537fab --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/Signature.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +import org.libremail.mail.HtmlToText + +/** + * A named, rich (HTML) email signature belonging to an account. Each account may have several; at + * most one is its [isDefault], which is the one auto-inserted when composing from that account. + */ +data class Signature( + val id: String, + val accountId: String, + val name: String, + /** The signature body as HTML (authored in the same rich editor as the message body). */ + val html: String, + val isDefault: Boolean = false, +) { + /** The signature as plain text, for the `text/plain` alternative and plaintext-only composing. */ + fun plainText(): String = HtmlToText.convert(html) +} diff --git a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt index b583df4..2071e71 100644 --- a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt @@ -84,13 +84,23 @@ class GraphSender @Inject constructor() { /** Builds the Graph `sendMail` JSON body (a pure function, so it is unit-testable without a network). */ internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List): String { + // Graph carries a single body object: send HTML when the message was formatted (Outlook renders + // it and derives its own plaintext), otherwise plain text so unformatted mail is unchanged. + val body = if (message.bodyHtml != null) { + JSONObject().put("contentType", "HTML").put("content", message.bodyHtml) + } else { + JSONObject().put("contentType", "Text").put("content", message.body) + } val mail = JSONObject() .put("subject", message.subject) - .put("body", JSONObject().put("contentType", "Text").put("content", message.body)) + .put("body", body) .put("toRecipients", recipientsJson(message.to)) if (message.cc.isNotBlank()) { mail.put("ccRecipients", recipientsJson(message.cc)) } + if (message.bcc.isNotBlank()) { + mail.put("bccRecipients", recipientsJson(message.bcc)) + } if (attachments.isNotEmpty()) { val items = JSONArray() attachments.forEach { file -> diff --git a/app/src/main/kotlin/org/libremail/mail/HtmlToText.kt b/app/src/main/kotlin/org/libremail/mail/HtmlToText.kt new file mode 100644 index 0000000..b61f3d5 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/HtmlToText.kt @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +/** + * Converts an HTML body into readable plain text for the `text/plain` alternative part and for + * quoting HTML originals in replies/forwards. Pure (no Android/Compose), so it is unit-testable on + * the JVM. + * + * It is intentionally lightweight — block elements become line breaks, list items gain a "• " + * marker, tags are dropped and entities decoded — rather than a full HTML renderer. The goal is a + * legible fallback, not a faithful reproduction. + */ +object HtmlToText { + + private val BLOCK_BREAK = Regex( + "(?i)]*>|", + ) + private val LIST_ITEM = Regex("(?i)]*>") + + fun convert(html: String): String { + var s = html + // Drop script/style contents outright so their text never leaks into the output. + s = s.replace(Regex("(?is)<(script|style)\\b[^>]*>.*?"), "") + s = LIST_ITEM.replace(s, "\n• ") + s = BLOCK_BREAK.replace(s, "\n") + s = s.replace(Regex("<[^>]*>"), "") + s = decodeEntities(s) + // Collapse runs of spaces/tabs, then trim trailing spaces and cap consecutive blank lines. + s = s.replace(Regex("[ \\t]+"), " ") + s = s.lineSequence().joinToString("\n") { it.trim() } + s = s.replace(Regex("\n{3,}"), "\n\n") + return s.trim() + } + + private fun decodeEntities(s: String): String = s + .replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("—", "—") + .replace("–", "–") + .replace("&", "&") +} diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt index b034896..7ce2d18 100644 --- a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -7,6 +7,7 @@ import jakarta.mail.internet.InternetAddress import jakarta.mail.internet.MimeBodyPart import jakarta.mail.internet.MimeMessage import jakarta.mail.internet.MimeMultipart +import jakarta.mail.internet.MimePart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.libremail.domain.model.MailSecurity @@ -59,19 +60,13 @@ class SmtpSender @Inject constructor() { if (message.cc.isNotBlank()) { setRecipients(Message.RecipientType.CC, InternetAddress.parse(message.cc)) } + if (message.bcc.isNotBlank()) { + setRecipients(Message.RecipientType.BCC, InternetAddress.parse(message.bcc)) + } subject = message.subject sentDate = Date() } - if (attachments.isEmpty()) { - mime.setText(message.body, "UTF-8") - } else { - val multipart = MimeMultipart() - multipart.addBodyPart(MimeBodyPart().apply { setText(message.body, "UTF-8") }) - attachments.forEach { file -> - multipart.addBodyPart(MimeBodyPart().apply { attachFile(file) }) - } - mime.setContent(multipart) - } + applyBody(mime, message, attachments) val transport = session.getTransport(protocol) transport.connect(params.host, params.port, params.username, params.secret) @@ -82,6 +77,40 @@ class SmtpSender @Inject constructor() { } } + /** + * Sets the message body: + * - plaintext-only ([OutgoingMessage.bodyHtml] null): a single `text/plain` part, exactly as + * before, so unformatted mail is unchanged on the wire; + * - formatted: a `multipart/alternative` of `text/plain` (fallback, first) + `text/html` + * (preferred, last, per RFC 2046). + * When there are attachments the body is nested inside a `multipart/mixed` as its first part. + */ + private fun applyBody(mime: MimeMessage, message: OutgoingMessage, attachments: List) { + if (attachments.isEmpty()) { + setBody(mime, message) + return + } + val mixed = MimeMultipart("mixed") + mixed.addBodyPart(MimeBodyPart().also { setBody(it, message) }) + attachments.forEach { file -> mixed.addBodyPart(MimeBodyPart().apply { attachFile(file) }) } + mime.setContent(mixed) + } + + /** Writes the body onto [part]: plain text, or a text/plain + text/html alternative when formatted. */ + private fun setBody(part: MimePart, message: OutgoingMessage) { + val html = message.bodyHtml + if (html == null) { + part.setText(message.body, "UTF-8") + } else { + part.setContent(alternative(message.body, html)) + } + } + + private fun alternative(plain: String, html: String): MimeMultipart = MimeMultipart("alternative").apply { + addBodyPart(MimeBodyPart().apply { setText(plain, "UTF-8") }) + addBodyPart(MimeBodyPart().apply { setContent(html, "text/html; charset=UTF-8") }) + } + private companion object { const val TIMEOUT_MS = "15000" } diff --git a/app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt b/app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt new file mode 100644 index 0000000..a694b7c --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Reads and deep-links this app's system battery-optimization state, so the user can move LibreMail + * to "Unrestricted" and keep [IdleService]'s push connection (and periodic sync) from being throttled + * or torn down by Doze. + * + * We deliberately do NOT use the restricted `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission or its + * one-tap dialog: Google Play limits that permission to an approved set of use cases (rejection risk, + * see #17). Sending the user to the system screen instead needs no extra permission and is safe on + * both Play and F-Droid (#16). + * + * Note [isIgnoringBatteryOptimizations] reflects the Doze allowlist: it is `true` only for the + * "Unrestricted" setting and `false` for *both* "Optimized" and "Restricted", so it cannot single out + * the (most harmful) "Restricted" state on its own. + */ +@Singleton +class BatteryOptimizationManager @Inject constructor(@ApplicationContext private val context: Context) { + /** + * Whether the platform exposes battery-optimization control. The Doze allowlist has existed since + * API 23, so at our minSdk (29) this is always true; the version check keeps [BatteryPromptDecision] + * honest if the floor ever drops below API 23. + */ + val isSupported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + + /** True when this app is currently exempt from battery optimization ("Unrestricted"). */ + fun isIgnoringBatteryOptimizations(): Boolean { + val powerManager = context.getSystemService(PowerManager::class.java) ?: return false + return powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + /** + * Intent to this app's system details screen, where the user can open **Battery** and choose + * **Unrestricted**. App-details is targeted (rather than the flat battery-optimization list) + * because it is the only route to the Unrestricted/Optimized/Restricted setting and lands + * directly on LibreMail. Always resolvable since API 9. + */ + fun settingsIntent(): Intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) +} diff --git a/app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt b/app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt new file mode 100644 index 0000000..7d3ab3d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +/** + * Pure decision for whether onboarding should surface the battery opt-in step. Deliberately free of + * Android types so it is exhaustively unit-testable; the live inputs are read by + * [BatteryOptimizationManager] and [org.libremail.data.settings.SettingsRepository]. + */ +object BatteryPromptDecision { + + /** + * Prompt only when all three hold: + * - [supported]: the platform exposes battery-optimization control (the Doze allowlist is API 23+, + * so this is always true at our minSdk — kept explicit so the rule stays correct if the floor + * ever drops). + * - not [alreadyUnrestricted]: an app already exempt from battery optimization gains nothing. + * - not [alreadyHandled]: the user has already seen and acted on (or dismissed) the prompt, so we + * don't nag on a later onboarding run. + */ + fun shouldPrompt(supported: Boolean, alreadyUnrestricted: Boolean, alreadyHandled: Boolean): Boolean = + supported && !alreadyUnrestricted && !alreadyHandled +} diff --git a/app/src/main/kotlin/org/libremail/reporting/AppLog.kt b/app/src/main/kotlin/org/libremail/reporting/AppLog.kt new file mode 100644 index 0000000..74a2b7f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/AppLog.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.util.Log + +/** + * Logging facade that mirrors Logcat output into the process [RingLogBuffer] so recent activity can + * be attached to a user-reviewed [DebugReport]. Call [install] once at startup. Never pass PII + * (email addresses, message content, credentials) to these methods — the buffer can end up in a + * report the user reviews and may submit. + */ +object AppLog { + @Volatile + private var buffer: RingLogBuffer? = null + + fun install(buffer: RingLogBuffer) { + this.buffer = buffer + } + + fun d(tag: String, message: String) { + Log.d(tag, message) + buffer?.record('D', tag, message) + } + + fun i(tag: String, message: String) { + Log.i(tag, message) + buffer?.record('I', tag, message) + } + + fun w(tag: String, message: String) { + Log.w(tag, message) + buffer?.record('W', tag, message) + } + + fun e(tag: String, message: String, throwable: Throwable? = null) { + Log.e(tag, message, throwable) + buffer?.record('E', tag, message) + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt b/app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt new file mode 100644 index 0000000..3d6e7ee --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** Resolves the app's version once, tolerating any lookup failure. */ +@Singleton +class AppVersionProvider @Inject constructor(@ApplicationContext context: Context) { + val versionName: String + val versionCode: Long + + init { + var name = UNKNOWN + var code = 0L + runCatching { + @Suppress("DEPRECATION") // getPackageInfo(String, int) is fine for a plain version lookup. + val info = context.packageManager.getPackageInfo(context.packageName, 0) + name = info.versionName ?: UNKNOWN + code = info.longVersionCode + } + versionName = name + versionCode = code + } + + private companion object { + const val UNKNOWN = "unknown" + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt b/app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt new file mode 100644 index 0000000..d889dd4 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.os.Process +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.system.exitProcess + +/** + * Installs a process-wide uncaught-exception handler that persists a crash [DebugReport] locally for + * review on next launch. Reports are NEVER sent automatically — submission is strictly user-initiated + * (see [ReportSubmitter]). The previous handler is always invoked so the system still shows the crash + * and terminates the process. + */ +@Singleton +class CrashReporter @Inject constructor( + private val collector: DiagnosticsCollector, + private val store: ReportStore, + private val logBuffer: RingLogBuffer, +) { + fun install() { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + persist(throwable) + if (previous != null) { + previous.uncaughtException(thread, throwable) + } else { + terminate() + } + } + } + + /** + * Saves a crash report. Wrapped so a failure here can never mask the original crash. Exposed for + * tests; installing the handler simply routes uncaught exceptions here. + */ + fun persist(throwable: Throwable) { + runCatching { + logBuffer.record('E', TAG, "Uncaught exception: ${throwable.javaClass.name}") + store.save(collector.collectCrash(throwable)) + } + } + + private fun terminate() { + Process.killProcess(Process.myPid()) + exitProcess(EXIT_FAILURE) + } + + private companion object { + const val TAG = "CrashReporter" + const val EXIT_FAILURE = 10 + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt b/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt new file mode 100644 index 0000000..3b6d929 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import org.json.JSONArray +import org.json.JSONObject +import java.time.Instant + +/** Whether a report was produced by an uncaught crash or requested manually by the user. */ +enum class ReportKind { CRASH, MANUAL } + +/** + * A locally-stored diagnostic report. It never leaves the device unless the user explicitly submits + * it (see [ReportSubmitter]); [toSubmissionPayload] is the single rendering that is shown for + * review, copied, saved to a file, and POSTed on submit, so what the user reads is exactly what is + * sent. Only the fields assembled by `DiagnosticsCollector` are captured — no message content. + */ +@Suppress("LongParameterList") // A flat diagnostic DTO; grouping fields would only obscure the payload. +data class DebugReport( + val id: String, + val createdAtMillis: Long, + val kind: ReportKind, + val appVersionName: String, + val appVersionCode: Long, + val androidRelease: String, + val androidSdkInt: Int, + val deviceManufacturer: String, + val deviceModel: String, + val stackTrace: String?, + val settings: Map, + val logs: List, + val userComment: String = "", +) { + /** The exact text shown for review, copied, saved to a file, and POSTed on submit. */ + fun toSubmissionPayload(): String = toJson().toString(JSON_INDENT) + + /** Compact form used for on-disk persistence. */ + fun toStorageJson(): String = toJson().toString() + + private fun toJson(): JSONObject { + val app = JSONObject() + .put("versionName", appVersionName) + .put("versionCode", appVersionCode) + val device = JSONObject() + .put("manufacturer", deviceManufacturer) + .put("model", deviceModel) + .put("androidRelease", androidRelease) + .put("sdkInt", androidSdkInt) + val settingsJson = JSONObject() + settings.forEach { (key, value) -> settingsJson.put(key, value) } + val json = JSONObject() + .put("id", id) + .put("createdAt", Instant.ofEpochMilli(createdAtMillis).toString()) + .put("createdAtMillis", createdAtMillis) + .put("kind", kind.name) + .put("app", app) + .put("device", device) + .put("userComment", userComment) + .put("settings", settingsJson) + .put("logs", JSONArray(logs)) + if (stackTrace != null) json.put("stackTrace", stackTrace) + return json + } + + companion object { + private const val JSON_INDENT = 2 + + fun fromStorageJson(raw: String): DebugReport { + val json = JSONObject(raw) + val app = json.getJSONObject("app") + val device = json.getJSONObject("device") + val settingsJson = json.getJSONObject("settings") + val settings = LinkedHashMap() + settingsJson.keys().forEach { key -> settings[key] = settingsJson.getString(key) } + val logsJson = json.getJSONArray("logs") + val logs = ArrayList(logsJson.length()) + for (i in 0 until logsJson.length()) logs.add(logsJson.getString(i)) + return DebugReport( + id = json.getString("id"), + createdAtMillis = json.getLong("createdAtMillis"), + kind = ReportKind.valueOf(json.getString("kind")), + appVersionName = app.getString("versionName"), + appVersionCode = app.getLong("versionCode"), + androidRelease = device.getString("androidRelease"), + androidSdkInt = device.getInt("sdkInt"), + deviceManufacturer = device.getString("manufacturer"), + deviceModel = device.getString("model"), + stackTrace = if (json.has("stackTrace")) json.getString("stackTrace") else null, + settings = settings, + logs = logs, + userComment = json.optString("userComment", ""), + ) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt b/app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt new file mode 100644 index 0000000..6b09218 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.os.Build +import kotlinx.coroutines.flow.first +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Assembles a [DebugReport] from app/device metadata, a stack trace (for crashes), a minimal + * non-PII settings summary, and the recent in-app log buffer. Only the fields listed in [summarize] + * are captured — deliberately no account emails, server names, or message content. + */ +@Singleton +class DiagnosticsCollector @Inject constructor( + private val appVersion: AppVersionProvider, + private val settingsRepository: SettingsRepository, + private val logBuffer: RingLogBuffer, +) { + // Cached so a crash report (built synchronously on the crashing thread) can still include + // settings without touching DataStore. Refreshed whenever settings are read for a manual + // report or explicitly warmed at startup. + @Volatile + private var cachedSettings: Map = emptyMap() + + /** Pre-reads settings so a later crash report can include them. Safe to call and ignore. */ + suspend fun warmSettingsCache() { + cachedSettings = summarize(settingsRepository.settings.first()) + } + + /** Builds a report for a user-initiated ("Report a problem") request; includes live settings. */ + suspend fun collectManual(): DebugReport { + val settings = summarize(settingsRepository.settings.first()) + cachedSettings = settings + return build(ReportKind.MANUAL, throwable = null, settings = settings) + } + + /** Builds a crash report synchronously; it must not block or throw on the crashing thread. */ + fun collectCrash(throwable: Throwable): DebugReport = + build(ReportKind.CRASH, throwable = throwable, settings = cachedSettings) + + private fun build(kind: ReportKind, throwable: Throwable?, settings: Map) = DebugReport( + id = UUID.randomUUID().toString(), + createdAtMillis = System.currentTimeMillis(), + kind = kind, + appVersionName = appVersion.versionName, + appVersionCode = appVersion.versionCode, + androidRelease = Build.VERSION.RELEASE ?: "", + androidSdkInt = Build.VERSION.SDK_INT, + deviceManufacturer = Build.MANUFACTURER ?: "", + deviceModel = Build.MODEL ?: "", + stackTrace = throwable?.stackTraceToString(), + settings = settings, + logs = logBuffer.snapshot().map { it.formatted() }, + ) + + private fun summarize(settings: AppSettings): Map = linkedMapOf( + "dynamicColor" to settings.dynamicColor.toString(), + "newMailNotifications" to settings.newMailNotifications.toString(), + "pushIdle" to settings.pushIdle.toString(), + "allowStartTls" to settings.allowStartTls.toString(), + "loadRemoteImages" to settings.loadRemoteImages.toString(), + "encryptCache" to settings.encryptCache.toString(), + "fetchPolicy" to settings.fetchPolicy.name, + ) +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt new file mode 100644 index 0000000..247b1ed --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File + +/** + * File-backed store of pending [DebugReport]s — one JSON file per report under [directory]. + * Deliberately NOT Room-backed: a crash-time save must be simple and robust, independent of the + * (possibly encrypted, possibly mid-migration) app database. Reports persist until the user submits + * or discards them. [reports] is a snapshot that updates on every [save]/[delete]. + */ +class ReportStore(private val directory: File) { + private val lock = Any() + private val _reports = MutableStateFlow(scan()) + val reports: StateFlow> = _reports.asStateFlow() + + fun save(report: DebugReport) { + synchronized(lock) { + directory.mkdirs() + File(directory, fileName(report.id)).writeText(report.toStorageJson()) + _reports.value = scan() + } + } + + fun find(id: String): DebugReport? = _reports.value.firstOrNull { it.id == id } + + fun delete(id: String) { + synchronized(lock) { + File(directory, fileName(id)).delete() + _reports.value = scan() + } + } + + private fun scan(): List { + val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) } + ?: return emptyList() + return files + .mapNotNull { file -> runCatching { DebugReport.fromStorageJson(file.readText()) }.getOrNull() } + .sortedByDescending { it.createdAtMillis } + } + + private fun fileName(id: String) = "$id$SUFFIX" + + private companion object { + const val SUFFIX = ".json" + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt b/app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt new file mode 100644 index 0000000..b116381 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import androidx.work.WorkInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.libremail.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +/** Coarse state of a user-initiated report submission. */ +enum class SubmitStatus { IDLE, SUBMITTING, SUCCEEDED, FAILED } + +/** + * The single seam through which a report can leave the device, and only when the user taps Submit. + * [isEnabled] is false when no ingest endpoint is configured (the default in this repo), so the UI + * can steer the user to Copy/Save instead of a submission that would only fail. + */ +@Singleton +class ReportSubmitter @Inject constructor(private val scheduler: ReportUploadScheduler) { + val isEnabled: Boolean get() = BuildConfig.DEBUG_REPORT_ENDPOINT.isNotBlank() + + fun submit(reportId: String) { + scheduler.enqueue(reportId) + } + + fun status(reportId: String): Flow = scheduler.statusFlow(reportId).map(::toStatus) + + private fun toStatus(infos: List): SubmitStatus { + if (infos.isEmpty()) return SubmitStatus.IDLE + return when { + infos.any { it.state == WorkInfo.State.FAILED || it.state == WorkInfo.State.CANCELLED } -> + SubmitStatus.FAILED + infos.all { it.state == WorkInfo.State.SUCCEEDED } -> SubmitStatus.SUCCEEDED + else -> SubmitStatus.SUBMITTING + } + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt b/app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt new file mode 100644 index 0000000..370354a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Enqueues a user-initiated report upload as a retrying WorkManager job and observes its state. */ +@Singleton +class ReportUploadScheduler @Inject constructor(@ApplicationContext private val context: Context) { + fun enqueue(reportId: String) { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .setInputData(workDataOf(ReportUploadWorker.KEY_REPORT_ID to reportId)) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .addTag(TAG) + .build() + // REPLACE: a fresh Submit tap starts a clean attempt for this report, overriding any pending + // retry-backoff. Reports are keyed per id, so distinct reports never collide. + WorkManager.getInstance(context) + .enqueueUniqueWork(uniqueName(reportId), ExistingWorkPolicy.REPLACE, request) + } + + fun statusFlow(reportId: String): Flow> = + WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(uniqueName(reportId)) + + private fun uniqueName(reportId: String) = "$WORK_PREFIX$reportId" + + companion object { + const val TAG = "libremail_report_upload" + private const val WORK_PREFIX = "libremail_report_upload_" + private const val BACKOFF_SECONDS = 30L + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt b/app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt new file mode 100644 index 0000000..444b3c7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.libremail.BuildConfig +import java.net.HttpURLConnection +import java.net.URL + +/** + * Posts a single user-submitted report to the ingest endpoint. It runs ONLY when the user tapped + * Submit (enqueued by [ReportUploadScheduler]); nothing here runs automatically. The endpoint is + * [BuildConfig.DEBUG_REPORT_ENDPOINT] — empty by default, because the ingest server (issue #34) is + * out of scope for this repo, so submissions no-op with a clear failure until an endpoint is set. + */ +@HiltWorker +class ReportUploadWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val store: ReportStore, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val id = inputData.getString(KEY_REPORT_ID) ?: return Result.success() + val report = store.find(id) ?: return Result.success() // discarded before the job ran + val endpoint = BuildConfig.DEBUG_REPORT_ENDPOINT + if (endpoint.isBlank()) return Result.failure() // no ingest server configured in this build + return withContext(Dispatchers.IO) { + runCatching { post(endpoint, report.toSubmissionPayload()) }.fold( + onSuccess = { code -> onResponse(code, id) }, + onFailure = { retryOrFail() }, // network error — retry with backoff + ) + } + } + + private fun onResponse(code: Int, id: String): Result = when { + code in SUCCESS_CODES -> { + store.delete(id) // delivered — drop the local copy + Result.success() + } + code in SERVER_ERROR_CODES -> retryOrFail() // transient server-side failure + else -> Result.failure() // client error (4xx) — retrying won't help + } + + private fun retryOrFail(): Result = if (runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry() + + private fun post(endpoint: String, body: String): Int { + val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("Content-Type", "application/json; charset=utf-8") + } + return try { + connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + connection.responseCode + } finally { + connection.disconnect() + } + } + + companion object { + const val KEY_REPORT_ID = "report_id" + private val SUCCESS_CODES = 200..299 + private val SERVER_ERROR_CODES = 500..599 + private const val TIMEOUT_MS = 15_000 + private const val MAX_ATTEMPTS = 5 + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt b/app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt new file mode 100644 index 0000000..390bbb3 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** One captured log line. */ +data class LogEntry(val timeMillis: Long, val level: Char, val tag: String, val message: String) { + fun formatted(): String = "${Instant.ofEpochMilli(timeMillis)} $level/$tag: $message" +} + +/** + * A small, thread-safe, in-memory ring buffer of recent app log lines. It is held in memory only + * (never written to disk on its own) and copied into a [DebugReport] on request. Callers must never + * record PII (email addresses, message content, credentials) — see [AppLog]. + */ +@Singleton +class RingLogBuffer @Inject constructor() { + private val lock = Any() + private val entries = ArrayDeque() + + fun record(level: Char, tag: String, message: String) { + synchronized(lock) { + if (entries.size >= CAPACITY) entries.removeFirst() + entries.addLast(LogEntry(System.currentTimeMillis(), level, tag, message)) + } + } + + fun snapshot(): List = synchronized(lock) { entries.toList() } + + fun clear() { + synchronized(lock) { entries.clear() } + } + + private companion object { + const val CAPACITY = 200 + } +} diff --git a/app/src/main/kotlin/org/libremail/richtext/RichText.kt b/app/src/main/kotlin/org/libremail/richtext/RichText.kt new file mode 100644 index 0000000..923ee13 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/richtext/RichText.kt @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.richtext + +/** Inline character styles the compose editor supports. */ +enum class RichStyle { BOLD, ITALIC, UNDERLINE } + +/** A run of [style] over the half-open range [[start], [end]) of the plain text. */ +data class RichSpan(val start: Int, val end: Int, val style: RichStyle) + +/** A hyperlink over the half-open range [[start], [end]) pointing at [url]. */ +data class RichLink(val start: Int, val end: Int, val url: String) + +/** + * The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links]. + * + * Block structure (unordered/ordered lists and block quotes) is encoded as recognizable line + * prefixes inside [text] — "• " for bullets, "N. " for numbered items, and "> " for quotes — so + * the editor can stay a single plain-text field (keeping the plaintext-only experience smooth) + * while the model still round-trips cleanly to real HTML and to a readable plaintext fallback. + */ +data class RichTextContent( + val text: String = "", + val spans: List = emptyList(), + val links: List = emptyList(), +) { + val isBlank: Boolean get() = text.isBlank() + + /** + * True when the content carries anything a plaintext field could not represent: inline styling, + * a link, or a block marker. When false, callers should send/persist plaintext only so an + * unformatted message stays byte-for-byte identical to the old plaintext-only path. + */ + fun hasFormatting(): Boolean = + spans.isNotEmpty() || links.isNotEmpty() || text.lineSequence().any { lineMarker(it) != null } +} + +/** Recognized block markers and the tags they map to. */ +internal const val BULLET_PREFIX = "• " +internal const val QUOTE_PREFIX = "> " +private val ORDERED_PREFIX = Regex("^\\d+\\. ") + +private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE } + +private data class Line(val kind: Kind, val contentStart: Int, val contentEnd: Int) + +/** The block marker prefixing [line], or null for an ordinary paragraph line. */ +internal fun lineMarker(line: String): String? = when { + line.startsWith(BULLET_PREFIX) -> BULLET_PREFIX + line.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX + else -> ORDERED_PREFIX.find(line)?.value +} + +/** + * Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or + * Compose types), so the whole conversion is unit-testable on the JVM. + * + * The emitted subset — `
    /
      /

    1. ` — 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("
    2. ").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("
    3. ") + i++ + } + sb.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("
      ") + sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)) + i++ + } + sb.append("
      ") + return i +} + +private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List, from: Int): Int { + sb.append("

      ") + var i = from + while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) { + if (i > from) sb.append("
      ") + sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)) + i++ + } + 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 { + 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("
      • One
      • Two
      ")) + } + + @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("

      Hello there

      \n\n\n

      bye

      ") + 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
      World

      ", 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("

      bold 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("
      • Milk
      • Eggs
      ", RichTextHtml.toHtml(RichTextContent("• Milk\n• Eggs"))) + assertEquals("
      1. One
      2. Two
      ", RichTextHtml.toHtml(RichTextContent("1. One\n2. Two"))) + assertEquals("
      a
      b
      ", 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("
        \n
      • One
      • \n
      • Two
      • \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() + 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=