Merge remote-tracking branch 'origin/main' into feat-screen-unlock
Brings the screen-lock app gate (#22) up to date with 25 commits of main (signatures, backup opt-in, battery optimization, rich compose, reporting). Conflicts resolved as a union of both features: - SettingsRepository: adopt main's top-level Keys + shared toAppSettings() refactor and thread appLock through it; keep both appLock and includeInBackup - DatabaseModule: keep provideSignatureDao; keep DatabaseFiles.NAME for DB_NAME - MainActivity: wrap LibreMailApp(pendingCompose=...) inside AppLockGateHost - SettingsViewModel/SettingsScreen: union app-lock and battery state/effects; keep LocalResources for the app-lock toast (LocalContextGetResourceValueCall lint) - SettingsScreenTest: construct SettingsViewModel with the merged 5 args - strings.xml: keep both the app-lock and battery/diagnostics string blocks Fast gate green with JDK 21: assembleDebug + testDebugUnitTest + lintDebug + compileDebugAndroidTestKotlin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,28 +5,37 @@ A free and open-source email client for Android, built with Kotlin, Jetpack
|
||||
Compose and Material 3 (Material You). LibreMail aims for a friendly default
|
||||
experience with power-user features tucked under an **Advanced Settings** group.
|
||||
|
||||
> Status: **in development.** Material You shell; **account setup** (Gmail OAuth via
|
||||
> AppAuth/PKCE and generic IMAP/SMTP, with a live connection test and Keystore-
|
||||
> encrypted credentials); **IMAP receive** — background sync (WorkManager) into a local
|
||||
> Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and
|
||||
> rendered in a hardened WebView (JavaScript off, remote images blocked by default),
|
||||
> with mark-read, star, and delete; and **composing** — a compose screen with device-
|
||||
> contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued
|
||||
> and retried, with a viewable outbox folder), plus reply and **drafts** saved for
|
||||
> later; **on-device new-mail
|
||||
> notifications** (no push service) with persisted settings; **instant push** via a
|
||||
> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a
|
||||
> system viewer, and attach files when composing; **multiple accounts** — a unified inbox
|
||||
> with per-account filtering; and
|
||||
> **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft**
|
||||
> accounts (OAuth 2.0 sign-in, IMAP receive + Microsoft Graph send, SMTP/XOAUTH2 fallback).
|
||||
> Status: **in development.** Material You shell; **onboarding** — a first-run flow from a
|
||||
> welcome screen through a vendor picker (Outlook/Hotmail, Gmail, Yahoo, iCloud, or Other) and
|
||||
> per-vendor setup to your first account's inbox; **account setup** — Outlook/Microsoft via
|
||||
> OAuth 2.0 (AppAuth/PKCE), Gmail/Yahoo/iCloud via app password, and generic IMAP/SMTP, all
|
||||
> with a live connection test and Keystore-encrypted credentials; **IMAP receive** — background
|
||||
> sync (WorkManager) into a local Room cache with pull-to-refresh, backfilling your **entire**
|
||||
> mail history (resumable) with an optional device-only retention cap; **reading** — message
|
||||
> bodies fetched on open and rendered in a hardened WebView (JavaScript off, remote images
|
||||
> blocked by default), with mark-read, star, and delete; **composing** — a rich-text HTML
|
||||
> editor with a formatting toolbar and per-account signatures that sends
|
||||
> `multipart/alternative` (HTML with a plaintext fallback) through a reliable background
|
||||
> **outbox** (WorkManager-queued and retried, with a viewable outbox folder), plus
|
||||
> device-contacts autocomplete, reply, and **drafts**; **on-device new-mail notifications** (no
|
||||
> push service) with persisted settings; **instant push** via a foreground **IMAP IDLE**
|
||||
> service; **attachments** — downloaded on demand and opened in a system viewer, and attach
|
||||
> files when composing; **multiple accounts** — a unified inbox with per-account filtering;
|
||||
> **search** across cached mail and the server (IMAP SEARCH); Outlook/Microsoft send via
|
||||
> Microsoft **Graph** with an SMTP/XOAUTH2 fallback; an opt-in **app lock**
|
||||
> (biometric/device-credential) that binds the encrypted cache key to your unlock; **mailto:**
|
||||
> link handling with optional default-mail-app registration; and opt-in, F-Droid-safe **debug
|
||||
> reporting** — local crash/error capture that you review (with a PII disclaimer) and submit
|
||||
> only on an explicit action.
|
||||
|
||||
## Features (target MVP)
|
||||
|
||||
- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider.
|
||||
- Send and receive email with **Outlook/Microsoft** (OAuth 2.0), **Gmail, Yahoo and iCloud** (app password), and **any other IMAP/SMTP** provider.
|
||||
- Guided first-run onboarding: welcome → vendor picker → per-vendor setup → your inbox.
|
||||
- Material You dynamic theming, light/dark, edge-to-edge.
|
||||
- Clean compose screen with phone/account contacts integration.
|
||||
- Modern security: OAuth 2.0 Authorization Code + PKCE, no stored passwords for Gmail.
|
||||
- Rich-text compose with a formatting toolbar, per-account signatures, and phone/account contacts integration.
|
||||
- Offline-first: a local Room cache with full-history backfill and an optional device-only retention limit.
|
||||
- Modern, opt-in security: OAuth 2.0 (Authorization Code + PKCE) for Outlook, Keystore-encrypted credentials, optional SQLCipher cache encryption, and a biometric/device-credential app lock.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -67,27 +76,27 @@ sdkmanager "platforms;android-37.0" "build-tools;37.0.0"
|
||||
`local.properties` (git-ignored) must point `sdk.dir` at your Android SDK; Android
|
||||
Studio creates it automatically.
|
||||
|
||||
## Gmail account setup (OAuth client)
|
||||
## Accounts and onboarding
|
||||
|
||||
Gmail IMAP/SMTP requires the restricted `https://mail.google.com/` scope. While the
|
||||
app is unpublished you can use it in **Testing** mode with up to 100 test users and
|
||||
no security assessment; a public Play Store release later requires a Google CASA
|
||||
assessment for the restricted scope.
|
||||
On first launch LibreMail runs a short onboarding flow: a welcome screen, a **vendor picker**
|
||||
(Outlook/Hotmail, Gmail, Yahoo, iCloud, or **Other**), per-vendor setup, and an "add another
|
||||
account?" prompt before it drops you on your first account's inbox. You can add more accounts
|
||||
later from settings; a unified inbox merges them with per-account filtering.
|
||||
|
||||
1. In the [Google Cloud Console](https://console.cloud.google.com/), create a
|
||||
project (e.g. *LibreMail*).
|
||||
2. **APIs & Services → Library →** enable the **Gmail API**.
|
||||
3. **OAuth consent screen:** user type *External*; add the scope
|
||||
`https://mail.google.com/`; under **Test users**, add your Google address.
|
||||
Leave the app in **Testing**.
|
||||
4. **Credentials → Create credentials → OAuth client ID → Android.** Use package
|
||||
name `org.libremail.app` and your debug keystore SHA-1:
|
||||
```bash
|
||||
keytool -list -v -keystore "$HOME/.android/debug.keystore" \
|
||||
-alias androiddebugkey -storepass android -keypass android
|
||||
```
|
||||
5. Copy `secrets.properties.example` to `secrets.properties` (git-ignored) and set
|
||||
`GMAIL_OAUTH_CLIENT_ID` to your client ID. The build injects it via `BuildConfig`.
|
||||
LibreMail supports three kinds of account:
|
||||
|
||||
- **Outlook / Hotmail** — signs in with **OAuth 2.0** through Microsoft (AppAuth); no password
|
||||
is stored. See [Outlook / Microsoft account setup](#outlook--microsoft-account-setup-oauth-client)
|
||||
below.
|
||||
- **Gmail, Yahoo and iCloud** — preconfigured IMAP/SMTP that authenticate with a provider
|
||||
**app password** (not your normal account password), preferring STARTTLS where the provider
|
||||
supports it. Onboarding links you to each vendor's app-password page. **Gmail requires
|
||||
2-Step Verification to be enabled** before Google will issue an app password.
|
||||
- **Other** — a manual IMAP/SMTP form (host, port, security, and credentials) for any other
|
||||
provider.
|
||||
|
||||
App passwords and OAuth tokens are held in a credential store encrypted with the Android
|
||||
Keystore, and every account runs a live connection test before it is saved.
|
||||
|
||||
## Outlook / Microsoft account setup (OAuth client)
|
||||
|
||||
@@ -108,6 +117,25 @@ token. A working client ID ships with the build; to use your own Azure app regis
|
||||
4. Copy the **Application (client) ID** into `secrets.properties` as
|
||||
`OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default).
|
||||
|
||||
## Privacy and data flow
|
||||
|
||||
LibreMail is offline-first: your mail lives in a local cache, and by default network traffic
|
||||
goes only to your mail providers (IMAP/SMTP, plus Microsoft's OAuth and Graph endpoints for
|
||||
Outlook). There is no analytics SDK and no always-on telemetry. The privacy-sensitive extras
|
||||
are all **opt-in**:
|
||||
|
||||
- **Cache encryption** — the Room cache can be encrypted at rest with **SQLCipher**. With the
|
||||
optional **app lock** (biometric or device credential) enabled, the cache key is bound to
|
||||
your authentication, so the database is only decrypted after you unlock the app.
|
||||
- **Debug reporting** — **off by default.** When enabled, crashes and errors are captured
|
||||
**locally**; you review the full report — shown with a plain-language **PII disclaimer** —
|
||||
and it is sent only when you explicitly submit it, to a configurable (optional) endpoint.
|
||||
There is no hosted crash pipeline collecting reports in the background.
|
||||
- **Android Backup** — **off by default.** When you turn it on, only safe app settings are
|
||||
backed up; the encrypted-database key, account credentials, and the mail cache are
|
||||
**excluded**. Because Android's backup transport can route data through Google, it stays
|
||||
disabled unless you opt in — the kind of optional behavior F-Droid lists as an anti-feature.
|
||||
|
||||
## Architecture
|
||||
|
||||
Offline-first, unidirectional, layered:
|
||||
@@ -119,9 +147,9 @@ data/ Room (entities, DAOs, database) + repository implementation (source o
|
||||
di/ Hilt modules
|
||||
```
|
||||
|
||||
The UI observes Room via `Flow`; later increments add a sync engine (Angus Mail
|
||||
over IMAP/SMTP) that writes into Room, and an auth layer (AppAuth + an Android
|
||||
Keystore-backed credential store).
|
||||
The UI observes Room via `Flow`; a sync engine (Angus Mail over IMAP/SMTP, plus Microsoft
|
||||
Graph for Outlook send) writes into Room, and an auth layer (AppAuth for OAuth and an Android
|
||||
Keystore-backed credential store for app passwords) handles sign-in.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+17
-16
@@ -14,21 +14,12 @@ plugins {
|
||||
alias(libs.plugins.detekt)
|
||||
}
|
||||
|
||||
// Read the Gmail OAuth client id from secrets.properties (git-ignored). Empty when absent.
|
||||
// Read optional build secrets (Outlook client id, release signing) from secrets.properties
|
||||
// (git-ignored). Absent values fall back to the defaults below.
|
||||
val secretsFile = rootProject.file("secrets.properties")
|
||||
val secrets = Properties().apply {
|
||||
if (secretsFile.exists()) secretsFile.inputStream().use { load(it) }
|
||||
}
|
||||
val gmailOAuthClientId: String = secrets.getProperty("GMAIL_OAUTH_CLIENT_ID", "")
|
||||
|
||||
// For a Google installed-app OAuth client, AppAuth's redirect is the reversed client
|
||||
// id as a custom URI scheme. Fall back to a placeholder so the manifest stays valid
|
||||
// until a real client id is set in secrets.properties.
|
||||
val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleusercontent.com")) {
|
||||
"com.googleusercontent.apps." + gmailOAuthClientId.removeSuffix(".apps.googleusercontent.com")
|
||||
} else {
|
||||
"org.libremail.oauth"
|
||||
}
|
||||
|
||||
// Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via
|
||||
// secrets.properties; defaults to the app's registered client id.
|
||||
@@ -37,6 +28,15 @@ val outlookOAuthClientId: String = secrets.getProperty(
|
||||
"04e4aa5e-ed1f-47f9-b567-b99a0b29b3df",
|
||||
)
|
||||
|
||||
// Custom URI scheme AppAuth uses to capture the Outlook OAuth redirect. Must match the scheme of
|
||||
// OUTLOOK_OAUTH_REDIRECT_URI and the redirect URI registered in the Azure app registration.
|
||||
val outlookRedirectScheme = "org.libremail.outlook"
|
||||
|
||||
// Debug-report ingest endpoint (issue #34, out of scope for this repo). Empty by default: the debug
|
||||
// reporting client is strictly opt-in and never sends anything unless the user taps Submit AND an
|
||||
// endpoint is configured here (overridable via git-ignored secrets.properties).
|
||||
val debugReportEndpoint: String = secrets.getProperty("DEBUG_REPORT_ENDPOINT", "")
|
||||
|
||||
// Optional release signing, configured via git-ignored secrets.properties. When absent, release
|
||||
// builds fall back to the debug key (installable for testing, but not publishable).
|
||||
val releaseStoreFile: String? = secrets.getProperty("RELEASE_STORE_FILE")
|
||||
@@ -55,12 +55,12 @@ android {
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"")
|
||||
buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"")
|
||||
buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"")
|
||||
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"")
|
||||
// AppAuth captures the OAuth redirect via this custom scheme.
|
||||
manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme
|
||||
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"$outlookRedirectScheme://oauth2redirect\"")
|
||||
buildConfigField("String", "DEBUG_REPORT_ENDPOINT", "\"$debugReportEndpoint\"")
|
||||
// AppAuth's bundled manifest requires this placeholder; it registers the redirect scheme on
|
||||
// RedirectUriReceiverActivity so the Outlook sign-in redirect returns to the app.
|
||||
manifestPlaceholders["appAuthRedirectScheme"] = outlookRedirectScheme
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -198,6 +198,7 @@ dependencies {
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.espresso.intents)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 10,
|
||||
"identityHash": "aff2afe197ff1b0b41630e5a165d09f3",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aff2afe197ff1b0b41630e5a165d09f3')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 11,
|
||||
"identityHash": "a31c7e2934217228a837ab5453025da4",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "signatures",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentHtml",
|
||||
"columnName": "contentHtml",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_signatures_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a31c7e2934217228a837ab5453025da4')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ class FakeAccountRepository(
|
||||
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> {
|
||||
addedAccount = account
|
||||
addedPassword = password
|
||||
// Mirror the real repository: a successful add makes the account observable, so screens that
|
||||
// react to the account list (e.g. the mailbox after onboarding) see it appear.
|
||||
if (result.isSuccess) {
|
||||
accountsFlow.value = accountsFlow.value.filterNot { it.id == account.id } + account
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class ManualSetupScreenTest {
|
||||
// Build the view model once and capture it, so recomposition doesn't recreate it.
|
||||
private fun setContent(
|
||||
repository: FakeAccountRepository = FakeAccountRepository(),
|
||||
onAccountAdded: () -> Unit = {},
|
||||
onAccountAdded: (String) -> Unit = {},
|
||||
) {
|
||||
val viewModel = ManualSetupViewModel(repository)
|
||||
composeTestRule.setContent {
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.libremail.R
|
||||
import org.libremail.contacts.ContactsRepository
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
@@ -81,6 +82,7 @@ class ComposeScreenTest {
|
||||
accountRepository = FakeAccountRepository(accounts = listOf(account)),
|
||||
contactsRepository = ContactsRepository(context),
|
||||
accountSettingsRepository = AccountSettingsRepository(database.accountSettingsDao()),
|
||||
signatureRepository = SignatureRepository(database.signatureDao()),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
@@ -71,7 +72,12 @@ class MailboxScreenTest {
|
||||
)
|
||||
|
||||
private fun setContent(repo: FakeMailRepository) {
|
||||
val viewModel = MailboxViewModel(repo, FakeAccountRepository(accounts = listOf(account)), FakeMailSyncer())
|
||||
val viewModel = MailboxViewModel(
|
||||
repo,
|
||||
FakeAccountRepository(accounts = listOf(account)),
|
||||
FakeMailSyncer(),
|
||||
SavedStateHandle(),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
MailboxScreen(
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Instrumentation
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.hamcrest.CoreMatchers.allOf
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.push.BatteryOptimizationManager
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end test for the onboarding battery opt-in step (#49). It drives the real
|
||||
* [BatteryOptimizationScreen] + graph-scoped [OnboardingViewModel] through a NavHost that mirrors the
|
||||
* production "add another? → (optional) battery → inbox" tail (see
|
||||
* `LibreMailApp.onboardingFinishDestinations`).
|
||||
*
|
||||
* Battery status comes from the real [BatteryOptimizationManager]: a fresh emulator is never on the
|
||||
* battery allowlist, so the step is offered. The "already unrestricted" skip can't be forced from a
|
||||
* test (there's no API to set it) and is covered by the view-model unit tests; the "already handled"
|
||||
* skip is exercised here through the real settings DataStore.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BatteryOptimizationStepTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private lateinit var settingsRepository: SettingsRepository
|
||||
private lateinit var onboarding: OnboardingViewModel
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(10_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "add another? → battery → inbox" tail with one account already added this session,
|
||||
* starting on the add-another prompt. [handled] seeds the persisted "prompt handled" flag so the
|
||||
* skip path can be exercised through the real repository.
|
||||
*/
|
||||
private fun setContent(handled: Boolean) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
settingsRepository = SettingsRepository(context)
|
||||
runBlocking { settingsRepository.setBatteryPromptHandled(handled) }
|
||||
onboarding = OnboardingViewModel(BatteryOptimizationManager(context), settingsRepository)
|
||||
onboarding.onAccountAdded(FIRST_ACCOUNT_ID)
|
||||
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController = navController, startDestination = Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
composable(Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
|
||||
AddAnotherAccountScreen(
|
||||
onAddAnother = {},
|
||||
onFinish = {
|
||||
if (batteryPromptNeeded == true) {
|
||||
navController.navigate(Routes.ONBOARDING_BATTERY)
|
||||
} else {
|
||||
navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
|
||||
popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.ONBOARDING_BATTERY) {
|
||||
BatteryOptimizationScreen(
|
||||
viewModel = onboarding,
|
||||
onFinish = {
|
||||
onboarding.markBatteryPromptHandled()
|
||||
navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
|
||||
popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.MAILBOX_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
),
|
||||
) {
|
||||
Text(INBOX_MARKER)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun batteryStep_isOffered_thenNotNow_landsOnInbox() {
|
||||
setContent(handled = false)
|
||||
// The decision resolves asynchronously (a DataStore read); wait before driving the finish tap.
|
||||
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
|
||||
// The battery opt-in step is shown...
|
||||
waitForText(string(R.string.onboarding_battery_title))
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed()
|
||||
|
||||
// ...and "Not now" continues to the inbox and records the prompt as handled (so it won't nag).
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick()
|
||||
waitForText(INBOX_MARKER)
|
||||
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
|
||||
composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun batteryStep_takeMeThere_opensThisAppsSystemSettings() {
|
||||
setContent(handled = false)
|
||||
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
waitForText(string(R.string.onboarding_battery_title))
|
||||
|
||||
val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName
|
||||
Intents.init()
|
||||
try {
|
||||
// Stub the match so the real system settings screen never actually launches mid-test.
|
||||
Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS))
|
||||
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick()
|
||||
|
||||
// Deep-links to *this app's* details screen (where Battery → Unrestricted lives).
|
||||
Intents.intended(
|
||||
allOf(
|
||||
hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS),
|
||||
hasData(Uri.fromParts("package", packageName, null)),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
Intents.release()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finish_skipsBatteryStep_whenAlreadyHandled() {
|
||||
setContent(handled = true)
|
||||
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == false }
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
|
||||
// Straight to the inbox — the opt-in step is skipped entirely.
|
||||
waitForText(INBOX_MARKER)
|
||||
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INBOX_MARKER = "INBOX-REACHED"
|
||||
const val FIRST_ACCOUNT_ID = "imap:e2e@example.com"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.auth.OutlookAuthManager
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.push.BatteryOptimizationManager
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.FakeMailSyncer
|
||||
import org.libremail.ui.accountsetup.AccountPickerScreen
|
||||
import org.libremail.ui.accountsetup.AccountSetupViewModel
|
||||
import org.libremail.ui.accountsetup.AppPasswordSetupScreen
|
||||
import org.libremail.ui.accountsetup.AppPasswordViewModel
|
||||
import org.libremail.ui.mailbox.MailboxScreen
|
||||
import org.libremail.ui.mailbox.MailboxViewModel
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end test of the onboarding flow: a fresh install (no accounts) walks welcome → vendor
|
||||
* picker → app-password setup → "add another?" → the first account's inbox.
|
||||
*
|
||||
* It drives the real onboarding screens + ViewModels through a real [NavHost]. The account backend is
|
||||
* the in-memory [FakeAccountRepository] (a successful add makes the account observable) rather than a
|
||||
* live server — GreenMail-backed connection behaviour is covered by the repository unit tests; this
|
||||
* test owns the cross-screen navigation contract.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OnboardingFlowTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
// Generous cap for the slow, animation-disabled CI matrix emulators; waitUntil returns as soon
|
||||
// as the text appears, so the happy path is unaffected.
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(15_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
private fun inboxMessage() = Message(
|
||||
id = "imap:e2e@gmail.com:INBOX:1",
|
||||
accountId = "imap:e2e@gmail.com",
|
||||
sender = "Welcome",
|
||||
senderEmail = "welcome@gmail.com",
|
||||
subject = "E2E first message",
|
||||
snippet = "",
|
||||
body = "",
|
||||
isHtml = false,
|
||||
timestampMillis = 1_000L,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
folder = "INBOX",
|
||||
inInbox = true,
|
||||
bodyFetched = false,
|
||||
)
|
||||
|
||||
private fun setOnboardingContent(accountRepo: FakeAccountRepository, mailRepo: FakeMailRepository) {
|
||||
// Real collaborators are cheap here: the manager just wraps PowerManager and the repository
|
||||
// reads the on-device settings DataStore. This test drives its own nav graph (without the
|
||||
// battery step), so the onboarding view model's battery decision is inert for this flow.
|
||||
val appContext = composeTestRule.activity.applicationContext
|
||||
val onboarding = OnboardingViewModel(
|
||||
BatteryOptimizationManager(appContext),
|
||||
SettingsRepository(appContext),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val outlookAuthManager = remember { OutlookAuthManager(context) }
|
||||
NavHost(navController = navController, startDestination = Routes.ONBOARDING_WELCOME) {
|
||||
composable(Routes.ONBOARDING_WELCOME) {
|
||||
OnboardingWelcomeScreen(
|
||||
onAddAccount = { navController.navigate(Routes.ONBOARDING_PICKER) },
|
||||
)
|
||||
}
|
||||
composable(Routes.ONBOARDING_PICKER) {
|
||||
val viewModel = remember { AccountSetupViewModel(outlookAuthManager, accountRepo) }
|
||||
AccountPickerScreen(
|
||||
onBack = {},
|
||||
onAccountAdded = { id ->
|
||||
onboarding.onAccountAdded(id)
|
||||
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER)
|
||||
},
|
||||
onPickProvider = { provider ->
|
||||
navController.navigate(Routes.onboardingAppPassword(provider.key))
|
||||
},
|
||||
onManualSetup = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.ONBOARDING_APP_PASSWORD_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val key = entry.arguments?.getString(Routes.APP_PASSWORD_ARG_PROVIDER).orEmpty()
|
||||
val viewModel = remember {
|
||||
AppPasswordViewModel(
|
||||
SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to key)),
|
||||
accountRepo,
|
||||
)
|
||||
}
|
||||
AppPasswordSetupScreen(
|
||||
onBack = {},
|
||||
onAccountAdded = { id ->
|
||||
onboarding.onAccountAdded(id)
|
||||
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
popUpTo(Routes.ONBOARDING_PICKER)
|
||||
}
|
||||
},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composable(Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
AddAnotherAccountScreen(
|
||||
onAddAnother = {
|
||||
navController.navigate(Routes.ONBOARDING_PICKER) {
|
||||
popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onFinish = {
|
||||
val id = onboarding.firstAddedAccountId
|
||||
val dest = if (id != null) Routes.mailboxForAccount(id) else Routes.MAILBOX
|
||||
navController.navigate(dest) {
|
||||
popUpTo(Routes.ONBOARDING_WELCOME) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.MAILBOX_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
val account = entry.arguments?.getString(Routes.MAILBOX_ARG_ACCOUNT).orEmpty()
|
||||
val viewModel = remember {
|
||||
MailboxViewModel(
|
||||
mailRepo,
|
||||
accountRepo,
|
||||
FakeMailSyncer(),
|
||||
SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to account)),
|
||||
)
|
||||
}
|
||||
MailboxScreen(
|
||||
onOpenMessage = {},
|
||||
onCompose = {},
|
||||
onOpenDrafts = {},
|
||||
onOpenOutbox = {},
|
||||
onAddAccount = {},
|
||||
onOpenCompose = {},
|
||||
onSelectTab = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onboarding_addsAppPasswordAccount_thenLandsOnFirstAccountInbox() {
|
||||
val accountRepo = FakeAccountRepository()
|
||||
val mailRepo = FakeMailRepository(messages = listOf(inboxMessage()))
|
||||
setOnboardingContent(accountRepo, mailRepo)
|
||||
|
||||
// Welcome → picker.
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
|
||||
|
||||
// Picker → Gmail app-password setup.
|
||||
waitForText("Gmail")
|
||||
composeTestRule.onNodeWithText("Gmail").performClick()
|
||||
|
||||
// App-password setup: email + app password come from the user; servers come from the preset.
|
||||
// performScrollTo first — on the short default matrix emulator the fields and the "Test and
|
||||
// add" button sit below the fold of this scrolling screen, and a positional click on an
|
||||
// off-screen button is a silent no-op (which is why this passed only on API 37's taller AVD).
|
||||
waitForText(string(R.string.app_password_email))
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_email))
|
||||
.performScrollTo().performTextInput("e2e@gmail.com")
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_field))
|
||||
.performScrollTo().performTextInput("app-pass")
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add))
|
||||
.performScrollTo().performClick()
|
||||
|
||||
// "Add another?" prompt → No.
|
||||
waitForText(string(R.string.onboarding_add_another_prompt))
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
|
||||
// Landed on the first (and only) account's inbox.
|
||||
waitForText("E2E first message")
|
||||
composeTestRule.onNodeWithText("E2E first message").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
@@ -19,6 +18,7 @@ import org.libremail.R
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
@@ -48,6 +48,8 @@ class AccountSettingsScreenTest {
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private var manageSignaturesClicked = false
|
||||
|
||||
private fun setContent(): AccountSettingsRepository {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
// Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by
|
||||
@@ -64,24 +66,27 @@ class AccountSettingsScreenTest {
|
||||
savedStateHandle = SavedStateHandle(mapOf(Routes.ACCOUNT_SETTINGS_ARG_ID to account.id)),
|
||||
accountRepository = FakeAccountRepository(accounts = listOf(account)),
|
||||
accountSettingsRepository = repository,
|
||||
signatureRepository = SignatureRepository(db.signatureDao()),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AccountSettingsScreen(onBack = {}, viewModel = viewModel)
|
||||
AccountSettingsScreen(
|
||||
onBack = {},
|
||||
onManageSignatures = { manageSignaturesClicked = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editingSignature_persistsThroughTheRepository() {
|
||||
val repository = setContent()
|
||||
fun manageSignatures_opensTheSignaturesScreen() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_signature_hint)).performTextInput("Cheers")
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
runBlocking { repository.get(account.id).signature } == "Cheers"
|
||||
}
|
||||
composeTestRule.waitUntil(5_000) { manageSignaturesClicked }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.libremail.data.security.KeystoreCrypto
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.push.BatteryOptimizationManager
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
@@ -46,11 +47,23 @@ class SettingsScreenTest {
|
||||
override fun availability() = AppLockAvailability.NONE_ENROLLED
|
||||
}
|
||||
val keyStore = DatabaseKeyStore(context, KeystoreCrypto(), DatabaseKeyCipher(), PassphraseSession())
|
||||
val viewModel = SettingsViewModel(FakeAccountRepository(), settingsRepository, appLockManager, keyStore)
|
||||
val viewModel = SettingsViewModel(
|
||||
FakeAccountRepository(),
|
||||
settingsRepository,
|
||||
appLockManager,
|
||||
keyStore,
|
||||
BatteryOptimizationManager(context),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
SettingsScreen(onAddAccount = {}, onOpenAccount = {}, onSelectTab = {}, viewModel = viewModel)
|
||||
SettingsScreen(
|
||||
onAddAccount = {},
|
||||
onOpenAccount = {},
|
||||
onSelectTab = {},
|
||||
onReportProblem = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,19 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!-- Android Backup is opt-in and OFF by default (issue #21). allowBackup can't be toggled at
|
||||
runtime, so LibreMailBackupAgent gates it on the "Include settings in Android Backup"
|
||||
preference: with backup disabled the agent ships nothing. When enabled, only the settings
|
||||
DataStore is backed up per the allowlist in data_extraction_rules (API 31+) /
|
||||
backup_rules (API 29-30) — never credentials, the mail cache, or the Keystore-sealed cache
|
||||
passphrase. fullBackupOnly keeps this to Auto Backup (full-data) only. -->
|
||||
<application
|
||||
android:name=".LibreMailApplication"
|
||||
android:allowBackup="false"
|
||||
android:allowBackup="true"
|
||||
android:backupAgent=".backup.LibreMailBackupAgent"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:fullBackupOnly="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -29,6 +38,38 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle mailto: links tapped in browsers and other apps (ACTION_VIEW), which is also
|
||||
what makes LibreMail appear on the system "Open by default" / default-apps screen
|
||||
where the platform exposes an email association. Android has no public RoleManager
|
||||
email role, so becoming the system default is OEM-dependent — hence "where supported". -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mailto" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- "Send email to <address>" targets from other apps (contacts, dialer, etc.). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SENDTO" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mailto" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Share-to-email: text and RFC-822 email payloads with recipients/subject/body extras. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:mimeType="message/rfc822" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="message/rfc822" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- WorkManager is initialized on-demand via Configuration.Provider, so remove the
|
||||
@@ -61,25 +102,20 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
|
||||
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
|
||||
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
|
||||
(mobile/desktop) redirect in the Azure app registration.
|
||||
AppAuth's RedirectUriReceiverActivity extends AppCompatActivity, so it needs an
|
||||
AppCompat theme; without this it inherits the app's Theme.Material shell and crashes
|
||||
("You need to use a Theme.AppCompat theme") when the redirect launches it. We reuse the
|
||||
translucent theme AppAuth itself applies to AuthorizationManagementActivity. -->
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth's bundled manifest
|
||||
already declares RedirectUriReceiverActivity with an intent-filter for
|
||||
${appAuthRedirectScheme}, which build.gradle.kts sets to the Outlook scheme
|
||||
(org.libremail.outlook); the redirect URI org.libremail.outlook://oauth2redirect must
|
||||
be registered as a public-client (mobile/desktop) redirect in the Azure app
|
||||
registration. We only merge a theme onto that activity here: AppAuth declares no theme
|
||||
and RedirectUriReceiverActivity extends AppCompatActivity, so without an AppCompat theme
|
||||
it inherits the app's Theme.Material shell and crashes ("You need to use a Theme.AppCompat
|
||||
theme") when the redirect launches it. We reuse the translucent theme AppAuth itself
|
||||
applies to AuthorizationManagementActivity. -->
|
||||
<activity
|
||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.AppCompat.Translucent.NoTitleBar"
|
||||
tools:node="merge">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="org.libremail.outlook" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -16,6 +16,10 @@ import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.push.IdlePushManager
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.CrashReporter
|
||||
import org.libremail.reporting.DiagnosticsCollector
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
@@ -33,6 +37,12 @@ class LibreMailApplication :
|
||||
|
||||
@Inject lateinit var idlePushManager: IdlePushManager
|
||||
|
||||
@Inject lateinit var ringLogBuffer: RingLogBuffer
|
||||
|
||||
@Inject lateinit var crashReporter: CrashReporter
|
||||
|
||||
@Inject lateinit var diagnosticsCollector: DiagnosticsCollector
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
|
||||
@@ -46,6 +56,13 @@ class LibreMailApplication :
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Wire up debug reporting first so crashes during the rest of startup are still captured.
|
||||
AppLog.install(ringLogBuffer)
|
||||
crashReporter.install()
|
||||
AppLog.i(TAG, "Application created")
|
||||
// Warm the settings cache so a later crash report can include non-PII settings without
|
||||
// touching DataStore on the crashing thread.
|
||||
appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } }
|
||||
syncScheduler.schedulePeriodicSync()
|
||||
// Run the IMAP IDLE push service only while it has something to do: the push setting is on
|
||||
// AND at least one account exists. This starts it when the first account is added and stops
|
||||
@@ -72,4 +89,8 @@ class LibreMailApplication :
|
||||
fun ensurePushStarted() {
|
||||
if (pushShouldBeActive) idlePushManager.start()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "LibreMail"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package org.libremail
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -12,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
@@ -19,6 +21,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.ui.LibreMailApp
|
||||
import org.libremail.ui.compose.ComposePrefill
|
||||
import org.libremail.ui.compose.IntentComposeParser
|
||||
import org.libremail.ui.lock.AppLockGateHost
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import javax.inject.Inject
|
||||
@@ -32,6 +36,12 @@ class MainActivity : FragmentActivity() {
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
/**
|
||||
* A pending compose request parsed from a `mailto:` / share intent, consumed once by the NavHost.
|
||||
* Held as Compose state so [onNewIntent] can re-trigger it while the activity is alive.
|
||||
*/
|
||||
private val pendingCompose = mutableStateOf<ComposePrefill?>(null)
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Foreground: recover IDLE push if a background start was previously blocked.
|
||||
@@ -41,6 +51,11 @@ class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
// Only on a fresh launch — on a config-change recreation the NavHost restores the compose
|
||||
// destination itself, so re-parsing the (unchanged) intent would open a duplicate.
|
||||
if (savedInstanceState == null) {
|
||||
pendingCompose.value = IntentComposeParser.parse(intent)
|
||||
}
|
||||
setContent {
|
||||
val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
|
||||
LibreMailTheme(dynamicColor = dynamicColor) {
|
||||
@@ -48,11 +63,20 @@ class MainActivity : FragmentActivity() {
|
||||
// Gate the whole app behind the screen-lock when app-lock is enabled. When it is off
|
||||
// the gate resolves straight to the content, so this is a no-op for most users.
|
||||
AppLockGateHost {
|
||||
LibreMailApp()
|
||||
LibreMailApp(
|
||||
pendingCompose = pendingCompose.value,
|
||||
onComposeHandled = { pendingCompose.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
IntentComposeParser.parse(intent)?.let { pendingCompose.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationRequest
|
||||
import net.openid.appauth.AuthorizationResponse
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import net.openid.appauth.AuthorizationServiceConfiguration
|
||||
import net.openid.appauth.ResponseTypeValues
|
||||
import org.json.JSONObject
|
||||
import org.libremail.BuildConfig
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/**
|
||||
* Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted
|
||||
* `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2.
|
||||
*/
|
||||
@Singleton
|
||||
class GmailAuthManager @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
private val serviceConfig = AuthorizationServiceConfiguration(
|
||||
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
|
||||
Uri.parse("https://oauth2.googleapis.com/token"),
|
||||
)
|
||||
|
||||
/** False until a Google OAuth client id is provided in secrets.properties (see README). */
|
||||
val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank()
|
||||
|
||||
fun createAuthIntent(): Intent {
|
||||
val request = AuthorizationRequest.Builder(
|
||||
serviceConfig,
|
||||
BuildConfig.GMAIL_OAUTH_CLIENT_ID,
|
||||
ResponseTypeValues.CODE,
|
||||
Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI),
|
||||
)
|
||||
.setScope("openid email profile https://mail.google.com/")
|
||||
.build()
|
||||
return AuthorizationService(context).getAuthorizationRequestIntent(request)
|
||||
}
|
||||
|
||||
suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
|
||||
val response = AuthorizationResponse.fromIntent(responseIntent)
|
||||
val exception = AuthorizationException.fromIntent(responseIntent)
|
||||
if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
|
||||
|
||||
val service = AuthorizationService(context)
|
||||
try {
|
||||
val tokenResponse = suspendCancellableCoroutine { continuation ->
|
||||
service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
|
||||
if (token != null) {
|
||||
continuation.resume(token)
|
||||
} else {
|
||||
continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
val authState = AuthState(response, exception).apply { update(tokenResponse, null) }
|
||||
val email = emailFromIdToken(tokenResponse.idToken)
|
||||
?: throw IllegalStateException("Could not read the account email from the token")
|
||||
return OAuthResult(
|
||||
email = email,
|
||||
accessToken = tokenResponse.accessToken.orEmpty(),
|
||||
authStateJson = authState.jsonSerializeString(),
|
||||
)
|
||||
} finally {
|
||||
service.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */
|
||||
suspend fun freshAccessToken(authStateJson: String): FreshToken {
|
||||
val authState = AuthState.jsonDeserialize(authStateJson)
|
||||
val service = AuthorizationService(context)
|
||||
try {
|
||||
val accessToken = suspendCancellableCoroutine { continuation ->
|
||||
authState.performActionWithFreshTokens(service) { token, _, error ->
|
||||
if (token != null) {
|
||||
continuation.resume(token)
|
||||
} else {
|
||||
continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return FreshToken(
|
||||
accessToken = accessToken,
|
||||
authStateJson = authState.jsonSerializeString(),
|
||||
accessTokenExpiry = authState.accessTokenExpirationTime,
|
||||
)
|
||||
} finally {
|
||||
service.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private fun emailFromIdToken(idToken: String?): String? {
|
||||
if (idToken.isNullOrBlank()) return null
|
||||
return runCatching {
|
||||
val payload = idToken.split(".").getOrNull(1) ?: return null
|
||||
val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
|
||||
JSONObject(json).optString("email").ifBlank { null }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.backup
|
||||
|
||||
import org.libremail.data.settings.AppSettings
|
||||
|
||||
/**
|
||||
* Single source of truth for what LibreMail is willing to hand to Android Backup. Kept in lockstep
|
||||
* with `res/xml/data_extraction_rules.xml` (API 31+) and `res/xml/backup_rules.xml` (API 29-30); the
|
||||
* path constants here are asserted against those resources by `DataExtractionRulesTest`.
|
||||
*
|
||||
* Only re-creatable user preferences are eligible. The mail cache re-downloads on the next sync, and
|
||||
* the credentials plus the Keystore-sealed cache passphrase are device-bound secrets that would only
|
||||
* ever restore as undecryptable ciphertext — so they are never backed up.
|
||||
*/
|
||||
object BackupPolicy {
|
||||
|
||||
/** `filesDir`-relative DataStore file holding user preferences — the only data we back up. */
|
||||
const val SAFE_SETTINGS_FILE: String = "datastore/libremail_settings.preferences_pb"
|
||||
|
||||
/** `filesDir`-relative paths that must never leave the device. */
|
||||
val EXCLUDED_FILE_PATHS: List<String> = listOf(
|
||||
// Keystore-sealed SQLCipher passphrase for the encrypted cache: the wrapping key is
|
||||
// non-exportable and device-bound, so this ciphertext is useless anywhere else.
|
||||
"datastore/libremail_dbkey.preferences_pb",
|
||||
)
|
||||
|
||||
/** `databases`-dir-relative names that must never leave the device (encrypted credentials + mail cache). */
|
||||
val EXCLUDED_DATABASE_PATHS: List<String> = listOf(
|
||||
"libremail.db",
|
||||
"libremail.db-wal",
|
||||
"libremail.db-shm",
|
||||
"libremail.db-journal",
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up
|
||||
* (or transferred device-to-device) unless the user has explicitly enabled it in Settings.
|
||||
*/
|
||||
fun shouldBackUp(settings: AppSettings): Boolean = settings.includeInBackup
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.backup
|
||||
|
||||
import android.app.backup.BackupAgentHelper
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.libremail.data.settings.settingsDataStore
|
||||
import org.libremail.data.settings.toAppSettings
|
||||
|
||||
/**
|
||||
* Enforces the runtime backup opt-in on top of Android Auto Backup.
|
||||
*
|
||||
* `android:allowBackup` is a manifest flag that can't be toggled at runtime, so the "include settings
|
||||
* in Android Backup" preference is enforced here instead: [onFullBackup] runs the backup only when the
|
||||
* user has opted in (the default is off, so no app data leaves the device). When opted in, it defers to
|
||||
* the framework, which applies the allowlist in `res/xml/data_extraction_rules.xml` (and
|
||||
* `res/xml/backup_rules.xml` on API < 31) — backing up the user-preferences DataStore only, never the
|
||||
* mail cache, the encrypted credentials, or the Keystore-sealed cache passphrase.
|
||||
*
|
||||
* Extends [BackupAgentHelper] (rather than raw `BackupAgent`) so the unused key/value backup/restore
|
||||
* paths inherit safe no-op implementations; only full-data backup is used (`fullBackupOnly=true`), and
|
||||
* full-data restore uses the default `onRestoreFile` handling.
|
||||
*
|
||||
* The opt-in flag is read directly from the shared [settingsDataStore] singleton so it does not depend
|
||||
* on Hilt or `Application.onCreate` having run in the framework's restricted backup mode.
|
||||
*/
|
||||
class LibreMailBackupAgent : BackupAgentHelper() {
|
||||
|
||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||
if (backupOptedIn()) {
|
||||
super.onFullBackup(data)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the opt-in flag; any failure defaults to "not opted in" so we never back up by accident. */
|
||||
private fun backupOptedIn(): Boolean = runCatching {
|
||||
runBlocking {
|
||||
BackupPolicy.shouldBackUp(applicationContext.settingsDataStore.data.first().toAppSettings())
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -2,40 +2,55 @@
|
||||
package org.libremail.data
|
||||
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.mail.HtmlToText
|
||||
import org.libremail.mail.ReplyContext
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/** The pre-filled compose fields for a reply/forward (everything else the user fills in). */
|
||||
data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String)
|
||||
/**
|
||||
* The pre-filled compose fields for a reply/forward. [body] is the plaintext form; [bodyHtml] is the
|
||||
* matching HTML (the quote rendered as a `<blockquote>`), so the reply can go out as
|
||||
* `multipart/alternative` without the user having to re-format the quote.
|
||||
*/
|
||||
data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String, val bodyHtml: String)
|
||||
|
||||
/**
|
||||
* Pure builder that turns an original message ([ReplyContext]) into the pre-filled compose fields for a
|
||||
* reply, reply-all, or forward. Kept free of Android/IMAP dependencies so it can be unit-tested directly.
|
||||
*
|
||||
* HTML originals are quoted by first reducing them to readable text (via [HtmlToText]) and then
|
||||
* quoting that — never by prefixing "> " onto raw tags — so the quote can never corrupt the markup.
|
||||
* The plaintext quote's "> " / attribution structure is then rendered to a clean `<blockquote>` for
|
||||
* the HTML alternative.
|
||||
*/
|
||||
object ReplyBuilder {
|
||||
|
||||
fun build(context: ReplyContext, mode: ReplyMode, selfEmail: String): ReplyContent = when (mode) {
|
||||
ReplyMode.REPLY -> ReplyContent(
|
||||
to = context.fromEmail,
|
||||
cc = "",
|
||||
subject = prefixedSubject(context.subject, "Re:"),
|
||||
body = quotedReply(context),
|
||||
)
|
||||
ReplyMode.REPLY -> reply(context, cc = "")
|
||||
ReplyMode.REPLY_ALL -> reply(context, cc = replyAllCc(context, selfEmail).joinToString(", "))
|
||||
ReplyMode.FORWARD -> {
|
||||
val body = forwardedBody(context)
|
||||
ReplyContent(
|
||||
to = "",
|
||||
cc = "",
|
||||
subject = prefixedSubject(context.subject, "Fwd:"),
|
||||
body = body,
|
||||
bodyHtml = htmlOf(body),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ReplyMode.REPLY_ALL -> ReplyContent(
|
||||
private fun reply(context: ReplyContext, cc: String): ReplyContent {
|
||||
val body = quotedReply(context)
|
||||
return ReplyContent(
|
||||
to = context.fromEmail,
|
||||
cc = replyAllCc(context, selfEmail).joinToString(", "),
|
||||
cc = cc,
|
||||
subject = prefixedSubject(context.subject, "Re:"),
|
||||
body = quotedReply(context),
|
||||
)
|
||||
|
||||
ReplyMode.FORWARD -> ReplyContent(
|
||||
to = "",
|
||||
cc = "",
|
||||
subject = prefixedSubject(context.subject, "Fwd:"),
|
||||
body = forwardedBody(context),
|
||||
body = body,
|
||||
bodyHtml = htmlOf(body),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,15 +83,12 @@ object ReplyBuilder {
|
||||
append(bodyText(context))
|
||||
}
|
||||
|
||||
/** The original body as plain text (HTML stripped), suitable for quoting in a plain-text compose. */
|
||||
private fun bodyText(context: ReplyContext): String = if (context.isHtml) htmlToText(context.body) else context.body
|
||||
/** The original body as plain text (HTML stripped), suitable for quoting in a compose field. */
|
||||
private fun bodyText(context: ReplyContext): String =
|
||||
if (context.isHtml) HtmlToText.convert(context.body) else context.body
|
||||
|
||||
private fun htmlToText(html: String): String = html
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
||||
.replace(Regex("<[^>]*>"), "")
|
||||
.replace(Regex("[ \\t]+"), " ")
|
||||
.trim()
|
||||
/** Renders the plaintext quote (with its "> " markers) to the equivalent clean HTML. */
|
||||
private fun htmlOf(body: String): String = RichTextHtml.toHtml(RichTextContent(body))
|
||||
|
||||
private fun formatDate(millis: Long): String =
|
||||
SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis))
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data
|
||||
|
||||
import org.libremail.domain.model.Signature
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
|
||||
/**
|
||||
* A signature rendered into both forms the composer needs: [plain] for the plaintext body/fallback
|
||||
* and [html] for the HTML body. The two are kept in sync — parsing [html] back through the rich-text
|
||||
* model yields exactly [plain] — so the editor shows the same content whichever it seeds from, and a
|
||||
* From-account swap can strip the previously applied block from either representation.
|
||||
*
|
||||
* The block opens with the RFC 3676 "-- " delimiter so receiving clients recognize it as a signature.
|
||||
*/
|
||||
data class SignatureBlock(val plain: String, val html: String) {
|
||||
val isEmpty: Boolean get() = plain.isEmpty() && html.isEmpty()
|
||||
|
||||
companion object {
|
||||
val EMPTY = SignatureBlock("", "")
|
||||
|
||||
/** The block for [signature], or [EMPTY] when there is none / it is blank. */
|
||||
fun of(signature: Signature?): SignatureBlock {
|
||||
if (signature == null) return EMPTY
|
||||
val plainSig = signature.plainText().trimEnd()
|
||||
if (plainSig.isBlank() && signature.html.isBlank()) return EMPTY
|
||||
return SignatureBlock(
|
||||
plain = "$DELIMITER_PLAIN$plainSig",
|
||||
html = RichTextHtml.toHtml(RichTextContent(DELIMITER_PLAIN)) + signature.html,
|
||||
)
|
||||
}
|
||||
|
||||
private const val DELIMITER_PLAIN = "\n\n-- \n"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.dao.OutboxDao
|
||||
import org.libremail.data.local.dao.SignatureDao
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
@@ -19,6 +20,7 @@ import org.libremail.data.local.entity.DraftEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -30,8 +32,9 @@ import org.libremail.data.local.entity.OutboxEntity
|
||||
OutboxEntity::class,
|
||||
DraftEntity::class,
|
||||
FolderEntity::class,
|
||||
SignatureEntity::class,
|
||||
],
|
||||
version = 9,
|
||||
version = 11,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class LibreMailDatabase : RoomDatabase() {
|
||||
@@ -43,4 +46,5 @@ abstract class LibreMailDatabase : RoomDatabase() {
|
||||
abstract fun outboxDao(): OutboxDao
|
||||
abstract fun draftDao(): DraftDao
|
||||
abstract fun folderDao(): FolderDao
|
||||
abstract fun signatureDao(): SignatureDao
|
||||
}
|
||||
|
||||
@@ -159,9 +159,11 @@ internal fun DraftEntity.toDomain(): Draft = Draft(
|
||||
accountId = accountId,
|
||||
to = toAddresses,
|
||||
cc = ccAddresses,
|
||||
bcc = bccAddresses,
|
||||
subject = subject,
|
||||
body = body,
|
||||
updatedAt = updatedAt,
|
||||
bodyHtml = bodyHtml,
|
||||
attachments = attachments.toOutgoingAttachments(),
|
||||
)
|
||||
|
||||
@@ -170,10 +172,12 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity(
|
||||
accountId = accountId,
|
||||
toAddresses = to,
|
||||
ccAddresses = cc,
|
||||
bccAddresses = bcc,
|
||||
subject = subject,
|
||||
body = body,
|
||||
updatedAt = updatedAt,
|
||||
attachments = attachments.toJson(),
|
||||
bodyHtml = bodyHtml,
|
||||
)
|
||||
|
||||
/** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */
|
||||
@@ -202,6 +206,7 @@ internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage(
|
||||
body = body,
|
||||
createdAt = createdAt,
|
||||
lastError = lastError,
|
||||
bodyHtml = bodyHtml,
|
||||
)
|
||||
|
||||
private fun String.toMailSecurity(): MailSecurity = runCatching {
|
||||
|
||||
@@ -171,3 +171,48 @@ val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v9 -> v10: bcc support for outgoing mail (preserves existing data). Adds a `bccAddresses` column
|
||||
* to `outbox` and `drafts` so a `mailto:`-launched (or manually addressed) blind-copy recipient
|
||||
* survives being queued and saved. The `DEFAULT ''` matches the entities' `@ColumnInfo(defaultValue)`
|
||||
* so the fresh-install schema validates identically to the migrated one (the MIGRATION_7_8 pattern).
|
||||
*/
|
||||
val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v10 -> v11: rich composition (preserves existing data).
|
||||
* - `drafts`/`outbox`: add a nullable `bodyHtml` column carrying the HTML form of the body when a
|
||||
* message was composed with formatting (null = plaintext-only, sent/kept exactly as before).
|
||||
* - add the `signatures` table (multiple named signatures per account, one default), with a
|
||||
* cascading foreign key to `accounts`, and backfill each account's existing per-account settings
|
||||
* signature as its default signature so nobody loses one on upgrade.
|
||||
*/
|
||||
val MIGRATION_10_11 = object : Migration(10, 11) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bodyHtml` TEXT")
|
||||
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bodyHtml` TEXT")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `signatures` (" +
|
||||
"`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||
"`contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), " +
|
||||
"FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE)",
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `signatures` (`accountId`)")
|
||||
// Preserve any existing plain-text per-account signature as that account's default signature.
|
||||
// Newlines become <br> so the HTML keeps the original line breaks; other characters are rare
|
||||
// in signatures and pass through unescaped.
|
||||
db.execSQL(
|
||||
"INSERT INTO `signatures` (`id`, `accountId`, `name`, `contentHtml`, `isDefault`) " +
|
||||
"SELECT `accountId` || ':default-signature', `accountId`, 'Signature', " +
|
||||
"replace(`signature`, char(10), '<br>'), 1 " +
|
||||
"FROM `account_settings` WHERE `signature` <> ''",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
@Dao
|
||||
interface SignatureDao {
|
||||
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY isDefault DESC, name COLLATE NOCASE")
|
||||
fun observeForAccount(accountId: String): Flow<List<SignatureEntity>>
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE id = :id LIMIT 1")
|
||||
suspend fun getById(id: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE accountId = :accountId AND isDefault = 1 LIMIT 1")
|
||||
suspend fun getDefault(accountId: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY name COLLATE NOCASE LIMIT 1")
|
||||
suspend fun firstForAccount(accountId: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM signatures WHERE accountId = :accountId")
|
||||
suspend fun countForAccount(accountId: String): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(signature: SignatureEntity)
|
||||
|
||||
@Query("DELETE FROM signatures WHERE id = :id")
|
||||
suspend fun delete(id: String)
|
||||
|
||||
@Query("UPDATE signatures SET isDefault = 0 WHERE accountId = :accountId")
|
||||
suspend fun clearDefault(accountId: String)
|
||||
|
||||
@Query("UPDATE signatures SET isDefault = 1 WHERE id = :id")
|
||||
suspend fun markDefault(id: String)
|
||||
|
||||
/** Makes [id] the account's sole default in one transaction (clears the others first). */
|
||||
@Transaction
|
||||
suspend fun setDefault(accountId: String, id: String) {
|
||||
clearDefault(accountId)
|
||||
markDefault(id)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@@ -11,9 +12,12 @@ data class DraftEntity(
|
||||
val accountId: String?,
|
||||
val toAddresses: String,
|
||||
val ccAddresses: String,
|
||||
@ColumnInfo(defaultValue = "") val bccAddresses: String = "",
|
||||
val subject: String,
|
||||
val body: String,
|
||||
val updatedAt: Long,
|
||||
/** JSON array of the draft's attachments ([uri, name] pairs); empty string when there are none. */
|
||||
val attachments: String = "",
|
||||
/** HTML form of [body] when the draft carries formatting; null for plaintext drafts. */
|
||||
val bodyHtml: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@@ -11,8 +12,11 @@ data class OutboxEntity(
|
||||
val accountId: String,
|
||||
val toAddresses: String,
|
||||
val ccAddresses: String,
|
||||
@ColumnInfo(defaultValue = "") val bccAddresses: String = "",
|
||||
val subject: String,
|
||||
val body: String,
|
||||
val createdAt: Long,
|
||||
val lastError: String? = null,
|
||||
/** HTML form of [body] when composed with formatting; null sends `text/plain` only. */
|
||||
val bodyHtml: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One saved signature. The [accountId] foreign key cascades on delete, so removing an account drops
|
||||
* its signatures. [isDefault] marks the one auto-inserted when composing from the account; the
|
||||
* repository keeps at most one default per account.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "signatures",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = AccountEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["accountId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("accountId")],
|
||||
)
|
||||
data class SignatureEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val accountId: String,
|
||||
val name: String,
|
||||
val contentHtml: String,
|
||||
val isDefault: Boolean = false,
|
||||
)
|
||||
@@ -8,6 +8,7 @@ import jakarta.mail.Flags
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.libremail.data.ReplyBuilder
|
||||
import org.libremail.data.SignatureBlock
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.AttachmentDao
|
||||
import org.libremail.data.local.dao.DraftDao
|
||||
@@ -20,6 +21,7 @@ import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.toDomain
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.data.sync.MailConnectionFactory
|
||||
import org.libremail.data.sync.SendScheduler
|
||||
import org.libremail.domain.model.Attachment
|
||||
@@ -52,6 +54,7 @@ class MailRepositoryImpl @Inject constructor(
|
||||
private val connectionFactory: MailConnectionFactory,
|
||||
private val sendScheduler: SendScheduler,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val signatureRepository: SignatureRepository,
|
||||
) : MailRepository {
|
||||
|
||||
override fun observeMessages(): Flow<List<Message>> = messageDao.observeAll().map { rows ->
|
||||
@@ -191,9 +194,15 @@ class MailRepositoryImpl @Inject constructor(
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val context = imapClient.fetchForReply(params, entity.folder, uidOf(messageId))
|
||||
val content = ReplyBuilder.build(context, mode, account.email)
|
||||
// Bake the sending account's signature into the reply/forward body so it round-trips as part
|
||||
// of the draft (compose won't re-append for drafts).
|
||||
val signature = accountSettingsRepository.get(entity.accountId).signatureBlock()
|
||||
// Bake the sending account's default signature into the reply/forward body — above the quoted
|
||||
// original — so it round-trips as part of the draft (compose won't re-append for drafts). Both
|
||||
// the plaintext and HTML forms are stored so the reply can go out as multipart/alternative.
|
||||
val settings = accountSettingsRepository.get(entity.accountId)
|
||||
val sig = if (settings.signatureEnabled) {
|
||||
SignatureBlock.of(signatureRepository.getDefault(entity.accountId))
|
||||
} else {
|
||||
SignatureBlock.EMPTY
|
||||
}
|
||||
val draftId = UUID.randomUUID().toString()
|
||||
saveDraft(
|
||||
Draft(
|
||||
@@ -202,8 +211,9 @@ class MailRepositoryImpl @Inject constructor(
|
||||
to = content.to,
|
||||
cc = content.cc,
|
||||
subject = content.subject,
|
||||
body = content.body + signature,
|
||||
body = sig.plain + content.body,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
bodyHtml = sig.html + content.bodyHtml,
|
||||
attachments = emptyList(),
|
||||
),
|
||||
)
|
||||
@@ -266,9 +276,11 @@ class MailRepositoryImpl @Inject constructor(
|
||||
accountId = outgoing.accountId,
|
||||
toAddresses = outgoing.to,
|
||||
ccAddresses = outgoing.cc,
|
||||
bccAddresses = outgoing.bcc,
|
||||
subject = outgoing.subject,
|
||||
body = outgoing.body,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
bodyHtml = outgoing.bodyHtml,
|
||||
),
|
||||
)
|
||||
sendScheduler.sendNow()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.settings
|
||||
|
||||
import android.app.backup.BackupManager
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
@@ -15,7 +16,15 @@ import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "libremail_settings")
|
||||
/**
|
||||
* User-preferences DataStore. Exposed as `internal` (not `private`) and read via [toAppSettings] so
|
||||
* that [org.libremail.backup.LibreMailBackupAgent] can consult the backup opt-in flag through the
|
||||
* exact same singleton instance. The system instantiates the backup agent in the app process while
|
||||
* the app may already hold this DataStore open; constructing a second DataStore for the same file
|
||||
* would crash with "There are multiple DataStores active for the same file", so both sides must go
|
||||
* through this one delegate.
|
||||
*/
|
||||
internal val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "libremail_settings")
|
||||
|
||||
/**
|
||||
* How aggressively the app downloads message content during sync.
|
||||
@@ -34,24 +43,43 @@ data class AppSettings(
|
||||
val loadRemoteImages: Boolean = false,
|
||||
val encryptCache: Boolean = false,
|
||||
val appLock: Boolean = false,
|
||||
val includeInBackup: Boolean = false,
|
||||
val fetchPolicy: FetchPolicy = FetchPolicy.ALWAYS,
|
||||
)
|
||||
|
||||
private object Keys {
|
||||
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
|
||||
val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications")
|
||||
val PUSH_IDLE = booleanPreferencesKey("push_idle")
|
||||
val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls")
|
||||
val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images")
|
||||
val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache")
|
||||
val APP_LOCK = booleanPreferencesKey("app_lock")
|
||||
val INCLUDE_IN_BACKUP = booleanPreferencesKey("include_in_backup")
|
||||
val FETCH_POLICY = stringPreferencesKey("fetch_policy")
|
||||
val BATTERY_PROMPT_HANDLED = booleanPreferencesKey("battery_prompt_handled")
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps persisted preferences to [AppSettings]. Shared with the backup agent so it reads the opt-in
|
||||
* flag (and its default) through exactly the same logic the app uses.
|
||||
*/
|
||||
internal fun Preferences.toAppSettings(): AppSettings = AppSettings(
|
||||
dynamicColor = this[Keys.DYNAMIC_COLOR] ?: true,
|
||||
newMailNotifications = this[Keys.NEW_MAIL_NOTIFICATIONS] ?: true,
|
||||
pushIdle = this[Keys.PUSH_IDLE] ?: true,
|
||||
allowStartTls = this[Keys.ALLOW_STARTTLS] ?: false,
|
||||
loadRemoteImages = this[Keys.LOAD_REMOTE_IMAGES] ?: false,
|
||||
encryptCache = this[Keys.ENCRYPT_CACHE] ?: false,
|
||||
appLock = this[Keys.APP_LOCK] ?: false,
|
||||
includeInBackup = this[Keys.INCLUDE_IN_BACKUP] ?: false,
|
||||
fetchPolicy = this[Keys.FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
|
||||
?: FetchPolicy.ALWAYS,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class SettingsRepository @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
dynamicColor = prefs[DYNAMIC_COLOR] ?: true,
|
||||
newMailNotifications = prefs[NEW_MAIL_NOTIFICATIONS] ?: true,
|
||||
pushIdle = prefs[PUSH_IDLE] ?: true,
|
||||
allowStartTls = prefs[ALLOW_STARTTLS] ?: false,
|
||||
loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false,
|
||||
encryptCache = prefs[ENCRYPT_CACHE] ?: false,
|
||||
appLock = prefs[APP_LOCK] ?: false,
|
||||
fetchPolicy = prefs[FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
|
||||
?: FetchPolicy.ALWAYS,
|
||||
)
|
||||
}
|
||||
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { it.toAppSettings() }
|
||||
|
||||
val dynamicColor: Flow<Boolean> = settings.map { it.dynamicColor }
|
||||
|
||||
@@ -59,29 +87,41 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
|
||||
|
||||
suspend fun fetchPolicy(): FetchPolicy = settings.first().fetchPolicy
|
||||
|
||||
suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value)
|
||||
suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value)
|
||||
suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value)
|
||||
suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value)
|
||||
suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value)
|
||||
suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, value)
|
||||
suspend fun setAppLock(value: Boolean) = put(APP_LOCK, value)
|
||||
/**
|
||||
* One-time onboarding flag: whether the user has already seen/acted on the "unrestricted battery"
|
||||
* opt-in, so onboarding asks at most once (see #49). Not part of [AppSettings] — it is internal
|
||||
* onboarding state, not a user-facing preference. It rides along in Android Backup (the whole
|
||||
* settings DataStore is one file); a restore therefore may skip the prompt on a device that isn't
|
||||
* yet allowlisted — the Advanced Settings battery row is the recovery path there.
|
||||
*/
|
||||
suspend fun isBatteryPromptHandled(): Boolean =
|
||||
context.settingsDataStore.data.map { it[Keys.BATTERY_PROMPT_HANDLED] ?: false }.first()
|
||||
|
||||
suspend fun setBatteryPromptHandled(value: Boolean) = put(Keys.BATTERY_PROMPT_HANDLED, value)
|
||||
|
||||
suspend fun setDynamicColor(value: Boolean) = put(Keys.DYNAMIC_COLOR, value)
|
||||
suspend fun setNewMailNotifications(value: Boolean) = put(Keys.NEW_MAIL_NOTIFICATIONS, value)
|
||||
suspend fun setPushIdle(value: Boolean) = put(Keys.PUSH_IDLE, value)
|
||||
suspend fun setAllowStartTls(value: Boolean) = put(Keys.ALLOW_STARTTLS, value)
|
||||
suspend fun setLoadRemoteImages(value: Boolean) = put(Keys.LOAD_REMOTE_IMAGES, value)
|
||||
suspend fun setEncryptCache(value: Boolean) = put(Keys.ENCRYPT_CACHE, value)
|
||||
suspend fun setAppLock(value: Boolean) = put(Keys.APP_LOCK, value)
|
||||
|
||||
/**
|
||||
* Opts this app in/out of system Android Backup. Off by default. After persisting, nudges the
|
||||
* framework so the change takes effect on the next backup pass — enabling schedules a backup of
|
||||
* the safe settings, disabling schedules one that ships nothing (clearing any prior cloud copy).
|
||||
*/
|
||||
suspend fun setIncludeInBackup(value: Boolean) {
|
||||
put(Keys.INCLUDE_IN_BACKUP, value)
|
||||
runCatching { BackupManager(context).dataChanged() }
|
||||
}
|
||||
|
||||
suspend fun setFetchPolicy(value: FetchPolicy) {
|
||||
context.settingsDataStore.edit { it[FETCH_POLICY] = value.name }
|
||||
context.settingsDataStore.edit { it[Keys.FETCH_POLICY] = value.name }
|
||||
}
|
||||
|
||||
private suspend fun put(key: Preferences.Key<Boolean>, value: Boolean) {
|
||||
context.settingsDataStore.edit { it[key] = value }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
|
||||
val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications")
|
||||
val PUSH_IDLE = booleanPreferencesKey("push_idle")
|
||||
val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls")
|
||||
val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images")
|
||||
val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache")
|
||||
val APP_LOCK = booleanPreferencesKey("app_lock")
|
||||
val FETCH_POLICY = stringPreferencesKey("fetch_policy")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.settings
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.libremail.data.local.dao.SignatureDao
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
import org.libremail.domain.model.Signature
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* CRUD for per-account signatures, keeping the "at most one default per account" invariant. The
|
||||
* first signature created for an account becomes its default automatically; deleting the default
|
||||
* promotes another (if any) so an account with signatures always has one to auto-insert.
|
||||
*/
|
||||
@Singleton
|
||||
class SignatureRepository @Inject constructor(private val dao: SignatureDao) {
|
||||
|
||||
fun observeForAccount(accountId: String): Flow<List<Signature>> =
|
||||
dao.observeForAccount(accountId).map { rows -> rows.map { it.toDomain() } }
|
||||
|
||||
suspend fun get(id: String): Signature? = dao.getById(id)?.toDomain()
|
||||
|
||||
suspend fun getDefault(accountId: String): Signature? = dao.getDefault(accountId)?.toDomain()
|
||||
|
||||
/** Creates a signature; makes it the default when it is the account's first. Returns its id. */
|
||||
suspend fun create(accountId: String, name: String, html: String): String {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val isFirst = dao.countForAccount(accountId) == 0
|
||||
dao.upsert(SignatureEntity(id, accountId, name, html, isDefault = isFirst))
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun update(id: String, name: String, html: String) {
|
||||
val existing = dao.getById(id) ?: return
|
||||
dao.upsert(existing.copy(name = name, contentHtml = html))
|
||||
}
|
||||
|
||||
suspend fun delete(id: String) {
|
||||
val existing = dao.getById(id) ?: return
|
||||
dao.delete(id)
|
||||
// If we removed the default, promote the account's first remaining signature.
|
||||
if (existing.isDefault) {
|
||||
dao.firstForAccount(existing.accountId)?.let { dao.markDefault(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDefault(accountId: String, id: String) = dao.setDefault(accountId, id)
|
||||
|
||||
private fun SignatureEntity.toDomain() = Signature(id, accountId, name, contentHtml, isDefault)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.libremail.auth.FreshToken
|
||||
import org.libremail.auth.GmailAuthManager
|
||||
import org.libremail.auth.OutlookAuthManager
|
||||
import org.libremail.data.local.toImapParams
|
||||
import org.libremail.data.local.toSmtpParams
|
||||
@@ -23,7 +22,6 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class MailConnectionFactory @Inject constructor(
|
||||
private val credentialStore: CredentialStore,
|
||||
private val gmailAuthManager: GmailAuthManager,
|
||||
private val outlookAuthManager: OutlookAuthManager,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
@@ -54,8 +52,6 @@ class MailConnectionFactory @Inject constructor(
|
||||
private suspend fun resolveSecret(account: Account): String = when (account.authType) {
|
||||
AuthType.PASSWORD_IMAP ->
|
||||
credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}")
|
||||
AuthType.OAUTH_GMAIL ->
|
||||
cachedAccessToken(account.id, SCOPE_GMAIL, gmailAuthManager::freshAccessToken)
|
||||
AuthType.OAUTH_OUTLOOK ->
|
||||
cachedAccessToken(account.id, SCOPE_OUTLOOK, outlookAuthManager::freshOutlookToken)
|
||||
}
|
||||
@@ -91,7 +87,6 @@ class MailConnectionFactory @Inject constructor(
|
||||
private suspend fun strictStartTls(): Boolean = !settingsRepository.settings.first().allowStartTls
|
||||
|
||||
private companion object {
|
||||
const val SCOPE_GMAIL = "gmail"
|
||||
const val SCOPE_OUTLOOK = "outlook"
|
||||
const val SCOPE_GRAPH = "graph"
|
||||
const val EXPIRY_BUFFER_MS = 60_000L
|
||||
|
||||
@@ -54,8 +54,10 @@ class SendWorker @AssistedInject constructor(
|
||||
accountId = entity.accountId,
|
||||
to = entity.toAddresses,
|
||||
cc = entity.ccAddresses,
|
||||
bcc = entity.bccAddresses,
|
||||
subject = entity.subject,
|
||||
body = entity.body,
|
||||
bodyHtml = entity.bodyHtml,
|
||||
)
|
||||
val files = orderedAttachments(attachmentDir)
|
||||
if (account.authType == AuthType.OAUTH_OUTLOOK) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
import org.libremail.data.local.DatabaseEncryption
|
||||
import org.libremail.data.local.DatabaseFiles
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.MIGRATION_10_11
|
||||
import org.libremail.data.local.MIGRATION_1_2
|
||||
import org.libremail.data.local.MIGRATION_2_3
|
||||
import org.libremail.data.local.MIGRATION_3_4
|
||||
@@ -22,6 +23,7 @@ import org.libremail.data.local.MIGRATION_5_6
|
||||
import org.libremail.data.local.MIGRATION_6_7
|
||||
import org.libremail.data.local.MIGRATION_7_8
|
||||
import org.libremail.data.local.MIGRATION_8_9
|
||||
import org.libremail.data.local.MIGRATION_9_10
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.AccountSettingsDao
|
||||
import org.libremail.data.local.dao.AttachmentDao
|
||||
@@ -30,6 +32,7 @@ import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.dao.OutboxDao
|
||||
import org.libremail.data.local.dao.SignatureDao
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
@@ -57,6 +60,8 @@ object DatabaseModule {
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9,
|
||||
MIGRATION_9_10,
|
||||
MIGRATION_10_11,
|
||||
)
|
||||
// No destructive fallback: the migration chain is complete, and silently dropping the
|
||||
// accounts/credentials/mail tables would lose stored secrets. A missing migration should
|
||||
@@ -126,5 +131,8 @@ object DatabaseModule {
|
||||
@Provides
|
||||
fun provideFolderDao(database: LibreMailDatabase): FolderDao = database.folderDao()
|
||||
|
||||
@Provides
|
||||
fun provideSignatureDao(database: LibreMailDatabase): SignatureDao = database.signatureDao()
|
||||
|
||||
private const val DB_NAME = DatabaseFiles.NAME
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.di
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.libremail.reporting.ReportStore
|
||||
import java.io.File
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ReportingModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideReportStore(@ApplicationContext context: Context): ReportStore =
|
||||
ReportStore(File(context.filesDir, "debug_reports"))
|
||||
}
|
||||
@@ -3,9 +3,6 @@ package org.libremail.domain.model
|
||||
|
||||
/** How an account authenticates with its mail server. */
|
||||
enum class AuthType {
|
||||
/** Gmail via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
|
||||
OAUTH_GMAIL,
|
||||
|
||||
/** Outlook / Microsoft via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
|
||||
OAUTH_OUTLOOK,
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ data class Draft(
|
||||
val accountId: String?,
|
||||
val to: String,
|
||||
val cc: String,
|
||||
val bcc: String = "",
|
||||
val subject: String,
|
||||
val body: String,
|
||||
val updatedAt: Long,
|
||||
/** HTML form of [body] when the draft was composed with formatting; null for plaintext drafts. */
|
||||
val bodyHtml: String? = null,
|
||||
val attachments: List<OutgoingAttachment> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.domain.model
|
||||
|
||||
// Standard mail submission ports, named so the presets below don't read as "magic numbers".
|
||||
// File-level (not in the companion) so the enum entries can reference them during construction.
|
||||
private const val IMAPS_PORT = 993
|
||||
private const val SMTP_SUBMISSION_PORT = 587
|
||||
private const val SMTPS_PORT = 465
|
||||
|
||||
/**
|
||||
* Preconfigured IMAP/SMTP presets for the app-password vendors (Gmail, Yahoo, iCloud).
|
||||
*
|
||||
* These mirror [Account.outlook]: each entry knows its servers so onboarding only has to collect an
|
||||
* email + app password (see the app-password setup screen). Outlook is intentionally NOT here — it
|
||||
* uses interactive OAuth, not an app password.
|
||||
*
|
||||
* Port / security rationale (verified against current vendor docs):
|
||||
* - IMAP is implicit TLS on 993 for all three: none of these vendors document a STARTTLS IMAP
|
||||
* endpoint, so 993/[MailSecurity.SSL_TLS] is the only correct choice.
|
||||
* - SMTP biases toward STARTTLS on 587 where the vendor documents it (Gmail, iCloud), matching the
|
||||
* epic's "prefer STARTTLS where supported" guidance. Yahoo documents implicit TLS on 465 as its
|
||||
* outgoing server, so it keeps 465/[MailSecurity.SSL_TLS].
|
||||
* - [MailSecurity.NONE] is never used — every path here is encrypted end to end.
|
||||
*/
|
||||
enum class MailProvider(
|
||||
/** Stable lowercase key used as a navigation argument and to look a provider back up. */
|
||||
val key: String,
|
||||
/** Brand name shown in the picker and setup screen (a proper noun, not localized). */
|
||||
val displayName: String,
|
||||
/** The page where the user creates an app password for this provider. */
|
||||
val appPasswordHelpUrl: String,
|
||||
private val imapHost: String,
|
||||
private val smtpHost: String,
|
||||
private val smtpPort: Int,
|
||||
private val smtpSecurity: MailSecurity,
|
||||
) {
|
||||
GMAIL(
|
||||
key = "gmail",
|
||||
displayName = "Gmail",
|
||||
appPasswordHelpUrl = "https://myaccount.google.com/apppasswords",
|
||||
imapHost = "imap.gmail.com",
|
||||
smtpHost = "smtp.gmail.com",
|
||||
// Google documents smtp.gmail.com:587 with STARTTLS as the standard submission endpoint.
|
||||
smtpPort = SMTP_SUBMISSION_PORT,
|
||||
smtpSecurity = MailSecurity.STARTTLS,
|
||||
),
|
||||
YAHOO(
|
||||
key = "yahoo",
|
||||
displayName = "Yahoo Mail",
|
||||
appPasswordHelpUrl = "https://login.yahoo.com/account/security",
|
||||
imapHost = "imap.mail.yahoo.com",
|
||||
smtpHost = "smtp.mail.yahoo.com",
|
||||
// Yahoo documents smtp.mail.yahoo.com:465 with implicit SSL/TLS as its outgoing server.
|
||||
smtpPort = SMTPS_PORT,
|
||||
smtpSecurity = MailSecurity.SSL_TLS,
|
||||
),
|
||||
ICLOUD(
|
||||
key = "icloud",
|
||||
displayName = "iCloud Mail",
|
||||
appPasswordHelpUrl = "https://appleid.apple.com",
|
||||
imapHost = "imap.mail.me.com",
|
||||
smtpHost = "smtp.mail.me.com",
|
||||
// Apple documents smtp.mail.me.com:587 with STARTTLS for iCloud Mail.
|
||||
smtpPort = SMTP_SUBMISSION_PORT,
|
||||
smtpSecurity = MailSecurity.STARTTLS,
|
||||
),
|
||||
;
|
||||
|
||||
/**
|
||||
* Builds a [PASSWORD_IMAP][AuthType.PASSWORD_IMAP] [Account] for this provider. The caller
|
||||
* supplies the address; the servers come from the preset. The id mirrors the manual-setup
|
||||
* convention (`imap:<email>`) so app-password and manual accounts share one identity scheme.
|
||||
*/
|
||||
fun createAccount(email: String, displayName: String = email): Account {
|
||||
val address = email.trim()
|
||||
return Account(
|
||||
id = "imap:$address",
|
||||
email = address,
|
||||
displayName = displayName.trim().ifBlank { address },
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig(imapHost, IMAPS_PORT, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig(smtpHost, smtpPort, smtpSecurity),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Resolves a provider by its [key], or null if none matches (case-insensitive). */
|
||||
fun fromKey(key: String): MailProvider? = entries.firstOrNull { it.key.equals(key, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,6 @@ data class OutboxMessage(
|
||||
val body: String,
|
||||
val createdAt: Long,
|
||||
val lastError: String?,
|
||||
/** HTML form of [body] when composed with formatting; null for plaintext messages. */
|
||||
val bodyHtml: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.domain.model
|
||||
|
||||
/** A message the user is sending. [to]/[cc] are comma-separated address lists. */
|
||||
/**
|
||||
* A message the user is sending. [to]/[cc]/[bcc] are comma-separated address lists.
|
||||
*
|
||||
* [body] is always the plain-text form. [bodyHtml] carries the HTML form when the message was
|
||||
* composed with formatting; when it is null the message is sent as `text/plain` only (unchanged
|
||||
* from the plaintext-only path), otherwise as `multipart/alternative` with both parts.
|
||||
*/
|
||||
data class OutgoingMessage(
|
||||
val accountId: String,
|
||||
val to: String,
|
||||
val cc: String = "",
|
||||
val bcc: String = "",
|
||||
val subject: String,
|
||||
val body: String,
|
||||
val bodyHtml: String? = null,
|
||||
val attachments: List<OutgoingAttachment> = emptyList(),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.domain.model
|
||||
|
||||
import org.libremail.mail.HtmlToText
|
||||
|
||||
/**
|
||||
* A named, rich (HTML) email signature belonging to an account. Each account may have several; at
|
||||
* most one is its [isDefault], which is the one auto-inserted when composing from that account.
|
||||
*/
|
||||
data class Signature(
|
||||
val id: String,
|
||||
val accountId: String,
|
||||
val name: String,
|
||||
/** The signature body as HTML (authored in the same rich editor as the message body). */
|
||||
val html: String,
|
||||
val isDefault: Boolean = false,
|
||||
) {
|
||||
/** The signature as plain text, for the `text/plain` alternative and plaintext-only composing. */
|
||||
fun plainText(): String = HtmlToText.convert(html)
|
||||
}
|
||||
@@ -84,13 +84,23 @@ class GraphSender @Inject constructor() {
|
||||
|
||||
/** Builds the Graph `sendMail` JSON body (a pure function, so it is unit-testable without a network). */
|
||||
internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List<File>): String {
|
||||
// Graph carries a single body object: send HTML when the message was formatted (Outlook renders
|
||||
// it and derives its own plaintext), otherwise plain text so unformatted mail is unchanged.
|
||||
val body = if (message.bodyHtml != null) {
|
||||
JSONObject().put("contentType", "HTML").put("content", message.bodyHtml)
|
||||
} else {
|
||||
JSONObject().put("contentType", "Text").put("content", message.body)
|
||||
}
|
||||
val mail = JSONObject()
|
||||
.put("subject", message.subject)
|
||||
.put("body", JSONObject().put("contentType", "Text").put("content", message.body))
|
||||
.put("body", body)
|
||||
.put("toRecipients", recipientsJson(message.to))
|
||||
if (message.cc.isNotBlank()) {
|
||||
mail.put("ccRecipients", recipientsJson(message.cc))
|
||||
}
|
||||
if (message.bcc.isNotBlank()) {
|
||||
mail.put("bccRecipients", recipientsJson(message.bcc))
|
||||
}
|
||||
if (attachments.isNotEmpty()) {
|
||||
val items = JSONArray()
|
||||
attachments.forEach { file ->
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
/**
|
||||
* Converts an HTML body into readable plain text for the `text/plain` alternative part and for
|
||||
* quoting HTML originals in replies/forwards. Pure (no Android/Compose), so it is unit-testable on
|
||||
* the JVM.
|
||||
*
|
||||
* It is intentionally lightweight — block elements become line breaks, list items gain a "• "
|
||||
* marker, tags are dropped and entities decoded — rather than a full HTML renderer. The goal is a
|
||||
* legible fallback, not a faithful reproduction.
|
||||
*/
|
||||
object HtmlToText {
|
||||
|
||||
private val BLOCK_BREAK = Regex(
|
||||
"(?i)</?(p|div|tr|table|ul|ol|h[1-6]|blockquote)\\b[^>]*>|<br\\s*/?>",
|
||||
)
|
||||
private val LIST_ITEM = Regex("(?i)<li\\b[^>]*>")
|
||||
|
||||
fun convert(html: String): String {
|
||||
var s = html
|
||||
// Drop script/style contents outright so their text never leaks into the output.
|
||||
s = s.replace(Regex("(?is)<(script|style)\\b[^>]*>.*?</\\1>"), "")
|
||||
s = LIST_ITEM.replace(s, "\n• ")
|
||||
s = BLOCK_BREAK.replace(s, "\n")
|
||||
s = s.replace(Regex("<[^>]*>"), "")
|
||||
s = decodeEntities(s)
|
||||
// Collapse runs of spaces/tabs, then trim trailing spaces and cap consecutive blank lines.
|
||||
s = s.replace(Regex("[ \\t]+"), " ")
|
||||
s = s.lineSequence().joinToString("\n") { it.trim() }
|
||||
s = s.replace(Regex("\n{3,}"), "\n\n")
|
||||
return s.trim()
|
||||
}
|
||||
|
||||
private fun decodeEntities(s: String): String = s
|
||||
.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("—", "—")
|
||||
.replace("–", "–")
|
||||
.replace("&", "&")
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import jakarta.mail.internet.InternetAddress
|
||||
import jakarta.mail.internet.MimeBodyPart
|
||||
import jakarta.mail.internet.MimeMessage
|
||||
import jakarta.mail.internet.MimeMultipart
|
||||
import jakarta.mail.internet.MimePart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
@@ -59,19 +60,13 @@ class SmtpSender @Inject constructor() {
|
||||
if (message.cc.isNotBlank()) {
|
||||
setRecipients(Message.RecipientType.CC, InternetAddress.parse(message.cc))
|
||||
}
|
||||
if (message.bcc.isNotBlank()) {
|
||||
setRecipients(Message.RecipientType.BCC, InternetAddress.parse(message.bcc))
|
||||
}
|
||||
subject = message.subject
|
||||
sentDate = Date()
|
||||
}
|
||||
if (attachments.isEmpty()) {
|
||||
mime.setText(message.body, "UTF-8")
|
||||
} else {
|
||||
val multipart = MimeMultipart()
|
||||
multipart.addBodyPart(MimeBodyPart().apply { setText(message.body, "UTF-8") })
|
||||
attachments.forEach { file ->
|
||||
multipart.addBodyPart(MimeBodyPart().apply { attachFile(file) })
|
||||
}
|
||||
mime.setContent(multipart)
|
||||
}
|
||||
applyBody(mime, message, attachments)
|
||||
|
||||
val transport = session.getTransport(protocol)
|
||||
transport.connect(params.host, params.port, params.username, params.secret)
|
||||
@@ -82,6 +77,40 @@ class SmtpSender @Inject constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the message body:
|
||||
* - plaintext-only ([OutgoingMessage.bodyHtml] null): a single `text/plain` part, exactly as
|
||||
* before, so unformatted mail is unchanged on the wire;
|
||||
* - formatted: a `multipart/alternative` of `text/plain` (fallback, first) + `text/html`
|
||||
* (preferred, last, per RFC 2046).
|
||||
* When there are attachments the body is nested inside a `multipart/mixed` as its first part.
|
||||
*/
|
||||
private fun applyBody(mime: MimeMessage, message: OutgoingMessage, attachments: List<File>) {
|
||||
if (attachments.isEmpty()) {
|
||||
setBody(mime, message)
|
||||
return
|
||||
}
|
||||
val mixed = MimeMultipart("mixed")
|
||||
mixed.addBodyPart(MimeBodyPart().also { setBody(it, message) })
|
||||
attachments.forEach { file -> mixed.addBodyPart(MimeBodyPart().apply { attachFile(file) }) }
|
||||
mime.setContent(mixed)
|
||||
}
|
||||
|
||||
/** Writes the body onto [part]: plain text, or a text/plain + text/html alternative when formatted. */
|
||||
private fun setBody(part: MimePart, message: OutgoingMessage) {
|
||||
val html = message.bodyHtml
|
||||
if (html == null) {
|
||||
part.setText(message.body, "UTF-8")
|
||||
} else {
|
||||
part.setContent(alternative(message.body, html))
|
||||
}
|
||||
}
|
||||
|
||||
private fun alternative(plain: String, html: String): MimeMultipart = MimeMultipart("alternative").apply {
|
||||
addBodyPart(MimeBodyPart().apply { setText(plain, "UTF-8") })
|
||||
addBodyPart(MimeBodyPart().apply { setContent(html, "text/html; charset=UTF-8") })
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = "15000"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.push
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reads and deep-links this app's system battery-optimization state, so the user can move LibreMail
|
||||
* to "Unrestricted" and keep [IdleService]'s push connection (and periodic sync) from being throttled
|
||||
* or torn down by Doze.
|
||||
*
|
||||
* We deliberately do NOT use the restricted `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission or its
|
||||
* one-tap dialog: Google Play limits that permission to an approved set of use cases (rejection risk,
|
||||
* see #17). Sending the user to the system screen instead needs no extra permission and is safe on
|
||||
* both Play and F-Droid (#16).
|
||||
*
|
||||
* Note [isIgnoringBatteryOptimizations] reflects the Doze allowlist: it is `true` only for the
|
||||
* "Unrestricted" setting and `false` for *both* "Optimized" and "Restricted", so it cannot single out
|
||||
* the (most harmful) "Restricted" state on its own.
|
||||
*/
|
||||
@Singleton
|
||||
class BatteryOptimizationManager @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
/**
|
||||
* Whether the platform exposes battery-optimization control. The Doze allowlist has existed since
|
||||
* API 23, so at our minSdk (29) this is always true; the version check keeps [BatteryPromptDecision]
|
||||
* honest if the floor ever drops below API 23.
|
||||
*/
|
||||
val isSupported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||
|
||||
/** True when this app is currently exempt from battery optimization ("Unrestricted"). */
|
||||
fun isIgnoringBatteryOptimizations(): Boolean {
|
||||
val powerManager = context.getSystemService(PowerManager::class.java) ?: return false
|
||||
return powerManager.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent to this app's system details screen, where the user can open **Battery** and choose
|
||||
* **Unrestricted**. App-details is targeted (rather than the flat battery-optimization list)
|
||||
* because it is the only route to the Unrestricted/Optimized/Restricted setting and lands
|
||||
* directly on LibreMail. Always resolvable since API 9.
|
||||
*/
|
||||
fun settingsIntent(): Intent = Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.push
|
||||
|
||||
/**
|
||||
* Pure decision for whether onboarding should surface the battery opt-in step. Deliberately free of
|
||||
* Android types so it is exhaustively unit-testable; the live inputs are read by
|
||||
* [BatteryOptimizationManager] and [org.libremail.data.settings.SettingsRepository].
|
||||
*/
|
||||
object BatteryPromptDecision {
|
||||
|
||||
/**
|
||||
* Prompt only when all three hold:
|
||||
* - [supported]: the platform exposes battery-optimization control (the Doze allowlist is API 23+,
|
||||
* so this is always true at our minSdk — kept explicit so the rule stays correct if the floor
|
||||
* ever drops).
|
||||
* - not [alreadyUnrestricted]: an app already exempt from battery optimization gains nothing.
|
||||
* - not [alreadyHandled]: the user has already seen and acted on (or dismissed) the prompt, so we
|
||||
* don't nag on a later onboarding run.
|
||||
*/
|
||||
fun shouldPrompt(supported: Boolean, alreadyUnrestricted: Boolean, alreadyHandled: Boolean): Boolean =
|
||||
supported && !alreadyUnrestricted && !alreadyHandled
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Logging facade that mirrors Logcat output into the process [RingLogBuffer] so recent activity can
|
||||
* be attached to a user-reviewed [DebugReport]. Call [install] once at startup. Never pass PII
|
||||
* (email addresses, message content, credentials) to these methods — the buffer can end up in a
|
||||
* report the user reviews and may submit.
|
||||
*/
|
||||
object AppLog {
|
||||
@Volatile
|
||||
private var buffer: RingLogBuffer? = null
|
||||
|
||||
fun install(buffer: RingLogBuffer) {
|
||||
this.buffer = buffer
|
||||
}
|
||||
|
||||
fun d(tag: String, message: String) {
|
||||
Log.d(tag, message)
|
||||
buffer?.record('D', tag, message)
|
||||
}
|
||||
|
||||
fun i(tag: String, message: String) {
|
||||
Log.i(tag, message)
|
||||
buffer?.record('I', tag, message)
|
||||
}
|
||||
|
||||
fun w(tag: String, message: String) {
|
||||
Log.w(tag, message)
|
||||
buffer?.record('W', tag, message)
|
||||
}
|
||||
|
||||
fun e(tag: String, message: String, throwable: Throwable? = null) {
|
||||
Log.e(tag, message, throwable)
|
||||
buffer?.record('E', tag, message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Resolves the app's version once, tolerating any lookup failure. */
|
||||
@Singleton
|
||||
class AppVersionProvider @Inject constructor(@ApplicationContext context: Context) {
|
||||
val versionName: String
|
||||
val versionCode: Long
|
||||
|
||||
init {
|
||||
var name = UNKNOWN
|
||||
var code = 0L
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION") // getPackageInfo(String, int) is fine for a plain version lookup.
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
name = info.versionName ?: UNKNOWN
|
||||
code = info.longVersionCode
|
||||
}
|
||||
versionName = name
|
||||
versionCode = code
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UNKNOWN = "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import android.os.Process
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Installs a process-wide uncaught-exception handler that persists a crash [DebugReport] locally for
|
||||
* review on next launch. Reports are NEVER sent automatically — submission is strictly user-initiated
|
||||
* (see [ReportSubmitter]). The previous handler is always invoked so the system still shows the crash
|
||||
* and terminates the process.
|
||||
*/
|
||||
@Singleton
|
||||
class CrashReporter @Inject constructor(
|
||||
private val collector: DiagnosticsCollector,
|
||||
private val store: ReportStore,
|
||||
private val logBuffer: RingLogBuffer,
|
||||
) {
|
||||
fun install() {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
persist(throwable)
|
||||
if (previous != null) {
|
||||
previous.uncaughtException(thread, throwable)
|
||||
} else {
|
||||
terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a crash report. Wrapped so a failure here can never mask the original crash. Exposed for
|
||||
* tests; installing the handler simply routes uncaught exceptions here.
|
||||
*/
|
||||
fun persist(throwable: Throwable) {
|
||||
runCatching {
|
||||
logBuffer.record('E', TAG, "Uncaught exception: ${throwable.javaClass.name}")
|
||||
store.save(collector.collectCrash(throwable))
|
||||
}
|
||||
}
|
||||
|
||||
private fun terminate() {
|
||||
Process.killProcess(Process.myPid())
|
||||
exitProcess(EXIT_FAILURE)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CrashReporter"
|
||||
const val EXIT_FAILURE = 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.time.Instant
|
||||
|
||||
/** Whether a report was produced by an uncaught crash or requested manually by the user. */
|
||||
enum class ReportKind { CRASH, MANUAL }
|
||||
|
||||
/**
|
||||
* A locally-stored diagnostic report. It never leaves the device unless the user explicitly submits
|
||||
* it (see [ReportSubmitter]); [toSubmissionPayload] is the single rendering that is shown for
|
||||
* review, copied, saved to a file, and POSTed on submit, so what the user reads is exactly what is
|
||||
* sent. Only the fields assembled by `DiagnosticsCollector` are captured — no message content.
|
||||
*/
|
||||
@Suppress("LongParameterList") // A flat diagnostic DTO; grouping fields would only obscure the payload.
|
||||
data class DebugReport(
|
||||
val id: String,
|
||||
val createdAtMillis: Long,
|
||||
val kind: ReportKind,
|
||||
val appVersionName: String,
|
||||
val appVersionCode: Long,
|
||||
val androidRelease: String,
|
||||
val androidSdkInt: Int,
|
||||
val deviceManufacturer: String,
|
||||
val deviceModel: String,
|
||||
val stackTrace: String?,
|
||||
val settings: Map<String, String>,
|
||||
val logs: List<String>,
|
||||
val userComment: String = "",
|
||||
) {
|
||||
/** The exact text shown for review, copied, saved to a file, and POSTed on submit. */
|
||||
fun toSubmissionPayload(): String = toJson().toString(JSON_INDENT)
|
||||
|
||||
/** Compact form used for on-disk persistence. */
|
||||
fun toStorageJson(): String = toJson().toString()
|
||||
|
||||
private fun toJson(): JSONObject {
|
||||
val app = JSONObject()
|
||||
.put("versionName", appVersionName)
|
||||
.put("versionCode", appVersionCode)
|
||||
val device = JSONObject()
|
||||
.put("manufacturer", deviceManufacturer)
|
||||
.put("model", deviceModel)
|
||||
.put("androidRelease", androidRelease)
|
||||
.put("sdkInt", androidSdkInt)
|
||||
val settingsJson = JSONObject()
|
||||
settings.forEach { (key, value) -> settingsJson.put(key, value) }
|
||||
val json = JSONObject()
|
||||
.put("id", id)
|
||||
.put("createdAt", Instant.ofEpochMilli(createdAtMillis).toString())
|
||||
.put("createdAtMillis", createdAtMillis)
|
||||
.put("kind", kind.name)
|
||||
.put("app", app)
|
||||
.put("device", device)
|
||||
.put("userComment", userComment)
|
||||
.put("settings", settingsJson)
|
||||
.put("logs", JSONArray(logs))
|
||||
if (stackTrace != null) json.put("stackTrace", stackTrace)
|
||||
return json
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val JSON_INDENT = 2
|
||||
|
||||
fun fromStorageJson(raw: String): DebugReport {
|
||||
val json = JSONObject(raw)
|
||||
val app = json.getJSONObject("app")
|
||||
val device = json.getJSONObject("device")
|
||||
val settingsJson = json.getJSONObject("settings")
|
||||
val settings = LinkedHashMap<String, String>()
|
||||
settingsJson.keys().forEach { key -> settings[key] = settingsJson.getString(key) }
|
||||
val logsJson = json.getJSONArray("logs")
|
||||
val logs = ArrayList<String>(logsJson.length())
|
||||
for (i in 0 until logsJson.length()) logs.add(logsJson.getString(i))
|
||||
return DebugReport(
|
||||
id = json.getString("id"),
|
||||
createdAtMillis = json.getLong("createdAtMillis"),
|
||||
kind = ReportKind.valueOf(json.getString("kind")),
|
||||
appVersionName = app.getString("versionName"),
|
||||
appVersionCode = app.getLong("versionCode"),
|
||||
androidRelease = device.getString("androidRelease"),
|
||||
androidSdkInt = device.getInt("sdkInt"),
|
||||
deviceManufacturer = device.getString("manufacturer"),
|
||||
deviceModel = device.getString("model"),
|
||||
stackTrace = if (json.has("stackTrace")) json.getString("stackTrace") else null,
|
||||
settings = settings,
|
||||
logs = logs,
|
||||
userComment = json.optString("userComment", ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Assembles a [DebugReport] from app/device metadata, a stack trace (for crashes), a minimal
|
||||
* non-PII settings summary, and the recent in-app log buffer. Only the fields listed in [summarize]
|
||||
* are captured — deliberately no account emails, server names, or message content.
|
||||
*/
|
||||
@Singleton
|
||||
class DiagnosticsCollector @Inject constructor(
|
||||
private val appVersion: AppVersionProvider,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val logBuffer: RingLogBuffer,
|
||||
) {
|
||||
// Cached so a crash report (built synchronously on the crashing thread) can still include
|
||||
// settings without touching DataStore. Refreshed whenever settings are read for a manual
|
||||
// report or explicitly warmed at startup.
|
||||
@Volatile
|
||||
private var cachedSettings: Map<String, String> = emptyMap()
|
||||
|
||||
/** Pre-reads settings so a later crash report can include them. Safe to call and ignore. */
|
||||
suspend fun warmSettingsCache() {
|
||||
cachedSettings = summarize(settingsRepository.settings.first())
|
||||
}
|
||||
|
||||
/** Builds a report for a user-initiated ("Report a problem") request; includes live settings. */
|
||||
suspend fun collectManual(): DebugReport {
|
||||
val settings = summarize(settingsRepository.settings.first())
|
||||
cachedSettings = settings
|
||||
return build(ReportKind.MANUAL, throwable = null, settings = settings)
|
||||
}
|
||||
|
||||
/** Builds a crash report synchronously; it must not block or throw on the crashing thread. */
|
||||
fun collectCrash(throwable: Throwable): DebugReport =
|
||||
build(ReportKind.CRASH, throwable = throwable, settings = cachedSettings)
|
||||
|
||||
private fun build(kind: ReportKind, throwable: Throwable?, settings: Map<String, String>) = DebugReport(
|
||||
id = UUID.randomUUID().toString(),
|
||||
createdAtMillis = System.currentTimeMillis(),
|
||||
kind = kind,
|
||||
appVersionName = appVersion.versionName,
|
||||
appVersionCode = appVersion.versionCode,
|
||||
androidRelease = Build.VERSION.RELEASE ?: "",
|
||||
androidSdkInt = Build.VERSION.SDK_INT,
|
||||
deviceManufacturer = Build.MANUFACTURER ?: "",
|
||||
deviceModel = Build.MODEL ?: "",
|
||||
stackTrace = throwable?.stackTraceToString(),
|
||||
settings = settings,
|
||||
logs = logBuffer.snapshot().map { it.formatted() },
|
||||
)
|
||||
|
||||
private fun summarize(settings: AppSettings): Map<String, String> = linkedMapOf(
|
||||
"dynamicColor" to settings.dynamicColor.toString(),
|
||||
"newMailNotifications" to settings.newMailNotifications.toString(),
|
||||
"pushIdle" to settings.pushIdle.toString(),
|
||||
"allowStartTls" to settings.allowStartTls.toString(),
|
||||
"loadRemoteImages" to settings.loadRemoteImages.toString(),
|
||||
"encryptCache" to settings.encryptCache.toString(),
|
||||
"fetchPolicy" to settings.fetchPolicy.name,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* File-backed store of pending [DebugReport]s — one JSON file per report under [directory].
|
||||
* Deliberately NOT Room-backed: a crash-time save must be simple and robust, independent of the
|
||||
* (possibly encrypted, possibly mid-migration) app database. Reports persist until the user submits
|
||||
* or discards them. [reports] is a snapshot that updates on every [save]/[delete].
|
||||
*/
|
||||
class ReportStore(private val directory: File) {
|
||||
private val lock = Any()
|
||||
private val _reports = MutableStateFlow(scan())
|
||||
val reports: StateFlow<List<DebugReport>> = _reports.asStateFlow()
|
||||
|
||||
fun save(report: DebugReport) {
|
||||
synchronized(lock) {
|
||||
directory.mkdirs()
|
||||
File(directory, fileName(report.id)).writeText(report.toStorageJson())
|
||||
_reports.value = scan()
|
||||
}
|
||||
}
|
||||
|
||||
fun find(id: String): DebugReport? = _reports.value.firstOrNull { it.id == id }
|
||||
|
||||
fun delete(id: String) {
|
||||
synchronized(lock) {
|
||||
File(directory, fileName(id)).delete()
|
||||
_reports.value = scan()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scan(): List<DebugReport> {
|
||||
val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) }
|
||||
?: return emptyList()
|
||||
return files
|
||||
.mapNotNull { file -> runCatching { DebugReport.fromStorageJson(file.readText()) }.getOrNull() }
|
||||
.sortedByDescending { it.createdAtMillis }
|
||||
}
|
||||
|
||||
private fun fileName(id: String) = "$id$SUFFIX"
|
||||
|
||||
private companion object {
|
||||
const val SUFFIX = ".json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import androidx.work.WorkInfo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.libremail.BuildConfig
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Coarse state of a user-initiated report submission. */
|
||||
enum class SubmitStatus { IDLE, SUBMITTING, SUCCEEDED, FAILED }
|
||||
|
||||
/**
|
||||
* The single seam through which a report can leave the device, and only when the user taps Submit.
|
||||
* [isEnabled] is false when no ingest endpoint is configured (the default in this repo), so the UI
|
||||
* can steer the user to Copy/Save instead of a submission that would only fail.
|
||||
*/
|
||||
@Singleton
|
||||
class ReportSubmitter @Inject constructor(private val scheduler: ReportUploadScheduler) {
|
||||
val isEnabled: Boolean get() = BuildConfig.DEBUG_REPORT_ENDPOINT.isNotBlank()
|
||||
|
||||
fun submit(reportId: String) {
|
||||
scheduler.enqueue(reportId)
|
||||
}
|
||||
|
||||
fun status(reportId: String): Flow<SubmitStatus> = scheduler.statusFlow(reportId).map(::toStatus)
|
||||
|
||||
private fun toStatus(infos: List<WorkInfo>): SubmitStatus {
|
||||
if (infos.isEmpty()) return SubmitStatus.IDLE
|
||||
return when {
|
||||
infos.any { it.state == WorkInfo.State.FAILED || it.state == WorkInfo.State.CANCELLED } ->
|
||||
SubmitStatus.FAILED
|
||||
infos.all { it.state == WorkInfo.State.SUCCEEDED } -> SubmitStatus.SUCCEEDED
|
||||
else -> SubmitStatus.SUBMITTING
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Enqueues a user-initiated report upload as a retrying WorkManager job and observes its state. */
|
||||
@Singleton
|
||||
class ReportUploadScheduler @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
fun enqueue(reportId: String) {
|
||||
val request = OneTimeWorkRequestBuilder<ReportUploadWorker>()
|
||||
.setConstraints(
|
||||
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
|
||||
)
|
||||
.setInputData(workDataOf(ReportUploadWorker.KEY_REPORT_ID to reportId))
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
|
||||
.addTag(TAG)
|
||||
.build()
|
||||
// REPLACE: a fresh Submit tap starts a clean attempt for this report, overriding any pending
|
||||
// retry-backoff. Reports are keyed per id, so distinct reports never collide.
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(uniqueName(reportId), ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
fun statusFlow(reportId: String): Flow<List<WorkInfo>> =
|
||||
WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(uniqueName(reportId))
|
||||
|
||||
private fun uniqueName(reportId: String) = "$WORK_PREFIX$reportId"
|
||||
|
||||
companion object {
|
||||
const val TAG = "libremail_report_upload"
|
||||
private const val WORK_PREFIX = "libremail_report_upload_"
|
||||
private const val BACKOFF_SECONDS = 30L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremail.BuildConfig
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Posts a single user-submitted report to the ingest endpoint. It runs ONLY when the user tapped
|
||||
* Submit (enqueued by [ReportUploadScheduler]); nothing here runs automatically. The endpoint is
|
||||
* [BuildConfig.DEBUG_REPORT_ENDPOINT] — empty by default, because the ingest server (issue #34) is
|
||||
* out of scope for this repo, so submissions no-op with a clear failure until an endpoint is set.
|
||||
*/
|
||||
@HiltWorker
|
||||
class ReportUploadWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val store: ReportStore,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val id = inputData.getString(KEY_REPORT_ID) ?: return Result.success()
|
||||
val report = store.find(id) ?: return Result.success() // discarded before the job ran
|
||||
val endpoint = BuildConfig.DEBUG_REPORT_ENDPOINT
|
||||
if (endpoint.isBlank()) return Result.failure() // no ingest server configured in this build
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching { post(endpoint, report.toSubmissionPayload()) }.fold(
|
||||
onSuccess = { code -> onResponse(code, id) },
|
||||
onFailure = { retryOrFail() }, // network error — retry with backoff
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onResponse(code: Int, id: String): Result = when {
|
||||
code in SUCCESS_CODES -> {
|
||||
store.delete(id) // delivered — drop the local copy
|
||||
Result.success()
|
||||
}
|
||||
code in SERVER_ERROR_CODES -> retryOrFail() // transient server-side failure
|
||||
else -> Result.failure() // client error (4xx) — retrying won't help
|
||||
}
|
||||
|
||||
private fun retryOrFail(): Result = if (runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
|
||||
|
||||
private fun post(endpoint: String, body: String): Int {
|
||||
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = TIMEOUT_MS
|
||||
readTimeout = TIMEOUT_MS
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
return try {
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
connection.responseCode
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_REPORT_ID = "report_id"
|
||||
private val SUCCESS_CODES = 200..299
|
||||
private val SERVER_ERROR_CODES = 500..599
|
||||
private const val TIMEOUT_MS = 15_000
|
||||
private const val MAX_ATTEMPTS = 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** One captured log line. */
|
||||
data class LogEntry(val timeMillis: Long, val level: Char, val tag: String, val message: String) {
|
||||
fun formatted(): String = "${Instant.ofEpochMilli(timeMillis)} $level/$tag: $message"
|
||||
}
|
||||
|
||||
/**
|
||||
* A small, thread-safe, in-memory ring buffer of recent app log lines. It is held in memory only
|
||||
* (never written to disk on its own) and copied into a [DebugReport] on request. Callers must never
|
||||
* record PII (email addresses, message content, credentials) — see [AppLog].
|
||||
*/
|
||||
@Singleton
|
||||
class RingLogBuffer @Inject constructor() {
|
||||
private val lock = Any()
|
||||
private val entries = ArrayDeque<LogEntry>()
|
||||
|
||||
fun record(level: Char, tag: String, message: String) {
|
||||
synchronized(lock) {
|
||||
if (entries.size >= CAPACITY) entries.removeFirst()
|
||||
entries.addLast(LogEntry(System.currentTimeMillis(), level, tag, message))
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshot(): List<LogEntry> = synchronized(lock) { entries.toList() }
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) { entries.clear() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CAPACITY = 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.richtext
|
||||
|
||||
/** Inline character styles the compose editor supports. */
|
||||
enum class RichStyle { BOLD, ITALIC, UNDERLINE }
|
||||
|
||||
/** A run of [style] over the half-open range [[start], [end]) of the plain text. */
|
||||
data class RichSpan(val start: Int, val end: Int, val style: RichStyle)
|
||||
|
||||
/** A hyperlink over the half-open range [[start], [end]) pointing at [url]. */
|
||||
data class RichLink(val start: Int, val end: Int, val url: String)
|
||||
|
||||
/**
|
||||
* The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links].
|
||||
*
|
||||
* Block structure (unordered/ordered lists and block quotes) is encoded as recognizable line
|
||||
* prefixes inside [text] — "• " for bullets, "N. " for numbered items, and "> " for quotes — so
|
||||
* the editor can stay a single plain-text field (keeping the plaintext-only experience smooth)
|
||||
* while the model still round-trips cleanly to real HTML and to a readable plaintext fallback.
|
||||
*/
|
||||
data class RichTextContent(
|
||||
val text: String = "",
|
||||
val spans: List<RichSpan> = emptyList(),
|
||||
val links: List<RichLink> = emptyList(),
|
||||
) {
|
||||
val isBlank: Boolean get() = text.isBlank()
|
||||
|
||||
/**
|
||||
* True when the content carries anything a plaintext field could not represent: inline styling,
|
||||
* a link, or a block marker. When false, callers should send/persist plaintext only so an
|
||||
* unformatted message stays byte-for-byte identical to the old plaintext-only path.
|
||||
*/
|
||||
fun hasFormatting(): Boolean =
|
||||
spans.isNotEmpty() || links.isNotEmpty() || text.lineSequence().any { lineMarker(it) != null }
|
||||
}
|
||||
|
||||
/** Recognized block markers and the tags they map to. */
|
||||
internal const val BULLET_PREFIX = "• "
|
||||
internal const val QUOTE_PREFIX = "> "
|
||||
private val ORDERED_PREFIX = Regex("^\\d+\\. ")
|
||||
|
||||
private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE }
|
||||
|
||||
private data class Line(val kind: Kind, val contentStart: Int, val contentEnd: Int)
|
||||
|
||||
/** The block marker prefixing [line], or null for an ordinary paragraph line. */
|
||||
internal fun lineMarker(line: String): String? = when {
|
||||
line.startsWith(BULLET_PREFIX) -> BULLET_PREFIX
|
||||
line.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX
|
||||
else -> ORDERED_PREFIX.find(line)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or
|
||||
* Compose types), so the whole conversion is unit-testable on the JVM.
|
||||
*
|
||||
* The emitted subset — `<b> <i> <u> <a> <ul>/<ol>/<li> <blockquote> <br>` — is deliberately narrow
|
||||
* so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts round-trip losslessly).
|
||||
*/
|
||||
object RichTextHtml {
|
||||
|
||||
fun toHtml(content: RichTextContent): String {
|
||||
if (content.text.isEmpty()) return ""
|
||||
val lines = classify(content.text)
|
||||
val sb = StringBuilder()
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
i = when (lines[i].kind) {
|
||||
Kind.BULLET -> emitList(sb, content, lines, i, Kind.BULLET, "ul")
|
||||
Kind.ORDERED -> emitList(sb, content, lines, i, Kind.ORDERED, "ol")
|
||||
Kind.QUOTE -> emitQuote(sb, content, lines, i)
|
||||
Kind.PARAGRAPH -> emitParagraph(sb, content, lines, i)
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */
|
||||
fun toPlainText(content: RichTextContent): String = content.text
|
||||
|
||||
fun fromHtml(html: String): RichTextContent = HtmlToRichParser(html).parse()
|
||||
}
|
||||
|
||||
private fun classify(text: String): List<Line> {
|
||||
val lines = ArrayList<Line>()
|
||||
var start = 0
|
||||
while (true) {
|
||||
val nl = text.indexOf('\n', start)
|
||||
val end = if (nl == -1) text.length else nl
|
||||
val marker = lineMarker(text.substring(start, end))
|
||||
val kind = when (marker) {
|
||||
BULLET_PREFIX -> Kind.BULLET
|
||||
QUOTE_PREFIX -> Kind.QUOTE
|
||||
null -> Kind.PARAGRAPH
|
||||
else -> Kind.ORDERED
|
||||
}
|
||||
lines.add(Line(kind, start + (marker?.length ?: 0), end))
|
||||
if (nl == -1) break
|
||||
start = nl + 1
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun emitList(
|
||||
sb: StringBuilder,
|
||||
content: RichTextContent,
|
||||
lines: List<Line>,
|
||||
from: Int,
|
||||
kind: Kind,
|
||||
tag: String,
|
||||
): Int {
|
||||
sb.append("<").append(tag).append(">")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == kind) {
|
||||
sb.append("<li>").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("</li>")
|
||||
i++
|
||||
}
|
||||
sb.append("</").append(tag).append(">")
|
||||
return i
|
||||
}
|
||||
|
||||
private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
sb.append("<blockquote>")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.QUOTE) {
|
||||
if (i > from) sb.append("<br>")
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
sb.append("</blockquote>")
|
||||
return i
|
||||
}
|
||||
|
||||
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
sb.append("<p>")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) {
|
||||
if (i > from) sb.append("<br>")
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
sb.append("</p>")
|
||||
return i
|
||||
}
|
||||
|
||||
/** Renders the inline styling/links over [[from], [to]) as nested `<a>/<b>/<i>/<u>` tags. */
|
||||
private fun renderInline(content: RichTextContent, from: Int, to: Int): String {
|
||||
if (from >= to) return ""
|
||||
val points = cutPoints(content, from, to)
|
||||
val sb = StringBuilder()
|
||||
for (idx in 0 until points.size - 1) {
|
||||
val a = points[idx]
|
||||
val b = points[idx + 1]
|
||||
if (a < b) appendRun(sb, content, a, b)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** The sorted set of offsets where a span or link starts/ends within [[from], [to]]. */
|
||||
private fun cutPoints(content: RichTextContent, from: Int, to: Int): List<Int> {
|
||||
val cuts = sortedSetOf(from, to)
|
||||
fun add(start: Int, end: Int) {
|
||||
if (end > from && start < to) {
|
||||
cuts.add(start.coerceIn(from, to))
|
||||
cuts.add(end.coerceIn(from, to))
|
||||
}
|
||||
}
|
||||
content.spans.forEach { add(it.start, it.end) }
|
||||
content.links.forEach { add(it.start, it.end) }
|
||||
return cuts.toList()
|
||||
}
|
||||
|
||||
/** Emits one constant-styling run [[a], [b]) with fully-closed tags, so nesting is always valid. */
|
||||
private fun appendRun(sb: StringBuilder, content: RichTextContent, a: Int, b: Int) {
|
||||
val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }.toSet()
|
||||
val link = content.links.firstOrNull { it.start <= a && b <= it.end }
|
||||
if (link != null) sb.append("<a href=\"").append(escapeAttr(link.url)).append("\">")
|
||||
if (RichStyle.BOLD in styles) sb.append("<b>")
|
||||
if (RichStyle.ITALIC in styles) sb.append("<i>")
|
||||
if (RichStyle.UNDERLINE in styles) sb.append("<u>")
|
||||
sb.append(escape(content.text.substring(a, b)))
|
||||
if (RichStyle.UNDERLINE in styles) sb.append("</u>")
|
||||
if (RichStyle.ITALIC in styles) sb.append("</i>")
|
||||
if (RichStyle.BOLD in styles) sb.append("</b>")
|
||||
if (link != null) sb.append("</a>")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the narrow HTML subset [toHtml][RichTextHtml.toHtml] emits (plus `strong`/`em` and
|
||||
* pretty-printer whitespace) back into a [RichTextContent]. A small state machine keeps the nesting
|
||||
* shallow: [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text.
|
||||
*/
|
||||
private class HtmlToRichParser(private val html: String) {
|
||||
private val text = StringBuilder()
|
||||
private val spans = ArrayList<RichSpan>()
|
||||
private val links = ArrayList<RichLink>()
|
||||
private var boldStart = -1
|
||||
private var italicStart = -1
|
||||
private var underlineStart = -1
|
||||
private var linkStart = -1
|
||||
private var linkUrl = ""
|
||||
private var listType: Char? = null
|
||||
private var olCount = 0
|
||||
private var inQuote = false
|
||||
|
||||
fun parse(): RichTextContent {
|
||||
var i = 0
|
||||
while (i < html.length) {
|
||||
if (html[i] == '<') {
|
||||
val gt = html.indexOf('>', i)
|
||||
if (gt == -1) break
|
||||
handleTag(html.substring(i + 1, gt).trim())
|
||||
i = gt + 1
|
||||
} else {
|
||||
val lt = html.indexOf('<', i)
|
||||
val end = if (lt == -1) html.length else lt
|
||||
handleText(html.substring(i, end))
|
||||
i = end
|
||||
}
|
||||
}
|
||||
return finish()
|
||||
}
|
||||
|
||||
private fun atLineStart() = text.isEmpty() || text.last() == '\n'
|
||||
|
||||
private fun newlineIfNeeded() {
|
||||
if (!atLineStart()) text.append('\n')
|
||||
}
|
||||
|
||||
private fun handleTag(raw: String) {
|
||||
val closing = raw.startsWith("/")
|
||||
val body = raw.removePrefix("/").trim()
|
||||
when (body.substringBefore(' ').substringBefore('/').lowercase()) {
|
||||
"br" -> {
|
||||
text.append('\n')
|
||||
if (inQuote) text.append(QUOTE_PREFIX)
|
||||
}
|
||||
"b", "strong" -> boldStart = toggle(closing, boldStart, RichStyle.BOLD)
|
||||
"i", "em" -> italicStart = toggle(closing, italicStart, RichStyle.ITALIC)
|
||||
"u" -> underlineStart = toggle(closing, underlineStart, RichStyle.UNDERLINE)
|
||||
"a" -> handleAnchor(closing, body)
|
||||
"ul" -> handleList(closing, 'u')
|
||||
"ol" -> handleList(closing, 'o')
|
||||
"li" -> if (!closing) startListItem()
|
||||
"blockquote" -> handleQuote(closing)
|
||||
"p", "div" -> newlineIfNeeded()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a style (returns the current offset) or closes it (records the span, returns -1). */
|
||||
private fun toggle(closing: Boolean, openOffset: Int, style: RichStyle): Int {
|
||||
if (!closing) return text.length
|
||||
if (openOffset >= 0) spans.add(RichSpan(openOffset, text.length, style))
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun handleAnchor(closing: Boolean, body: String) {
|
||||
if (closing) {
|
||||
if (linkStart >= 0) {
|
||||
links.add(RichLink(linkStart, text.length, linkUrl))
|
||||
linkStart = -1
|
||||
linkUrl = ""
|
||||
}
|
||||
} else {
|
||||
linkStart = text.length
|
||||
linkUrl = extractHref(body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleList(closing: Boolean, type: Char) {
|
||||
if (closing) {
|
||||
listType = null
|
||||
} else {
|
||||
listType = type
|
||||
if (type == 'o') olCount = 0
|
||||
}
|
||||
newlineIfNeeded()
|
||||
}
|
||||
|
||||
private fun startListItem() {
|
||||
newlineIfNeeded()
|
||||
if (listType == 'o') {
|
||||
olCount++
|
||||
text.append("$olCount. ")
|
||||
} else {
|
||||
text.append(BULLET_PREFIX)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleQuote(closing: Boolean) {
|
||||
newlineIfNeeded()
|
||||
inQuote = !closing
|
||||
if (!closing) text.append(QUOTE_PREFIX)
|
||||
}
|
||||
|
||||
private fun handleText(chunk: String) {
|
||||
// Drop the insignificant whitespace a pretty-printer leaves between block tags (blank runs at
|
||||
// a line start, or any blank run with a newline), but keep a real space between inline runs.
|
||||
if (!(chunk.isBlank() && (atLineStart() || chunk.contains('\n')))) text.append(unescape(chunk))
|
||||
}
|
||||
|
||||
private fun finish(): RichTextContent {
|
||||
val out = text.toString().trimEnd('\n')
|
||||
val len = out.length
|
||||
if (boldStart in 0 until len) spans.add(RichSpan(boldStart, len, RichStyle.BOLD))
|
||||
if (italicStart in 0 until len) spans.add(RichSpan(italicStart, len, RichStyle.ITALIC))
|
||||
if (underlineStart in 0 until len) spans.add(RichSpan(underlineStart, len, RichStyle.UNDERLINE))
|
||||
if (linkStart in 0 until len) links.add(RichLink(linkStart, len, linkUrl))
|
||||
return RichTextContent(
|
||||
text = out,
|
||||
spans = spans.filter { it.end <= len && it.start < it.end },
|
||||
links = links.filter { it.end <= len && it.start < it.end },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractHref(tagBody: String): String {
|
||||
val match = Regex("href\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE).find(tagBody) ?: return ""
|
||||
val (doubleQuoted, singleQuoted) = match.destructured
|
||||
return unescape(doubleQuoted.ifEmpty { singleQuoted })
|
||||
}
|
||||
|
||||
private fun escape(s: String): String = s.replace("&", "&").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("&", "&")
|
||||
@@ -0,0 +1,200 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.richtext
|
||||
|
||||
/** A block-level marker the toolbar can toggle over the selected lines. */
|
||||
enum class BlockMarker { BULLET, ORDERED, QUOTE }
|
||||
|
||||
/** The result of an editing op: the new [content] and where the selection should land. */
|
||||
data class EditResult(val content: RichTextContent, val selectionStart: Int, val selectionEnd: Int)
|
||||
|
||||
/**
|
||||
* Pure editing operations over [RichTextContent] — toggling inline styles/links and block markers —
|
||||
* shared by the Compose editor's toolbar. Keeping them free of Compose types makes the tricky index
|
||||
* bookkeeping (splitting spans, shifting offsets when a line marker is inserted/removed) unit-testable
|
||||
* on the JVM.
|
||||
*/
|
||||
object RichTextEditing {
|
||||
|
||||
/** Adds [style] over [[start], [end]) if it is not already fully styled, otherwise removes it. */
|
||||
fun toggleStyle(content: RichTextContent, start: Int, end: Int, style: RichStyle): RichTextContent {
|
||||
if (start >= end) return content
|
||||
val others = content.spans.filter { it.style != style }
|
||||
val same = content.spans.filter { it.style == style }
|
||||
val updated = if (isFullyStyled(same, start, end)) {
|
||||
subtractRange(same, start, end)
|
||||
} else {
|
||||
mergeSameStyle(same + RichSpan(start, end, style))
|
||||
}
|
||||
return content.copy(spans = (others + updated).sortedBy { it.start })
|
||||
}
|
||||
|
||||
/** Links [[start], [end]) to [url], replacing any links that overlap the range. */
|
||||
fun applyLink(content: RichTextContent, start: Int, end: Int, url: String): RichTextContent {
|
||||
if (start >= end || url.isBlank()) return content
|
||||
val kept = content.links.filter { it.end <= start || it.start >= end }
|
||||
return content.copy(links = (kept + RichLink(start, end, url)).sortedBy { it.start })
|
||||
}
|
||||
|
||||
/** Removes any links overlapping [[start], [end]). */
|
||||
fun removeLink(content: RichTextContent, start: Int, end: Int): RichTextContent {
|
||||
if (start >= end) return content
|
||||
return content.copy(links = content.links.filter { it.end <= start || it.start >= end })
|
||||
}
|
||||
|
||||
/** Whether [[start], [end]) is entirely covered by [style] (drives the toolbar's toggle state). */
|
||||
fun isStyled(content: RichTextContent, start: Int, end: Int, style: RichStyle): Boolean =
|
||||
start < end && isFullyStyled(content.spans.filter { it.style == style }, start, end)
|
||||
|
||||
/** Whether every line the selection touches carries [marker]. */
|
||||
fun hasBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): Boolean {
|
||||
val lineStarts = lineStartsTouching(content.text, start, end)
|
||||
return lineStarts.isNotEmpty() && lineStarts.all { markerAt(content.text, it) == marker }
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles [marker] across every line the selection touches: if all those lines already carry it,
|
||||
* it is removed; otherwise it is applied (replacing any other block marker already there). Spans,
|
||||
* links and the selection are shifted to track the inserted/removed prefixes.
|
||||
*/
|
||||
fun toggleBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): EditResult {
|
||||
val text = content.text
|
||||
val lineStarts = lineStartsTouching(text, start, end)
|
||||
val allHaveMarker = lineStarts.all { markerAt(text, it) == marker }
|
||||
val edits = ArrayList<LineEdit>()
|
||||
var ordinal = 1
|
||||
for (lineStart in lineStarts) {
|
||||
val existing = markerLengthAt(text, lineStart)
|
||||
when {
|
||||
allHaveMarker && existing > 0 -> edits.add(LineEdit(lineStart, existing, ""))
|
||||
allHaveMarker -> Unit
|
||||
else -> edits.add(LineEdit(lineStart, existing, insertFor(marker, ordinal++)))
|
||||
}
|
||||
}
|
||||
val (newText, remap) = applyEdits(text, edits)
|
||||
val newSpans = content.spans.mapNotNull { remapSpan(it, remap) }
|
||||
val newLinks = content.links.mapNotNull { remapLink(it, remap) }
|
||||
return EditResult(RichTextContent(newText, newSpans, newLinks), remap(start), remap(end))
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertFor(marker: BlockMarker, ordinal: Int): String = when (marker) {
|
||||
BlockMarker.BULLET -> BULLET_PREFIX
|
||||
BlockMarker.QUOTE -> QUOTE_PREFIX
|
||||
BlockMarker.ORDERED -> "$ordinal. "
|
||||
}
|
||||
|
||||
private fun remapSpan(span: RichSpan, remap: (Int) -> Int): RichSpan? {
|
||||
val s = remap(span.start)
|
||||
val e = remap(span.end)
|
||||
return if (s < e) RichSpan(s, e, span.style) else null
|
||||
}
|
||||
|
||||
private fun remapLink(link: RichLink, remap: (Int) -> Int): RichLink? {
|
||||
val s = remap(link.start)
|
||||
val e = remap(link.end)
|
||||
return if (s < e) RichLink(s, e, link.url) else null
|
||||
}
|
||||
|
||||
// --- inline style helpers ---
|
||||
|
||||
private fun isFullyStyled(spans: List<RichSpan>, start: Int, end: Int): Boolean {
|
||||
var pos = start
|
||||
for (span in spans.filter { it.end > start && it.start < end }.sortedBy { it.start }) {
|
||||
if (span.start > pos) return false
|
||||
pos = maxOf(pos, span.end)
|
||||
if (pos >= end) return true
|
||||
}
|
||||
return pos >= end
|
||||
}
|
||||
|
||||
private fun subtractRange(spans: List<RichSpan>, start: Int, end: Int): List<RichSpan> = spans.flatMap { span ->
|
||||
when {
|
||||
span.end <= start || span.start >= end -> listOf(span)
|
||||
else -> buildList {
|
||||
if (span.start < start) add(span.copy(end = start))
|
||||
if (span.end > end) add(span.copy(start = end))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeSameStyle(spans: List<RichSpan>): List<RichSpan> {
|
||||
val merged = ArrayList<RichSpan>()
|
||||
for (span in spans.sortedBy { it.start }) {
|
||||
val last = merged.lastOrNull()
|
||||
if (last != null && span.start <= last.end) {
|
||||
merged[merged.size - 1] = last.copy(end = maxOf(last.end, span.end))
|
||||
} else {
|
||||
merged.add(span)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// --- block marker helpers ---
|
||||
|
||||
private val ORDERED = Regex("^\\d+\\. ")
|
||||
|
||||
private fun markerAt(text: String, lineStart: Int): BlockMarker? {
|
||||
val rest = text.substring(lineStart)
|
||||
return when {
|
||||
rest.startsWith(BULLET_PREFIX) -> BlockMarker.BULLET
|
||||
rest.startsWith(QUOTE_PREFIX) -> BlockMarker.QUOTE
|
||||
// ORDERED is anchored at ^, so find() matches only when this line starts with "N. ".
|
||||
ORDERED.find(rest) != null -> BlockMarker.ORDERED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun markerLengthAt(text: String, lineStart: Int): Int {
|
||||
val rest = text.substring(lineStart)
|
||||
return when {
|
||||
rest.startsWith(BULLET_PREFIX) -> BULLET_PREFIX.length
|
||||
rest.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX.length
|
||||
else -> ORDERED.find(rest)?.value?.length ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Start offsets of every line the range [[start], [end]] intersects (a caret counts as its line). */
|
||||
private fun lineStartsTouching(text: String, start: Int, end: Int): List<Int> {
|
||||
val from = start.coerceIn(0, text.length)
|
||||
val to = end.coerceIn(from, text.length)
|
||||
val result = ArrayList<Int>()
|
||||
var lineStart = if (from == 0) 0 else text.lastIndexOf('\n', from - 1).let { if (it == -1) 0 else it + 1 }
|
||||
while (lineStart <= text.length) {
|
||||
result.add(lineStart)
|
||||
val nl = text.indexOf('\n', lineStart)
|
||||
if (nl == -1 || nl >= to) break
|
||||
lineStart = nl + 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private data class LineEdit(val offset: Int, val deleteLen: Int, val insert: String)
|
||||
|
||||
/** Applies line-start [edits] (ascending, non-overlapping) and returns the new text + an index remap. */
|
||||
private fun applyEdits(text: String, edits: List<LineEdit>): Pair<String, (Int) -> Int> {
|
||||
if (edits.isEmpty()) return text to { it }
|
||||
val sorted = edits.sortedBy { it.offset }
|
||||
val sb = StringBuilder()
|
||||
var cursor = 0
|
||||
for (edit in sorted) {
|
||||
sb.append(text, cursor, edit.offset)
|
||||
sb.append(edit.insert)
|
||||
cursor = edit.offset + edit.deleteLen
|
||||
}
|
||||
sb.append(text, cursor, text.length)
|
||||
val remap: (Int) -> Int = { index -> remapIndex(index, sorted).coerceIn(0, sb.length) }
|
||||
return sb.toString() to remap
|
||||
}
|
||||
|
||||
private fun remapIndex(index: Int, edits: List<LineEdit>): Int {
|
||||
var delta = 0
|
||||
for (edit in edits) {
|
||||
val delEnd = edit.offset + edit.deleteLen
|
||||
when {
|
||||
delEnd <= index -> delta += edit.insert.length - edit.deleteLen
|
||||
edit.offset < index -> delta += edit.insert.length - (index - edit.offset)
|
||||
}
|
||||
}
|
||||
return index + delta
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.take
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Decides the app's start destination from the stored account count: no accounts → the onboarding
|
||||
* welcome flow; otherwise the mailbox.
|
||||
*
|
||||
* [startDestination] is `null` until the first account snapshot loads — the UI holds (renders
|
||||
* nothing) during that window so a cold start never flashes the wrong screen. Only the *first*
|
||||
* determination is used ([take]), so adding the first account mid-onboarding does not later flip the
|
||||
* start destination and tear down the in-progress flow.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AppViewModel @Inject constructor(accountRepository: AccountRepository) : ViewModel() {
|
||||
|
||||
val startDestination: StateFlow<String?> = accountRepository.observeAccounts()
|
||||
.map { accounts -> if (accounts.isEmpty()) Routes.ONBOARDING else Routes.MAILBOX }
|
||||
.take(1)
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
}
|
||||
@@ -1,39 +1,99 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.navigation
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import org.libremail.ui.accountsetup.AccountSetupScreen
|
||||
import org.libremail.R
|
||||
import org.libremail.ui.accountsetup.AccountPickerScreen
|
||||
import org.libremail.ui.accountsetup.AppPasswordSetupScreen
|
||||
import org.libremail.ui.accountsetup.ManualSetupScreen
|
||||
import org.libremail.ui.compose.ComposePrefill
|
||||
import org.libremail.ui.compose.ComposeScreen
|
||||
import org.libremail.ui.drafts.DraftsScreen
|
||||
import org.libremail.ui.mailbox.MailboxScreen
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.onboarding.AddAnotherAccountScreen
|
||||
import org.libremail.ui.onboarding.BatteryOptimizationScreen
|
||||
import org.libremail.ui.onboarding.OnboardingViewModel
|
||||
import org.libremail.ui.onboarding.OnboardingWelcomeScreen
|
||||
import org.libremail.ui.outbox.OutboxScreen
|
||||
import org.libremail.ui.reader.ReaderScreen
|
||||
import org.libremail.ui.reporting.ProblemReportsScreen
|
||||
import org.libremail.ui.reporting.ReportReviewScreen
|
||||
import org.libremail.ui.reporting.StartupReportViewModel
|
||||
import org.libremail.ui.settings.AccountSettingsScreen
|
||||
import org.libremail.ui.settings.SettingsScreen
|
||||
import org.libremail.ui.settings.SignatureEditScreen
|
||||
import org.libremail.ui.settings.SignaturesScreen
|
||||
|
||||
@Composable
|
||||
fun LibreMailApp() {
|
||||
fun LibreMailApp(
|
||||
appViewModel: AppViewModel = hiltViewModel(),
|
||||
startupViewModel: StartupReportViewModel = hiltViewModel(),
|
||||
pendingCompose: ComposePrefill? = null,
|
||||
onComposeHandled: () -> Unit = {},
|
||||
) {
|
||||
val startDestination by appViewModel.startDestination.collectAsStateWithLifecycle()
|
||||
// Hold (render nothing) until the account count is known, so a cold start never flashes the
|
||||
// wrong screen before onboarding-vs-mailbox is decided.
|
||||
val start = startDestination ?: return
|
||||
val navController = rememberNavController()
|
||||
val pendingCrash by startupViewModel.pendingCrash.collectAsStateWithLifecycle()
|
||||
|
||||
// A mailto:/share intent opens compose on top of the mailbox, pre-filled. Keyed on the request so
|
||||
// it fires once per intent (and again for a new intent delivered while the app is alive).
|
||||
LaunchedEffect(pendingCompose) {
|
||||
val prefill = pendingCompose ?: return@LaunchedEffect
|
||||
navController.navigate(
|
||||
Routes.compose(
|
||||
to = prefill.to,
|
||||
subject = prefill.subject,
|
||||
cc = prefill.cc,
|
||||
bcc = prefill.bcc,
|
||||
body = prefill.body,
|
||||
),
|
||||
)
|
||||
onComposeHandled()
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.MAILBOX,
|
||||
startDestination = start,
|
||||
) {
|
||||
composable(Routes.MAILBOX) {
|
||||
onboardingGraph(navController)
|
||||
|
||||
composable(
|
||||
route = Routes.MAILBOX_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
),
|
||||
) {
|
||||
MailboxScreen(
|
||||
onOpenMessage = { id -> navController.navigate(Routes.reader(id)) },
|
||||
onCompose = { navController.navigate(Routes.compose()) },
|
||||
@@ -60,10 +120,22 @@ fun LibreMailApp() {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
navArgument(Routes.COMPOSE_ARG_CC) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
navArgument(Routes.COMPOSE_ARG_BCC) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
navArgument(Routes.COMPOSE_ARG_SUBJECT) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
navArgument(Routes.COMPOSE_ARG_BODY) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
navArgument(Routes.COMPOSE_ARG_FROM) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
@@ -81,33 +153,78 @@ fun LibreMailApp() {
|
||||
onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) },
|
||||
onOpenAccount = { accountId -> navController.navigate(Routes.accountSettings(accountId)) },
|
||||
onSelectTab = navController::navigateTab,
|
||||
onReportProblem = { navController.navigate(Routes.PROBLEM_REPORTS) },
|
||||
)
|
||||
}
|
||||
composable(Routes.PROBLEM_REPORTS) {
|
||||
ProblemReportsScreen(
|
||||
onBack = navController::popBackStack,
|
||||
onOpenReport = { reportId -> navController.navigate(Routes.reportReview(reportId)) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.REPORT_REVIEW_PATTERN,
|
||||
arguments = listOf(navArgument(Routes.REPORT_REVIEW_ARG_ID) { type = NavType.StringType }),
|
||||
) {
|
||||
ReportReviewScreen(onDone = navController::popBackStack)
|
||||
}
|
||||
composable(
|
||||
route = Routes.ACCOUNT_SETTINGS_PATTERN,
|
||||
arguments = listOf(navArgument(Routes.ACCOUNT_SETTINGS_ARG_ID) { type = NavType.StringType }),
|
||||
) {
|
||||
AccountSettingsScreen(onBack = navController::popBackStack)
|
||||
}
|
||||
composable(Routes.ACCOUNT_SETUP) {
|
||||
AccountSetupScreen(
|
||||
) { backStackEntry ->
|
||||
val accountId = backStackEntry.arguments?.getString(Routes.ACCOUNT_SETTINGS_ARG_ID).orEmpty()
|
||||
AccountSettingsScreen(
|
||||
onBack = navController::popBackStack,
|
||||
onManualSetup = { navController.navigate(Routes.MANUAL_SETUP) },
|
||||
onAccountAdded = {
|
||||
navController.navigate(Routes.MAILBOX) {
|
||||
popUpTo(Routes.MAILBOX) { inclusive = true }
|
||||
}
|
||||
onManageSignatures = { navController.navigate(Routes.signatures(accountId)) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.SIGNATURES_PATTERN,
|
||||
arguments = listOf(navArgument(Routes.SIGNATURES_ARG_ACCOUNT) { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val accountId = backStackEntry.arguments?.getString(Routes.SIGNATURES_ARG_ACCOUNT).orEmpty()
|
||||
SignaturesScreen(
|
||||
onBack = navController::popBackStack,
|
||||
onEdit = { signatureId -> navController.navigate(Routes.signatureEdit(accountId, signatureId)) },
|
||||
onAdd = { navController.navigate(Routes.signatureEdit(accountId)) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.SIGNATURE_EDIT_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.SIGNATURE_EDIT_ARG_ACCOUNT) { type = NavType.StringType },
|
||||
navArgument(Routes.SIGNATURE_EDIT_ARG_ID) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
),
|
||||
) {
|
||||
SignatureEditScreen(onBack = navController::popBackStack)
|
||||
}
|
||||
// "Add account" entry reused by Settings and the mailbox. These reuse the SAME picker/setup
|
||||
// screens as onboarding, but each pops back to where the user was on success (no "add
|
||||
// another?" prompt — that is onboarding-only, see #30).
|
||||
composable(Routes.ACCOUNT_SETUP) {
|
||||
AccountPickerScreen(
|
||||
onBack = navController::popBackStack,
|
||||
onAccountAdded = { navController.popBackStack() },
|
||||
onPickProvider = { provider -> navController.navigate(Routes.appPassword(provider.key)) },
|
||||
onManualSetup = { navController.navigate(Routes.MANUAL_SETUP) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.APP_PASSWORD_PATTERN,
|
||||
arguments = listOf(navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType }),
|
||||
) {
|
||||
AppPasswordSetupScreen(
|
||||
onBack = navController::popBackStack,
|
||||
onAccountAdded = { navController.popBackStack(Routes.ACCOUNT_SETUP, inclusive = true) },
|
||||
)
|
||||
}
|
||||
composable(Routes.MANUAL_SETUP) {
|
||||
ManualSetupScreen(
|
||||
onBack = navController::popBackStack,
|
||||
onAccountAdded = {
|
||||
navController.navigate(Routes.MAILBOX) {
|
||||
popUpTo(Routes.MAILBOX) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onAccountAdded = { navController.popBackStack(Routes.ACCOUNT_SETUP, inclusive = true) },
|
||||
)
|
||||
}
|
||||
composable(Routes.DRAFTS) {
|
||||
@@ -120,6 +237,144 @@ fun LibreMailApp() {
|
||||
OutboxScreen(onBack = navController::popBackStack)
|
||||
}
|
||||
}
|
||||
|
||||
// On launch, offer any saved crash report for review — never sent without the user's action.
|
||||
pendingCrash?.let { crash ->
|
||||
CrashReportDialog(
|
||||
onReview = {
|
||||
startupViewModel.dismiss()
|
||||
navController.navigate(Routes.reportReview(crash.id))
|
||||
},
|
||||
onLater = startupViewModel::dismiss,
|
||||
onDiscard = { startupViewModel.discard(crash.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CrashReportDialog(onReview: () -> Unit, onLater: () -> Unit, onDiscard: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onLater,
|
||||
title = { Text(stringResource(R.string.crash_prompt_title)) },
|
||||
text = { Text(stringResource(R.string.crash_prompt_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onReview) { Text(stringResource(R.string.crash_prompt_review)) }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onDiscard) { Text(stringResource(R.string.crash_prompt_discard)) }
|
||||
TextButton(onClick = onLater) { Text(stringResource(R.string.crash_prompt_later)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run onboarding as a nested graph so a single graph-scoped [OnboardingViewModel] can track the
|
||||
* first account added this session. The picker/setup screens are the same composables used by the
|
||||
* top-level "Add account" routes; here, a successful add routes to the "add another?" prompt instead
|
||||
* of popping back.
|
||||
*/
|
||||
private fun NavGraphBuilder.onboardingGraph(navController: NavHostController) {
|
||||
navigation(startDestination = Routes.ONBOARDING_WELCOME, route = Routes.ONBOARDING) {
|
||||
composable(Routes.ONBOARDING_WELCOME) {
|
||||
OnboardingWelcomeScreen(onAddAccount = { navController.navigate(Routes.ONBOARDING_PICKER) })
|
||||
}
|
||||
composable(Routes.ONBOARDING_PICKER) { entry ->
|
||||
val onboarding = onboardingViewModel(navController, entry)
|
||||
AccountPickerScreen(
|
||||
onBack = navController::popBackStack,
|
||||
onAccountAdded = { id ->
|
||||
onboarding.onAccountAdded(id)
|
||||
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER)
|
||||
},
|
||||
onPickProvider = { provider ->
|
||||
navController.navigate(Routes.onboardingAppPassword(provider.key))
|
||||
},
|
||||
onManualSetup = { navController.navigate(Routes.ONBOARDING_MANUAL) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.ONBOARDING_APP_PASSWORD_PATTERN,
|
||||
arguments = listOf(navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val onboarding = onboardingViewModel(navController, entry)
|
||||
AppPasswordSetupScreen(
|
||||
onBack = navController::popBackStack,
|
||||
onAccountAdded = { id -> onboarding.completeAdd(navController, id) },
|
||||
)
|
||||
}
|
||||
composable(Routes.ONBOARDING_MANUAL) { entry ->
|
||||
val onboarding = onboardingViewModel(navController, entry)
|
||||
ManualSetupScreen(
|
||||
onBack = navController::popBackStack,
|
||||
onAccountAdded = { id -> onboarding.completeAdd(navController, id) },
|
||||
)
|
||||
}
|
||||
onboardingFinishDestinations(navController)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tail of onboarding: the "add another?" prompt and the optional battery opt-in step. Split out of
|
||||
* [onboardingGraph] so each stays a readable length; both share the graph-scoped [OnboardingViewModel].
|
||||
*/
|
||||
private fun NavGraphBuilder.onboardingFinishDestinations(navController: NavHostController) {
|
||||
composable(Routes.ONBOARDING_ADD_ANOTHER) { entry ->
|
||||
val onboarding = onboardingViewModel(navController, entry)
|
||||
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
|
||||
AddAnotherAccountScreen(
|
||||
onAddAnother = {
|
||||
// Return to a fresh picker, clearing the prompt and the prior setup screen.
|
||||
navController.navigate(Routes.ONBOARDING_PICKER) {
|
||||
popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onFinish = {
|
||||
// Offer the battery opt-in as a final step when it's needed; otherwise go straight to
|
||||
// the inbox. A still-undecided (null) decision fails open to finishing.
|
||||
if (batteryPromptNeeded == true) {
|
||||
navController.navigate(Routes.ONBOARDING_BATTERY)
|
||||
} else {
|
||||
navController.finishOnboarding(onboarding.firstAddedAccountId)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.ONBOARDING_BATTERY) { entry ->
|
||||
val onboarding = onboardingViewModel(navController, entry)
|
||||
BatteryOptimizationScreen(
|
||||
viewModel = onboarding,
|
||||
onFinish = {
|
||||
onboarding.markBatteryPromptHandled()
|
||||
navController.finishOnboarding(onboarding.firstAddedAccountId)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Leaves onboarding for the inbox — the first account added this session, or the unfiltered mailbox. */
|
||||
private fun NavController.finishOnboarding(firstAccountId: String?) {
|
||||
val dest = if (firstAccountId != null) Routes.mailboxForAccount(firstAccountId) else Routes.MAILBOX
|
||||
navigate(dest) {
|
||||
// Leave onboarding entirely; the mailbox becomes the new back-stack root.
|
||||
popUpTo(Routes.ONBOARDING) { inclusive = true }
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the onboarding-graph-scoped [OnboardingViewModel] shared across the onboarding screens. */
|
||||
@Composable
|
||||
private fun onboardingViewModel(navController: NavController, entry: NavBackStackEntry): OnboardingViewModel {
|
||||
val parentEntry = remember(entry) { navController.getBackStackEntry(Routes.ONBOARDING) }
|
||||
return hiltViewModel(parentEntry)
|
||||
}
|
||||
|
||||
/** Records the added account, then advances to the "add another?" prompt (dropping the setup form). */
|
||||
private fun OnboardingViewModel.completeAdd(navController: NavController, accountId: String) {
|
||||
onAccountAdded(accountId)
|
||||
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
popUpTo(Routes.ONBOARDING_PICKER)
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigate between top-level tabs, preserving each tab's back stack and state. */
|
||||
|
||||
+79
-33
@@ -4,25 +4,31 @@ package org.libremail.ui.accountsetup
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@@ -34,19 +40,31 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
import org.libremail.domain.model.MailProvider
|
||||
|
||||
/**
|
||||
* The single account-vendor picker used both by first-run onboarding and the "Add account" entry
|
||||
* from Settings/mailbox. It routes each choice to the correct setup path:
|
||||
* - Outlook/Hotmail → the existing Microsoft OAuth flow, completed inline via [AccountSetupViewModel].
|
||||
* - Gmail / Yahoo / iCloud → the guided app-password screen with the matching [MailProvider] preset.
|
||||
* - Other (IMAP/SMTP) → the manual setup screen.
|
||||
*
|
||||
* @param onAccountAdded invoked with the new account id when the *inline* Outlook flow completes.
|
||||
* The app-password and manual paths report their own completion from their own screens.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountSetupScreen(
|
||||
fun AccountPickerScreen(
|
||||
onBack: () -> Unit,
|
||||
onAccountAdded: (String) -> Unit,
|
||||
onPickProvider: (MailProvider) -> Unit,
|
||||
onManualSetup: () -> Unit,
|
||||
onAccountAdded: () -> Unit,
|
||||
viewModel: AccountSetupViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -56,8 +74,10 @@ fun AccountSetupScreen(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result -> viewModel.onOutlookResult(result.data) }
|
||||
|
||||
LaunchedEffect(state.status) {
|
||||
if (state.status == SetupStatus.DONE) onAccountAdded()
|
||||
LaunchedEffect(state.status, state.addedAccountId) {
|
||||
if (state.status == SetupStatus.DONE) {
|
||||
state.addedAccountId?.let(onAccountAdded)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.error) {
|
||||
state.error?.let {
|
||||
@@ -86,24 +106,24 @@ fun AccountSetupScreen(
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Email,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.account_setup_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
ProviderRow(
|
||||
// Recognizable brand logos would need bundled trademarked assets; until those
|
||||
// exist we use a neutral mail glyph and rely on the visible label for recognition.
|
||||
icon = Icons.Filled.Email,
|
||||
label = stringResource(R.string.account_setup_outlook),
|
||||
enabled = !busy,
|
||||
onClick = {
|
||||
viewModel.outlookAuthIntent().fold(
|
||||
onSuccess = { intent ->
|
||||
@@ -113,19 +133,22 @@ fun AccountSetupScreen(
|
||||
onFailure = { viewModel.onOutlookLaunchFailed(it) },
|
||||
)
|
||||
},
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.account_setup_outlook))
|
||||
)
|
||||
MailProvider.entries.forEach { provider ->
|
||||
ProviderRow(
|
||||
icon = Icons.Filled.Email,
|
||||
label = provider.displayName,
|
||||
enabled = !busy,
|
||||
onClick = { onPickProvider(provider) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
ProviderRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
label = stringResource(R.string.account_setup_other),
|
||||
enabled = !busy,
|
||||
onClick = onManualSetup,
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.account_setup_other))
|
||||
}
|
||||
)
|
||||
}
|
||||
if (busy) {
|
||||
Box(
|
||||
@@ -140,3 +163,26 @@ fun AccountSetupScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderRow(icon: ImageVector, label: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClickLabel = label, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,19 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.auth.OutlookAuthManager
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Stage of an account-setup attempt, shared by the Outlook and manual flows. */
|
||||
enum class SetupStatus { IDLE, CONNECTING, DONE }
|
||||
|
||||
data class AccountSetupUiState(val status: SetupStatus = SetupStatus.IDLE, val error: String? = null)
|
||||
data class AccountSetupUiState(
|
||||
val status: SetupStatus = SetupStatus.IDLE,
|
||||
val error: String? = null,
|
||||
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
|
||||
val addedAccountId: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AccountSetupViewModel @Inject constructor(
|
||||
@@ -59,8 +65,11 @@ class AccountSetupViewModel @Inject constructor(
|
||||
runCatching {
|
||||
val oauth = outlookAuthManager.exchangeToken(data)
|
||||
accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow()
|
||||
Account.outlook(oauth.email).id
|
||||
}.fold(
|
||||
onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } },
|
||||
onSuccess = { accountId ->
|
||||
_state.update { it.copy(status = SetupStatus.DONE, addedAccountId = accountId) }
|
||||
},
|
||||
onFailure = { e ->
|
||||
// Stripped from release builds by the Log.d ProGuard rule (keeps any account
|
||||
// address / token detail out of shipped logs); visible in debug for diagnosis.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.accountsetup
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.R
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
|
||||
/**
|
||||
* Guided app-password setup for the preset vendors (Gmail/Yahoo/iCloud). Explains what an app
|
||||
* password is, warns to keep it safe, links out to the provider's app-password page, and collects
|
||||
* only an email + app password (the servers come from the [MailProvider] preset). Verifies and
|
||||
* persists via the same repository path as manual setup, surfacing failures as an inline snackbar.
|
||||
*
|
||||
* @param onAccountAdded invoked with the new account id after a successful add.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppPasswordSetupScreen(
|
||||
onBack: () -> Unit,
|
||||
onAccountAdded: (String) -> Unit,
|
||||
viewModel: AppPasswordViewModel = hiltViewModel(),
|
||||
) {
|
||||
val form by viewModel.form.collectAsStateWithLifecycle()
|
||||
val provider = viewModel.provider
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Resolved up front so the failure handler (a non-composable lambda) can use it.
|
||||
val openFailedMessage = stringResource(R.string.app_password_open_failed)
|
||||
|
||||
LaunchedEffect(form.status, form.addedAccountId) {
|
||||
if (form.status == SetupStatus.DONE) {
|
||||
form.addedAccountId?.let(onAccountAdded)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(form.error) {
|
||||
form.error?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.consumeError()
|
||||
}
|
||||
}
|
||||
|
||||
val busy = form.status == SetupStatus.CONNECTING
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
provider?.let { stringResource(R.string.app_password_title, it.displayName) }
|
||||
?: stringResource(R.string.title_account_setup),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
if (provider == null) {
|
||||
// Defensive: onboarding only ever routes valid provider keys here.
|
||||
Text(
|
||||
text = stringResource(R.string.app_password_unknown_provider),
|
||||
modifier = Modifier.padding(padding).padding(24.dp),
|
||||
)
|
||||
return@Scaffold
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
InfoCard(
|
||||
icon = Icons.Filled.Info,
|
||||
text = stringResource(providerIntro(provider)),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
InfoCard(
|
||||
icon = Icons.Filled.Info,
|
||||
text = stringResource(R.string.app_password_what_is),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
InfoCard(
|
||||
icon = Icons.Filled.Warning,
|
||||
text = stringResource(R.string.app_password_warning),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
// openUri throws if no browser/handler is installed; surface it instead of crashing.
|
||||
runCatching { uriHandler.openUri(provider.appPasswordHelpUrl) }
|
||||
.onFailure { scope.launch { snackbarHostState.showSnackbar(openFailedMessage) } }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.app_password_open_page, provider.displayName))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
OutlinedTextField(
|
||||
value = form.email,
|
||||
onValueChange = viewModel::onEmail,
|
||||
label = { Text(stringResource(R.string.app_password_email)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = form.appPassword,
|
||||
onValueChange = viewModel::onAppPassword,
|
||||
label = { Text(stringResource(R.string.app_password_field)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val account = remember(provider) { provider.createAccount("") }
|
||||
AdvancedServers(
|
||||
expanded = form.advancedExpanded,
|
||||
onToggle = viewModel::toggleAdvanced,
|
||||
imap = account.imap,
|
||||
smtp = account.smtp,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = viewModel::testAndSave,
|
||||
enabled = form.isValid && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (busy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.app_password_test_and_add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(icon: ImageVector, text: String) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp).padding(top = 2.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/** A collapsible, read-only view of the preset servers for users who want to confirm them. */
|
||||
@Composable
|
||||
private fun AdvancedServers(expanded: Boolean, onToggle: () -> Unit, imap: ServerConfig, smtp: ServerConfig) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.app_password_show_servers),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (expanded) 180f else 0f),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.app_password_server_imap, imap.host, imap.port, imap.security.label()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.app_password_server_smtp, smtp.host, smtp.port, smtp.security.label()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerIntro(provider: MailProvider): Int = when (provider) {
|
||||
MailProvider.GMAIL -> R.string.app_password_intro_gmail
|
||||
MailProvider.YAHOO -> R.string.app_password_intro_yahoo
|
||||
MailProvider.ICLOUD -> R.string.app_password_intro_icloud
|
||||
}
|
||||
|
||||
private fun MailSecurity.label(): String = when (this) {
|
||||
MailSecurity.SSL_TLS -> "SSL/TLS"
|
||||
MailSecurity.STARTTLS -> "STARTTLS"
|
||||
MailSecurity.NONE -> "None"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.accountsetup
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AppPasswordForm(
|
||||
val email: String = "",
|
||||
val appPassword: String = "",
|
||||
val advancedExpanded: Boolean = false,
|
||||
val status: SetupStatus = SetupStatus.IDLE,
|
||||
val error: String? = null,
|
||||
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
|
||||
val addedAccountId: String? = null,
|
||||
) {
|
||||
val isValid: Boolean get() = email.isNotBlank() && appPassword.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs the guided app-password setup screen (#29) for the preset vendors (Gmail/Yahoo/iCloud).
|
||||
*
|
||||
* The provider is passed as a nav argument and resolved from the [MailProvider] registry, which
|
||||
* supplies the servers. The user only supplies an email + app password; this builds a
|
||||
* `PASSWORD_IMAP` [org.libremail.domain.model.Account] from the preset and reuses
|
||||
* [AccountRepository.addImapAccount] (live connection test + persist), exactly like manual setup.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AppPasswordViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val accountRepository: AccountRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The provider preset selected in the picker; null only if an unknown key was routed here. */
|
||||
val provider: MailProvider? =
|
||||
savedStateHandle.get<String>(Routes.APP_PASSWORD_ARG_PROVIDER)?.let(MailProvider::fromKey)
|
||||
|
||||
private val _form = MutableStateFlow(AppPasswordForm())
|
||||
val form: StateFlow<AppPasswordForm> = _form.asStateFlow()
|
||||
|
||||
fun onEmail(value: String) = _form.update { it.copy(email = value) }
|
||||
fun onAppPassword(value: String) = _form.update { it.copy(appPassword = value) }
|
||||
fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) }
|
||||
fun consumeError() = _form.update { it.copy(error = null) }
|
||||
|
||||
fun testAndSave() {
|
||||
val provider = provider
|
||||
if (provider == null) {
|
||||
_form.update { it.copy(error = "Unknown email provider") }
|
||||
return
|
||||
}
|
||||
val f = _form.value
|
||||
if (!f.isValid) {
|
||||
_form.update { it.copy(error = "Enter your email address and app password") }
|
||||
return
|
||||
}
|
||||
val account = provider.createAccount(f.email)
|
||||
viewModelScope.launch {
|
||||
_form.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
|
||||
accountRepository.addImapAccount(account, f.appPassword).fold(
|
||||
onSuccess = {
|
||||
_form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) }
|
||||
},
|
||||
onFailure = { e ->
|
||||
_form.update {
|
||||
it.copy(
|
||||
status = SetupStatus.IDLE,
|
||||
error = e.message ?: "Could not connect to the server",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,14 +52,16 @@ import org.libremail.domain.model.MailSecurity
|
||||
@Composable
|
||||
fun ManualSetupScreen(
|
||||
onBack: () -> Unit,
|
||||
onAccountAdded: () -> Unit,
|
||||
onAccountAdded: (String) -> Unit,
|
||||
viewModel: ManualSetupViewModel = hiltViewModel(),
|
||||
) {
|
||||
val form by viewModel.form.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(form.status) {
|
||||
if (form.status == SetupStatus.DONE) onAccountAdded()
|
||||
LaunchedEffect(form.status, form.addedAccountId) {
|
||||
if (form.status == SetupStatus.DONE) {
|
||||
form.addedAccountId?.let(onAccountAdded)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(form.error) {
|
||||
form.error?.let {
|
||||
|
||||
@@ -28,6 +28,8 @@ data class ManualSetupForm(
|
||||
val advancedExpanded: Boolean = false,
|
||||
val status: SetupStatus = SetupStatus.IDLE,
|
||||
val error: String? = null,
|
||||
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
|
||||
val addedAccountId: String? = null,
|
||||
) {
|
||||
val isValid: Boolean
|
||||
get() = email.isNotBlank() && password.isNotBlank() && imapHost.isNotBlank() && smtpHost.isNotBlank()
|
||||
@@ -73,7 +75,7 @@ class ManualSetupViewModel @Inject constructor(private val accountRepository: Ac
|
||||
viewModelScope.launch {
|
||||
_form.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
|
||||
accountRepository.addImapAccount(account, f.password).fold(
|
||||
onSuccess = { _form.update { it.copy(status = SetupStatus.DONE) } },
|
||||
onSuccess = { _form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) } },
|
||||
onFailure = { e ->
|
||||
_form.update {
|
||||
it.copy(
|
||||
|
||||
@@ -145,6 +145,7 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
label = { Text(stringResource(R.string.compose_to)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (state.suggestions.isNotEmpty()) {
|
||||
@@ -158,6 +159,16 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
label = { Text(stringResource(R.string.compose_cc)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = state.bcc,
|
||||
onValueChange = viewModel::onBccChange,
|
||||
label = { Text(stringResource(R.string.compose_bcc)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -166,6 +177,7 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
onValueChange = viewModel::onSubjectChange,
|
||||
label = { Text(stringResource(R.string.compose_subject)) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
AttachmentsSection(
|
||||
@@ -174,10 +186,11 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
onRemove = viewModel::removeAttachment,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = state.body,
|
||||
onValueChange = viewModel::onBodyChange,
|
||||
label = { Text(stringResource(R.string.compose_body)) },
|
||||
RichTextBodyField(
|
||||
body = state.body,
|
||||
bodyHtml = state.bodyHtml,
|
||||
onBodyChange = viewModel::onBodyChange,
|
||||
label = stringResource(R.string.compose_body),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,13 +18,17 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.contacts.ContactSuggestion
|
||||
import org.libremail.contacts.ContactsRepository
|
||||
import org.libremail.data.SignatureBlock
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.model.OutgoingAttachment
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -32,8 +36,12 @@ import javax.inject.Inject
|
||||
data class ComposeUiState(
|
||||
val to: String = "",
|
||||
val cc: String = "",
|
||||
val bcc: String = "",
|
||||
val subject: String = "",
|
||||
/** The plaintext form of the body (also the `text/plain` fallback when sending). */
|
||||
val body: String = "",
|
||||
/** The HTML form of the body, or null when the message carries no formatting (plaintext-only). */
|
||||
val bodyHtml: String? = null,
|
||||
val fromAccountId: String? = null,
|
||||
val attachments: List<OutgoingAttachment> = emptyList(),
|
||||
val suggestions: List<ContactSuggestion> = emptyList(),
|
||||
@@ -49,6 +57,7 @@ class ComposeViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val contactsRepository: ContactsRepository,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val signatureRepository: SignatureRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val draftId: String? =
|
||||
@@ -57,7 +66,10 @@ class ComposeViewModel @Inject constructor(
|
||||
private val _state = MutableStateFlow(
|
||||
ComposeUiState(
|
||||
to = savedStateHandle.get<String>(Routes.COMPOSE_ARG_TO).orEmpty(),
|
||||
cc = savedStateHandle.get<String>(Routes.COMPOSE_ARG_CC).orEmpty(),
|
||||
bcc = savedStateHandle.get<String>(Routes.COMPOSE_ARG_BCC).orEmpty(),
|
||||
subject = savedStateHandle.get<String>(Routes.COMPOSE_ARG_SUBJECT).orEmpty(),
|
||||
body = savedStateHandle.get<String>(Routes.COMPOSE_ARG_BODY).orEmpty(),
|
||||
fromAccountId = savedStateHandle.get<String>(Routes.COMPOSE_ARG_FROM)?.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
@@ -76,7 +88,7 @@ class ComposeViewModel @Inject constructor(
|
||||
@Volatile private var navigated = false
|
||||
|
||||
/** The signature block last appended to the body, so a From-change can swap it out cleanly. */
|
||||
private var appliedSignatureBlock = ""
|
||||
private var appliedSignatureBlock = SignatureBlock.EMPTY
|
||||
|
||||
init {
|
||||
if (draftId != null) {
|
||||
@@ -88,6 +100,7 @@ class ComposeViewModel @Inject constructor(
|
||||
cc = draft.cc,
|
||||
subject = draft.subject,
|
||||
body = draft.body,
|
||||
bodyHtml = draft.bodyHtml,
|
||||
fromAccountId = draft.accountId ?: it.fromAccountId,
|
||||
attachments = draft.attachments,
|
||||
)
|
||||
@@ -111,28 +124,65 @@ class ComposeViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun onCcChange(value: String) = _state.update { it.copy(cc = value) }
|
||||
fun onBccChange(value: String) = _state.update { it.copy(bcc = value) }
|
||||
fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) }
|
||||
fun onBodyChange(value: String) = _state.update { it.copy(body = value) }
|
||||
|
||||
/**
|
||||
* The rich editor reports the current body in both forms: [plain] (also the plaintext fallback)
|
||||
* and [html], which is null when the content carries no formatting so the message stays
|
||||
* plaintext-only. Both are held for sending and for saving the draft.
|
||||
*/
|
||||
fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) }
|
||||
|
||||
fun selectFrom(accountId: String) {
|
||||
viewModelScope.launch { applySignature(accountId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the sending account and swaps its signature into the body: strips the previously-appended
|
||||
* signature block (when the body still ends with it) and appends the newly-selected account's.
|
||||
* Sets the sending account and swaps its default signature into the body: strips the
|
||||
* previously-appended block (when the body still ends with it) and appends the newly-selected
|
||||
* account's, in both the plaintext and HTML representations. Honors the account's
|
||||
* "append signature" preference.
|
||||
*/
|
||||
private suspend fun applySignature(accountId: String) {
|
||||
val block = accountSettingsRepository.get(accountId).signatureBlock()
|
||||
val settings = accountSettingsRepository.get(accountId)
|
||||
val block = if (settings.signatureEnabled) {
|
||||
SignatureBlock.of(signatureRepository.getDefault(accountId))
|
||||
} else {
|
||||
SignatureBlock.EMPTY
|
||||
}
|
||||
_state.update { s ->
|
||||
val base = if (appliedSignatureBlock.isNotEmpty() && s.body.endsWith(appliedSignatureBlock)) {
|
||||
s.body.removeSuffix(appliedSignatureBlock)
|
||||
} else {
|
||||
s.body
|
||||
}
|
||||
s.copy(fromAccountId = accountId, body = base + block)
|
||||
val basePlain = s.body.stripSuffixIfPresent(appliedSignatureBlock.plain)
|
||||
val newBody = basePlain + block.plain
|
||||
s.copy(fromAccountId = accountId, body = newBody, bodyHtml = swapHtmlSignature(s.bodyHtml, newBody, block))
|
||||
}
|
||||
appliedSignatureBlock = block
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the signature in the HTML body. When the old block is still a clean suffix (the common
|
||||
* case — the user changed accounts before editing), it is stripped and the new one appended,
|
||||
* preserving any formatting the user applied. Otherwise the HTML was re-serialized after editing
|
||||
* and no longer ends with the old block, so it is rebuilt from the plaintext to avoid ever
|
||||
* duplicating the signature (inline styling from before the switch is not preserved in that case).
|
||||
*/
|
||||
private fun swapHtmlSignature(currentHtml: String?, newBody: String, block: SignatureBlock): String? {
|
||||
val old = appliedSignatureBlock.html
|
||||
val cleanlyStrippable = old.isEmpty() || currentHtml == null || currentHtml.endsWith(old)
|
||||
val combined = if (cleanlyStrippable) {
|
||||
(currentHtml?.removeSuffix(old) ?: "") + block.html
|
||||
} else {
|
||||
RichTextHtml.toHtml(RichTextContent(newBody))
|
||||
}
|
||||
return normalizedHtml(combined)
|
||||
}
|
||||
|
||||
private fun String.stripSuffixIfPresent(suffix: String): String =
|
||||
if (suffix.isNotEmpty() && endsWith(suffix)) removeSuffix(suffix) else this
|
||||
|
||||
/** Keeps an HTML body only when it actually carries formatting, so plaintext stays plaintext. */
|
||||
private fun normalizedHtml(html: String): String? =
|
||||
if (html.isBlank() || !RichTextHtml.fromHtml(html).hasFormatting()) null else html
|
||||
fun addAttachments(items: List<OutgoingAttachment>) =
|
||||
_state.update { it.copy(attachments = it.attachments + items) }
|
||||
fun removeAttachment(uri: String) = _state.update {
|
||||
@@ -187,6 +237,7 @@ class ComposeViewModel @Inject constructor(
|
||||
val s = _state.value
|
||||
val hasContent = s.to.isNotBlank() ||
|
||||
s.cc.isNotBlank() ||
|
||||
s.bcc.isNotBlank() ||
|
||||
s.subject.isNotBlank() ||
|
||||
s.body.isNotBlank() ||
|
||||
s.attachments.isNotEmpty()
|
||||
@@ -197,9 +248,11 @@ class ComposeViewModel @Inject constructor(
|
||||
accountId = s.fromAccountId,
|
||||
to = s.to,
|
||||
cc = s.cc,
|
||||
bcc = s.bcc,
|
||||
subject = s.subject,
|
||||
body = s.body,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
bodyHtml = s.bodyHtml,
|
||||
attachments = s.attachments,
|
||||
),
|
||||
)
|
||||
@@ -220,7 +273,16 @@ class ComposeViewModel @Inject constructor(
|
||||
else -> {
|
||||
_state.update { it.copy(sending = true, error = null) }
|
||||
mailRepository.sendMessage(
|
||||
OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body, s.attachments),
|
||||
OutgoingMessage(
|
||||
accountId = account.id,
|
||||
to = s.to,
|
||||
cc = s.cc,
|
||||
bcc = s.bcc,
|
||||
subject = s.subject,
|
||||
body = s.body,
|
||||
bodyHtml = s.bodyHtml,
|
||||
attachments = s.attachments,
|
||||
),
|
||||
).fold(
|
||||
onSuccess = {
|
||||
draftId?.let { mailRepository.deleteDraft(it) }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Turns an inbound Android intent into a [ComposePrefill], so tapping a `mailto:` link or sharing to
|
||||
* LibreMail as an email opens a pre-filled compose screen.
|
||||
*
|
||||
* Handles:
|
||||
* - `ACTION_VIEW` / `ACTION_SENDTO` with a `mailto:` URI (delegates to [MailtoParser]).
|
||||
* - `ACTION_SEND` / `ACTION_SEND_MULTIPLE` email shares, reading the standard `EXTRA_EMAIL`,
|
||||
* `EXTRA_CC`, `EXTRA_BCC`, `EXTRA_SUBJECT` and `EXTRA_TEXT` extras.
|
||||
*
|
||||
* Extras fill in only the fields the URI left blank, so a `mailto:` URI always takes precedence.
|
||||
* Returns `null` for anything that isn't a mail intent (e.g. the plain launcher intent), or when the
|
||||
* intent carried nothing to compose.
|
||||
*/
|
||||
object IntentComposeParser {
|
||||
|
||||
fun parse(intent: Intent?): ComposePrefill? {
|
||||
if (intent == null) return null
|
||||
val prefill = when (intent.action) {
|
||||
Intent.ACTION_VIEW, Intent.ACTION_SENDTO -> fromMailto(intent)
|
||||
Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> fromShare(intent)
|
||||
else -> null
|
||||
}
|
||||
return prefill?.takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
private fun fromMailto(intent: Intent): ComposePrefill? {
|
||||
val data = intent.dataString
|
||||
if (data == null || !data.startsWith(MAILTO, ignoreCase = true)) return null
|
||||
return MailtoParser.parse(data).fillBlanksFrom(intent)
|
||||
}
|
||||
|
||||
private fun fromShare(intent: Intent): ComposePrefill {
|
||||
// A share may (rarely) also carry a mailto: URI; honour it, then fill from the extras.
|
||||
val base = intent.dataString
|
||||
?.takeIf { it.startsWith(MAILTO, ignoreCase = true) }
|
||||
?.let { MailtoParser.parse(it) }
|
||||
?: ComposePrefill()
|
||||
return base.fillBlanksFrom(intent)
|
||||
}
|
||||
|
||||
/** Fills each empty field from the corresponding email intent extra, leaving set fields intact. */
|
||||
private fun ComposePrefill.fillBlanksFrom(intent: Intent): ComposePrefill = ComposePrefill(
|
||||
to = to.ifBlank { addressExtra(intent, Intent.EXTRA_EMAIL) },
|
||||
cc = cc.ifBlank { addressExtra(intent, Intent.EXTRA_CC) },
|
||||
bcc = bcc.ifBlank { addressExtra(intent, Intent.EXTRA_BCC) },
|
||||
subject = subject.ifBlank { intent.getStringExtra(Intent.EXTRA_SUBJECT).orEmpty() },
|
||||
body = body.ifBlank { intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString().orEmpty() },
|
||||
)
|
||||
|
||||
/** Reads an address extra that may be a `String[]` (the documented form) or a single `String`. */
|
||||
private fun addressExtra(intent: Intent, key: String): String {
|
||||
intent.getStringArrayExtra(key)?.let { array ->
|
||||
return array.filter { it.isNotBlank() }.joinToString(", ")
|
||||
}
|
||||
return intent.getStringExtra(key).orEmpty()
|
||||
}
|
||||
|
||||
private const val MAILTO = "mailto:"
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* The fields used to pre-fill the compose screen when it is opened from a `mailto:` link or a
|
||||
* "send email" share intent. [isEmpty] is true when nothing worth composing was supplied.
|
||||
*/
|
||||
data class ComposePrefill(
|
||||
val to: String = "",
|
||||
val cc: String = "",
|
||||
val bcc: String = "",
|
||||
val subject: String = "",
|
||||
val body: String = "",
|
||||
) {
|
||||
val isEmpty: Boolean
|
||||
get() = to.isBlank() && cc.isBlank() && bcc.isBlank() && subject.isBlank() && body.isBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a `mailto:` URI (RFC 6068) into a [ComposePrefill].
|
||||
*
|
||||
* Pure Kotlin — it deliberately avoids `android.net.Uri` (a no-op stub in JVM unit tests, and its
|
||||
* opaque-URI handling drops `mailto` query parameters anyway) so it is fully unit-testable. It
|
||||
* supports multiple recipients, the `to`/`cc`/`bcc`/`subject`/`body` header fields (case-insensitive
|
||||
* and mergeable with any address list before the `?`), and RFC 3986 percent-encoding.
|
||||
*
|
||||
* A literal `+` is preserved rather than decoded to a space: unlike `application/x-www-form-urlencoded`,
|
||||
* `mailto` encodes spaces as `%20`, so `+` is a real character and addresses like `user+tag@example.com`
|
||||
* survive intact.
|
||||
*/
|
||||
object MailtoParser {
|
||||
|
||||
private const val SCHEME = "mailto:"
|
||||
private const val ESCAPE_LENGTH = 3 // a '%' plus two hex digits
|
||||
private const val HEX_RADIX = 16
|
||||
|
||||
fun parse(uri: String): ComposePrefill {
|
||||
val afterScheme = stripScheme(uri.trim())
|
||||
val queryStart = afterScheme.indexOf('?')
|
||||
val toPart = if (queryStart >= 0) afterScheme.substring(0, queryStart) else afterScheme
|
||||
val query = if (queryStart >= 0) afterScheme.substring(queryStart + 1) else ""
|
||||
|
||||
val params = parseQuery(query)
|
||||
val to = addresses(toPart) + params["to"].orEmpty().flatMap(::addresses)
|
||||
return ComposePrefill(
|
||||
to = to.joinToString(", "),
|
||||
cc = params["cc"].orEmpty().flatMap(::addresses).joinToString(", "),
|
||||
bcc = params["bcc"].orEmpty().flatMap(::addresses).joinToString(", "),
|
||||
// First occurrence wins for these single-valued fields (RFC 6068 leaves duplicates undefined).
|
||||
subject = params["subject"]?.firstOrNull()?.let(::percentDecode).orEmpty(),
|
||||
body = params["body"]?.firstOrNull()?.let(::percentDecode).orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun stripScheme(value: String): String =
|
||||
if (value.regionMatches(0, SCHEME, 0, SCHEME.length, ignoreCase = true)) {
|
||||
value.substring(SCHEME.length)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
|
||||
/** Splits a `k=v&k=v` query into a map of lowercased field name to its raw (still-encoded) values. */
|
||||
private fun parseQuery(query: String): Map<String, List<String>> {
|
||||
if (query.isEmpty()) return emptyMap()
|
||||
val result = mutableMapOf<String, MutableList<String>>()
|
||||
for (pair in query.split('&')) {
|
||||
if (pair.isEmpty()) continue
|
||||
val eq = pair.indexOf('=')
|
||||
val name = if (eq >= 0) pair.substring(0, eq) else pair
|
||||
val rawValue = if (eq >= 0) pair.substring(eq + 1) else ""
|
||||
result.getOrPut(name.lowercase()) { mutableListOf() }.add(rawValue)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Splits a comma-separated, percent-encoded address list into decoded, non-blank addresses. */
|
||||
private fun addresses(part: String): List<String> = part.split(',')
|
||||
.map { percentDecode(it).trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Decodes RFC 3986 `%XX` escapes as UTF-8 bytes. Leaves `+` untouched (see class doc) and passes
|
||||
* any malformed escape (`%` not followed by two hex digits) through verbatim.
|
||||
*/
|
||||
private fun percentDecode(value: String): String {
|
||||
if ('%' !in value) return value
|
||||
val out = ByteArrayOutputStream(value.length)
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
val decoded = if (c == '%') decodeEscape(value, i) else null
|
||||
if (decoded != null) {
|
||||
out.write(decoded)
|
||||
i += ESCAPE_LENGTH
|
||||
} else {
|
||||
val bytes = c.toString().toByteArray(Charsets.UTF_8)
|
||||
out.write(bytes, 0, bytes.size)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out.toString(Charsets.UTF_8.name())
|
||||
}
|
||||
|
||||
/** The byte value of the two hex digits following the `%` at [start], or null if malformed. */
|
||||
private fun decodeEscape(value: String, start: Int): Int? {
|
||||
if (start + ESCAPE_LENGTH > value.length) return null
|
||||
return value.substring(start + 1, start + ESCAPE_LENGTH).toIntOrNull(HEX_RADIX)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.libremail.R
|
||||
import org.libremail.richtext.BlockMarker
|
||||
import org.libremail.richtext.RichLink
|
||||
import org.libremail.richtext.RichSpan
|
||||
import org.libremail.richtext.RichStyle
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextEditing
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
|
||||
/** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */
|
||||
private const val URL_TAG = "libremail:url"
|
||||
|
||||
/**
|
||||
* A rich-text body editor: a formatting toolbar (bold / italic / underline, bulleted + numbered
|
||||
* lists, block quote, and link) above a rounded [OutlinedTextField]. It converts its
|
||||
* [AnnotatedString] to the app's [RichTextContent] model and reports both the plaintext form and its
|
||||
* HTML — or null HTML when nothing is formatted, so an unformatted message stays plaintext-only and
|
||||
* feels exactly like the old editor.
|
||||
*
|
||||
* The field is a normal Compose text field, so TalkBack, text selection, and large system fonts all
|
||||
* work as usual; the toolbar buttons carry content descriptions and toggle state for accessibility.
|
||||
*/
|
||||
@Composable
|
||||
fun RichTextBodyField(
|
||||
body: String,
|
||||
bodyHtml: String?,
|
||||
onBodyChange: (plain: String, html: String?) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val linkColor = MaterialTheme.colorScheme.primary
|
||||
var value by remember { mutableStateOf(seedValue(body, bodyHtml, linkColor)) }
|
||||
// Tracks the (plain, html) we last pushed up, so an external change (draft load / signature swap)
|
||||
// re-seeds the field but our own emissions do not fight the user's cursor.
|
||||
var lastEmitted by remember { mutableStateOf(body to bodyHtml) }
|
||||
|
||||
if (body to bodyHtml != lastEmitted) {
|
||||
value = seedValue(body, bodyHtml, linkColor)
|
||||
lastEmitted = body to bodyHtml
|
||||
}
|
||||
|
||||
fun emit(newValue: TextFieldValue) {
|
||||
value = newValue
|
||||
val content = newValue.annotatedString.toRichContent()
|
||||
val html = if (content.hasFormatting()) RichTextHtml.toHtml(content) else null
|
||||
lastEmitted = content.text to html
|
||||
onBodyChange(content.text, html)
|
||||
}
|
||||
|
||||
var showLinkDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier) {
|
||||
FormattingToolbar(
|
||||
value = value,
|
||||
onToggleStyle = { style -> emit(applyStyle(value, style, linkColor)) },
|
||||
onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor)) },
|
||||
onLink = { showLinkDialog = true },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = ::emit,
|
||||
label = { Text(label) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
if (showLinkDialog) {
|
||||
val hasSelection = value.selection.min < value.selection.max
|
||||
LinkDialog(
|
||||
enabled = hasSelection,
|
||||
onDismiss = { showLinkDialog = false },
|
||||
onConfirm = { url ->
|
||||
emit(applyLink(value, url, linkColor))
|
||||
showLinkDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormattingToolbar(
|
||||
value: TextFieldValue,
|
||||
onToggleStyle: (RichStyle) -> Unit,
|
||||
onToggleBlock: (BlockMarker) -> Unit,
|
||||
onLink: () -> Unit,
|
||||
) {
|
||||
val content = value.annotatedString.toRichContent()
|
||||
val start = value.selection.min
|
||||
val end = value.selection.max
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FormatButton(
|
||||
label = "B",
|
||||
description = stringResource(R.string.format_bold),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.BOLD),
|
||||
fontWeight = FontWeight.Bold,
|
||||
onClick = { onToggleStyle(RichStyle.BOLD) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "I",
|
||||
description = stringResource(R.string.format_italic),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.ITALIC),
|
||||
fontStyle = FontStyle.Italic,
|
||||
onClick = { onToggleStyle(RichStyle.ITALIC) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "U",
|
||||
description = stringResource(R.string.format_underline),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.UNDERLINE),
|
||||
underline = true,
|
||||
onClick = { onToggleStyle(RichStyle.UNDERLINE) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "•",
|
||||
description = stringResource(R.string.format_bullet_list),
|
||||
active = RichTextEditing.hasBlock(content, start, end, BlockMarker.BULLET),
|
||||
onClick = { onToggleBlock(BlockMarker.BULLET) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "1.",
|
||||
description = stringResource(R.string.format_numbered_list),
|
||||
active = RichTextEditing.hasBlock(content, start, end, BlockMarker.ORDERED),
|
||||
onClick = { onToggleBlock(BlockMarker.ORDERED) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "❝",
|
||||
description = stringResource(R.string.format_quote),
|
||||
active = RichTextEditing.hasBlock(content, start, end, BlockMarker.QUOTE),
|
||||
onClick = { onToggleBlock(BlockMarker.QUOTE) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "🔗",
|
||||
description = stringResource(R.string.format_link),
|
||||
active = false,
|
||||
onClick = onLink,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormatButton(
|
||||
label: String,
|
||||
description: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontStyle: FontStyle? = null,
|
||||
underline: Boolean = false,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val background = if (active) colors.secondaryContainer else Color.Transparent
|
||||
val textColor = if (active) colors.onSecondaryContainer else colors.onSurfaceVariant
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(background)
|
||||
.clickable(onClick = onClick, role = Role.Button, onClickLabel = description)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = textColor,
|
||||
style = LocalTextStyle.current.copy(
|
||||
fontWeight = fontWeight,
|
||||
fontStyle = fontStyle,
|
||||
textDecoration = if (underline) TextDecoration.Underline else null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkDialog(enabled: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.format_link_title)) },
|
||||
text = {
|
||||
if (enabled) {
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.format_link_url)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.format_link_needs_selection))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(url.trim()) }, enabled = enabled && url.isNotBlank()) {
|
||||
Text(stringResource(R.string.format_link_apply))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
// --- editor-op plumbing (TextFieldValue <-> RichTextContent) ---
|
||||
|
||||
private fun applyStyle(value: TextFieldValue, style: RichStyle, linkColor: Color): TextFieldValue {
|
||||
val updated = RichTextEditing.toggleStyle(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
value.selection.max,
|
||||
style,
|
||||
)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
|
||||
}
|
||||
|
||||
private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Color): TextFieldValue {
|
||||
val result = RichTextEditing.toggleBlock(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
value.selection.max,
|
||||
marker,
|
||||
)
|
||||
return TextFieldValue(
|
||||
result.content.toAnnotatedString(linkColor),
|
||||
TextRange(result.selectionStart, result.selectionEnd),
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyLink(value: TextFieldValue, url: String, linkColor: Color): TextFieldValue {
|
||||
val updated = RichTextEditing.applyLink(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
value.selection.max,
|
||||
url,
|
||||
)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
|
||||
}
|
||||
|
||||
private fun seedValue(body: String, bodyHtml: String?, linkColor: Color): TextFieldValue {
|
||||
val content = if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body)
|
||||
val annotated = content.toAnnotatedString(linkColor)
|
||||
return TextFieldValue(annotated, TextRange(annotated.length))
|
||||
}
|
||||
|
||||
/** Maps the app rich-text model onto a Compose [AnnotatedString] for display/editing. */
|
||||
internal fun RichTextContent.toAnnotatedString(linkColor: Color): AnnotatedString = buildAnnotatedString {
|
||||
append(text)
|
||||
spans.forEach { span -> addStyle(spanStyleFor(span.style), span.start, span.end) }
|
||||
links.forEach { link ->
|
||||
addStyle(SpanStyle(color = linkColor), link.start, link.end)
|
||||
addStringAnnotation(URL_TAG, link.url, link.start, link.end)
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a Compose [AnnotatedString] back to the app model, reading single-attribute span styles. */
|
||||
internal fun AnnotatedString.toRichContent(): RichTextContent {
|
||||
val richSpans = spanStyles.mapNotNull { range ->
|
||||
styleOf(range.item)?.let { RichSpan(range.start, range.end, it) }
|
||||
}
|
||||
val links = getStringAnnotations(URL_TAG, 0, length).map { RichLink(it.start, it.end, it.item) }
|
||||
return RichTextContent(text, richSpans, links)
|
||||
}
|
||||
|
||||
private fun spanStyleFor(style: RichStyle): SpanStyle = when (style) {
|
||||
RichStyle.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
RichStyle.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
RichStyle.UNDERLINE -> SpanStyle(textDecoration = TextDecoration.Underline)
|
||||
}
|
||||
|
||||
private fun styleOf(span: SpanStyle): RichStyle? = when {
|
||||
span.fontWeight == FontWeight.Bold -> RichStyle.BOLD
|
||||
span.fontStyle == FontStyle.Italic -> RichStyle.ITALIC
|
||||
span.textDecoration == TextDecoration.Underline -> RichStyle.UNDERLINE
|
||||
else -> null // e.g. the link color span, which is carried by the URL annotation instead
|
||||
}
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -86,6 +85,7 @@ import org.libremail.domain.model.Folder
|
||||
import org.libremail.domain.model.FolderRole
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.ui.onboarding.WelcomeContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -240,7 +240,9 @@ fun MailboxScreen(
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
if (!hasAccounts) {
|
||||
NoAccountState(onAddAccount = onAddAccount)
|
||||
// Onboarding covers the fresh-install empty case; this is the runtime fallback
|
||||
// (e.g. the last account was removed). Reuses the same welcome invitation.
|
||||
WelcomeContent(onAddAccount = onAddAccount, modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
val accountsById = remember(accounts) { accounts.associateBy { it.id } }
|
||||
val showAccount = selectedAccountId == null && accounts.size >= 2
|
||||
@@ -686,34 +688,6 @@ private fun MoveFolderDialog(folders: List<Folder>, onSelect: (Folder) -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoAccountState(onAddAccount: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Email,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(stringResource(R.string.mailbox_welcome_title), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.mailbox_welcome_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(onClick = onAddAccount) {
|
||||
Text(stringResource(R.string.settings_add_account))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoMessagesState(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.mailbox
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -29,6 +30,7 @@ import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import javax.inject.Inject
|
||||
|
||||
const val INBOX = "INBOX"
|
||||
@@ -42,8 +44,14 @@ class MailboxViewModel @Inject constructor(
|
||||
private val mailRepository: MailRepository,
|
||||
accountRepository: AccountRepository,
|
||||
private val mailSyncer: Syncer,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
// Optional "open filtered to this account" arg — set when onboarding lands the user on the
|
||||
// first account they added, so the mailbox opens that account's inbox rather than the unified view.
|
||||
private val initialAccountId: String? =
|
||||
savedStateHandle.get<String>(Routes.MAILBOX_ARG_ACCOUNT)?.takeIf { it.isNotBlank() }
|
||||
|
||||
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
@@ -52,7 +60,7 @@ class MailboxViewModel @Inject constructor(
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
/** null = unified "All inboxes"; otherwise the account whose mail is shown. */
|
||||
private val _selectedAccountId = MutableStateFlow<String?>(null)
|
||||
private val _selectedAccountId = MutableStateFlow(initialAccountId)
|
||||
val selectedAccountId: StateFlow<String?> = _selectedAccountId.asStateFlow()
|
||||
|
||||
/** The folder whose mail is shown (always a concrete folder; defaults to the inbox). */
|
||||
@@ -60,7 +68,7 @@ class MailboxViewModel @Inject constructor(
|
||||
val selectedFolder: StateFlow<String> = _selectedFolder.asStateFlow()
|
||||
|
||||
/** Which account's folders the drawer lists. null follows the mailbox selection / first account. */
|
||||
private val explicitDrawerAccountId = MutableStateFlow<String?>(null)
|
||||
private val explicitDrawerAccountId = MutableStateFlow(initialAccountId)
|
||||
|
||||
/** The account the drawer is browsing: explicit drawer pick, else the filtered account, else the first. */
|
||||
val drawerAccount: StateFlow<Account?> =
|
||||
@@ -241,11 +249,13 @@ class MailboxViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
// Fall back to the unified inbox if the filtered account is removed.
|
||||
// Fall back to the unified inbox if the filtered account is removed. The list.isNotEmpty()
|
||||
// guard avoids clobbering a seeded account filter during the initial empty emission (before
|
||||
// the account list first loads from the database).
|
||||
viewModelScope.launch {
|
||||
accounts.collect { list ->
|
||||
val selected = _selectedAccountId.value
|
||||
if (selected != null && list.none { it.id == selected }) {
|
||||
if (selected != null && list.isNotEmpty() && list.none { it.id == selected }) {
|
||||
_selectedAccountId.value = null
|
||||
_selectedFolder.value = INBOX
|
||||
}
|
||||
|
||||
@@ -11,6 +11,38 @@ object Routes {
|
||||
const val MANUAL_SETUP = "manual_setup"
|
||||
const val DRAFTS = "drafts"
|
||||
const val OUTBOX = "outbox"
|
||||
const val PROBLEM_REPORTS = "problem_reports"
|
||||
|
||||
// The mailbox can be opened filtered to a single account (used when onboarding lands the user on
|
||||
// the first account they added). The bare MAILBOX route resolves here with an empty account arg,
|
||||
// so it stays a valid start destination and bottom-tab target.
|
||||
const val MAILBOX_ARG_ACCOUNT = "account"
|
||||
const val MAILBOX_PATTERN = "mailbox?account={$MAILBOX_ARG_ACCOUNT}"
|
||||
fun mailboxForAccount(accountId: String) = "mailbox?account=${Uri.encode(accountId)}"
|
||||
|
||||
// App-password guided setup, parameterized by provider key (see MailProvider). Reused by both
|
||||
// onboarding and the standalone "Add account" entry.
|
||||
const val APP_PASSWORD_ARG_PROVIDER = "provider"
|
||||
const val APP_PASSWORD_PATTERN = "app_password/{$APP_PASSWORD_ARG_PROVIDER}"
|
||||
fun appPassword(provider: String) = "app_password/${Uri.encode(provider)}"
|
||||
|
||||
// Onboarding first-run flow (nested graph). ONBOARDING is the graph route; the rest are its
|
||||
// destinations. The graph owns the "first account added this session" state via a graph-scoped
|
||||
// ViewModel, so the picker/setup screens are registered inside it for onboarding and reused as
|
||||
// the top-level ACCOUNT_SETUP / APP_PASSWORD / MANUAL_SETUP routes for "Add account" later.
|
||||
const val ONBOARDING = "onboarding"
|
||||
const val ONBOARDING_WELCOME = "onboarding/welcome"
|
||||
const val ONBOARDING_PICKER = "onboarding/picker"
|
||||
const val ONBOARDING_MANUAL = "onboarding/manual"
|
||||
const val ONBOARDING_ADD_ANOTHER = "onboarding/add_another"
|
||||
|
||||
// Optional final onboarding step: invites the user to allow unrestricted background/battery usage
|
||||
// so push (IMAP IDLE) and periodic sync aren't throttled by Doze (#49). Shown only when the app
|
||||
// isn't already exempt and the user hasn't handled it before; otherwise onboarding skips straight
|
||||
// to the inbox.
|
||||
const val ONBOARDING_BATTERY = "onboarding/battery"
|
||||
const val ONBOARDING_APP_PASSWORD_PATTERN = "onboarding/app_password/{$APP_PASSWORD_ARG_PROVIDER}"
|
||||
fun onboardingAppPassword(provider: String) = "onboarding/app_password/${Uri.encode(provider)}"
|
||||
|
||||
const val READER_ARG_ID = "messageId"
|
||||
const val READER_PATTERN = "reader/{$READER_ARG_ID}"
|
||||
@@ -20,13 +52,47 @@ object Routes {
|
||||
const val ACCOUNT_SETTINGS_PATTERN = "account_settings/{$ACCOUNT_SETTINGS_ARG_ID}"
|
||||
fun accountSettings(accountId: String) = "account_settings/${Uri.encode(accountId)}"
|
||||
|
||||
const val SIGNATURES_ARG_ACCOUNT = "accountId"
|
||||
const val SIGNATURES_PATTERN = "signatures/{$SIGNATURES_ARG_ACCOUNT}"
|
||||
fun signatures(accountId: String) = "signatures/${Uri.encode(accountId)}"
|
||||
|
||||
const val SIGNATURE_EDIT_ARG_ACCOUNT = "accountId"
|
||||
const val SIGNATURE_EDIT_ARG_ID = "signatureId"
|
||||
const val SIGNATURE_EDIT_PATTERN =
|
||||
"signature_edit/{$SIGNATURE_EDIT_ARG_ACCOUNT}?$SIGNATURE_EDIT_ARG_ID={$SIGNATURE_EDIT_ARG_ID}"
|
||||
fun signatureEdit(accountId: String, signatureId: String = ""): String =
|
||||
"signature_edit/${Uri.encode(accountId)}?$SIGNATURE_EDIT_ARG_ID=${Uri.encode(signatureId)}"
|
||||
|
||||
const val REPORT_REVIEW_ARG_ID = "reportId"
|
||||
const val REPORT_REVIEW_PATTERN = "report_review/{$REPORT_REVIEW_ARG_ID}"
|
||||
fun reportReview(reportId: String) = "report_review/${Uri.encode(reportId)}"
|
||||
|
||||
const val COMPOSE_ARG_TO = "to"
|
||||
const val COMPOSE_ARG_CC = "cc"
|
||||
const val COMPOSE_ARG_BCC = "bcc"
|
||||
const val COMPOSE_ARG_SUBJECT = "subject"
|
||||
const val COMPOSE_ARG_BODY = "body"
|
||||
const val COMPOSE_ARG_FROM = "from"
|
||||
const val COMPOSE_ARG_DRAFT = "draft"
|
||||
const val COMPOSE_PATTERN =
|
||||
"compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}&from={$COMPOSE_ARG_FROM}&draft={$COMPOSE_ARG_DRAFT}"
|
||||
fun compose(to: String = "", subject: String = "", from: String = ""): String =
|
||||
"compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}&from=${Uri.encode(from)}"
|
||||
"compose?to={$COMPOSE_ARG_TO}&cc={$COMPOSE_ARG_CC}&bcc={$COMPOSE_ARG_BCC}" +
|
||||
"&subject={$COMPOSE_ARG_SUBJECT}&body={$COMPOSE_ARG_BODY}" +
|
||||
"&from={$COMPOSE_ARG_FROM}&draft={$COMPOSE_ARG_DRAFT}"
|
||||
|
||||
/**
|
||||
* Builds a compose route. Every field is URL-encoded so recipients, subjects and bodies that
|
||||
* contain `&`, `=`, spaces or newlines (e.g. from a `mailto:` link) round-trip through the
|
||||
* NavHost into the compose form intact.
|
||||
*/
|
||||
fun compose(
|
||||
to: String = "",
|
||||
subject: String = "",
|
||||
from: String = "",
|
||||
cc: String = "",
|
||||
bcc: String = "",
|
||||
body: String = "",
|
||||
): String = "compose?to=${Uri.encode(to)}&cc=${Uri.encode(cc)}&bcc=${Uri.encode(bcc)}" +
|
||||
"&subject=${Uri.encode(subject)}&body=${Uri.encode(body)}&from=${Uri.encode(from)}"
|
||||
|
||||
fun composeDraft(draftId: String): String = "compose?draft=${Uri.encode(draftId)}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.libremail.R
|
||||
|
||||
/**
|
||||
* Shown after an account is added *during onboarding*: confirms the add and asks whether to add
|
||||
* another. Yes returns to the vendor picker; No finishes onboarding and opens the first account's
|
||||
* inbox. Only part of the onboarding flow — adding an account from Settings later skips this prompt.
|
||||
*/
|
||||
@Composable
|
||||
fun AddAnotherAccountScreen(onAddAnother: () -> Unit, onFinish: () -> Unit) {
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_account_added_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_add_another_prompt),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = onAddAnother,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 360.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_add_another_yes))
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = onFinish,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 360.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_add_another_no))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
|
||||
/**
|
||||
* Final onboarding step (shown only when needed, see [OnboardingViewModel.batteryPromptNeeded]):
|
||||
* invites the user to allow unrestricted background/battery usage so push and periodic sync aren't
|
||||
* throttled by Doze. **Take me there** deep-links to the system screen (no restricted permission);
|
||||
* **Not now** skips. Either way [onFinish] proceeds to the inbox. On returning from Settings the
|
||||
* status is re-read and, if the app is now unrestricted, the screen reflects that with a "done" state.
|
||||
*
|
||||
* @param viewModel the graph-scoped onboarding view model (holds live battery status + the flag).
|
||||
* @param onFinish leaves onboarding for the inbox; the caller also marks the prompt handled.
|
||||
*/
|
||||
@Composable
|
||||
fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Unit) {
|
||||
val unrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Re-check on every resume so returning from the system settings screen reflects the new state.
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() }
|
||||
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (unrestricted) Icons.Filled.CheckCircle else Icons.Filled.Notifications,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (unrestricted) R.string.onboarding_battery_done_title else R.string.onboarding_battery_title,
|
||||
),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (unrestricted) R.string.onboarding_battery_done_body else R.string.onboarding_battery_body,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
if (unrestricted) {
|
||||
Button(
|
||||
onClick = onFinish,
|
||||
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_battery_continue))
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_battery_guidance),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
// Mark handled up front: the user is leaving for Settings and might not return
|
||||
// to this screen. Launching app-details always resolves; guard defensively.
|
||||
viewModel.markBatteryPromptHandled()
|
||||
runCatching { context.startActivity(viewModel.batterySettingsIntent()) }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_battery_take_me))
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = onFinish,
|
||||
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_battery_not_now))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.push.BatteryOptimizationManager
|
||||
import org.libremail.push.BatteryPromptDecision
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Session state for one run of the onboarding flow. Scoped to the onboarding nav graph's back-stack
|
||||
* entry, so it is created when onboarding starts and cleared when the graph is popped.
|
||||
*
|
||||
* It remembers the **first** account added this session (so finishing opens that account's inbox, see
|
||||
* #30) and decides whether to show the "unrestricted battery" opt-in step before finishing (see #49).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class OnboardingViewModel @Inject constructor(
|
||||
private val batteryOptimizationManager: BatteryOptimizationManager,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The id of the first account added this session, or null if none has been added yet. */
|
||||
var firstAddedAccountId: String? = null
|
||||
private set
|
||||
|
||||
private val _batteryPromptNeeded = MutableStateFlow<Boolean?>(null)
|
||||
|
||||
/**
|
||||
* Whether onboarding should show the battery opt-in step before finishing. `null` until decided;
|
||||
* the finish path treats `null` as "skip", so a slow read can never block the end of onboarding.
|
||||
* Decided once at graph start — neither input can change until the user reaches the step itself.
|
||||
*/
|
||||
val batteryPromptNeeded: StateFlow<Boolean?> = _batteryPromptNeeded.asStateFlow()
|
||||
|
||||
private val _batteryUnrestricted = MutableStateFlow(false)
|
||||
|
||||
/** Live "Unrestricted" status, re-read when the opt-in step resumes (e.g. back from Settings). */
|
||||
val batteryUnrestricted: StateFlow<Boolean> = _batteryUnrestricted.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val unrestricted = batteryOptimizationManager.isIgnoringBatteryOptimizations()
|
||||
_batteryUnrestricted.value = unrestricted
|
||||
_batteryPromptNeeded.value = BatteryPromptDecision.shouldPrompt(
|
||||
supported = batteryOptimizationManager.isSupported,
|
||||
alreadyUnrestricted = unrestricted,
|
||||
alreadyHandled = settingsRepository.isBatteryPromptHandled(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a freshly added account. Only the first one sticks — later adds don't overwrite it. */
|
||||
fun onAccountAdded(accountId: String) {
|
||||
if (firstAddedAccountId == null) {
|
||||
firstAddedAccountId = accountId
|
||||
}
|
||||
}
|
||||
|
||||
/** Intent to the system screen where the user flips this app to "Unrestricted". */
|
||||
fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent()
|
||||
|
||||
/** Re-read the live battery status; call when the opt-in step resumes. */
|
||||
fun refreshBatteryStatus() {
|
||||
viewModelScope.launch {
|
||||
_batteryUnrestricted.value = batteryOptimizationManager.isIgnoringBatteryOptimizations()
|
||||
}
|
||||
}
|
||||
|
||||
/** Record that the user has seen/acted on the battery opt-in so onboarding won't ask again. */
|
||||
fun markBatteryPromptHandled() {
|
||||
viewModelScope.launch { settingsRepository.setBatteryPromptHandled(true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.libremail.R
|
||||
|
||||
/**
|
||||
* First-run welcome. Invites the user to connect their first mailbox and hands off to the vendor
|
||||
* picker. Shown as the onboarding start destination when the app launches with no accounts.
|
||||
*/
|
||||
@Composable
|
||||
fun OnboardingWelcomeScreen(onAddAccount: () -> Unit) {
|
||||
Scaffold { padding ->
|
||||
WelcomeContent(
|
||||
onAddAccount = onAddAccount,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The welcome body: a headline, a short subtitle, and the "Add account" call to action. Extracted so
|
||||
* the mailbox's empty state (when the last account is removed) reuses the exact same invitation
|
||||
* instead of a separate blank-inbox screen.
|
||||
*/
|
||||
@Composable
|
||||
fun WelcomeContent(onAddAccount: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Email,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_welcome_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_welcome_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = onAddAccount,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 360.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_add_account))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reporting
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
import org.libremail.reporting.ReportKind
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProblemReportsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenReport: (String) -> Unit,
|
||||
viewModel: ProblemReportsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val reports by viewModel.reports.collectAsStateWithLifecycle()
|
||||
|
||||
// A newly created manual report opens straight into review.
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.created.collect { onOpenReport(it) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.reports_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
Button(
|
||||
onClick = viewModel::createManualReport,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.reports_create))
|
||||
}
|
||||
if (reports.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringResource(R.string.reports_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(reports, key = { it.id }) { report ->
|
||||
ReportRow(report = report, onClick = { onOpenReport(report.id) })
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReportRow(report: ReportSummary, onClick: () -> Unit) {
|
||||
val kindLabel = when (report.kind) {
|
||||
ReportKind.CRASH -> stringResource(R.string.report_kind_crash)
|
||||
ReportKind.MANUAL -> stringResource(R.string.report_kind_manual)
|
||||
}
|
||||
val timestamp = remember(report.createdAtMillis) {
|
||||
DateFormat.getDateTimeInstance().format(Date(report.createdAtMillis))
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(kindLabel, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reporting
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.reporting.DebugReport
|
||||
import org.libremail.reporting.DiagnosticsCollector
|
||||
import org.libremail.reporting.ReportKind
|
||||
import org.libremail.reporting.ReportStore
|
||||
import javax.inject.Inject
|
||||
|
||||
/** A row in the problem-reports list. */
|
||||
data class ReportSummary(val id: String, val kind: ReportKind, val createdAtMillis: Long)
|
||||
|
||||
@HiltViewModel
|
||||
class ProblemReportsViewModel @Inject constructor(
|
||||
private val store: ReportStore,
|
||||
private val collector: DiagnosticsCollector,
|
||||
) : ViewModel() {
|
||||
|
||||
val reports: StateFlow<List<ReportSummary>> = store.reports
|
||||
.map { list -> list.map { it.toSummary() } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), emptyList())
|
||||
|
||||
// Emits the id of a freshly created report so the screen can open it for review immediately.
|
||||
private val _created = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val created: SharedFlow<String> = _created
|
||||
|
||||
fun createManualReport() {
|
||||
viewModelScope.launch {
|
||||
val report = collector.collectManual()
|
||||
store.save(report)
|
||||
_created.tryEmit(report.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun discard(id: String) {
|
||||
viewModelScope.launch { store.delete(id) }
|
||||
}
|
||||
|
||||
private fun DebugReport.toSummary() = ReportSummary(id, kind, createdAtMillis)
|
||||
|
||||
private companion object {
|
||||
const val SUBSCRIBE_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reporting
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremail.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val savedMessage = stringResource(R.string.report_saved)
|
||||
val copiedMessage = stringResource(R.string.report_copied)
|
||||
|
||||
val saveLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val text = viewModel.payload()
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.contentResolver.openOutputStream(uri)?.use {
|
||||
it.write(text.toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
snackbarHostState.showSnackbar(savedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Once the report has been submitted (deleted by the worker) or discarded, leave the screen.
|
||||
LaunchedEffect(state.loaded, state.exists) {
|
||||
if (state.loaded && !state.exists) onDone()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.report_review_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDone) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
PiiDisclaimer()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = state.comment,
|
||||
onValueChange = viewModel::updateComment,
|
||||
label = { Text(stringResource(R.string.report_comment_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(R.string.report_payload_label),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PayloadBox(payload = state.payload)
|
||||
SubmitStatusText(state.submit)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = viewModel::submit,
|
||||
enabled = state.submit != SubmitUiState.SUBMITTING,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.report_submit))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(viewModel.payload()))
|
||||
scope.launch { snackbarHostState.showSnackbar(copiedMessage) }
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(R.string.report_copy))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(
|
||||
onClick = { saveLauncher.launch("libremail-report.json") },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(R.string.report_save))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(
|
||||
onClick = viewModel::discard,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_discard),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PiiDisclaimer() {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(16.dp)) {
|
||||
Icon(Icons.Filled.Warning, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.report_pii_disclaimer_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.report_pii_disclaimer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PayloadBox(payload: String) {
|
||||
SelectionContainer {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = payload,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubmitStatusText(state: SubmitUiState) {
|
||||
if (state == SubmitUiState.IDLE) return
|
||||
val text = when (state) {
|
||||
SubmitUiState.SUBMITTING -> stringResource(R.string.report_submitting)
|
||||
SubmitUiState.SUCCEEDED -> stringResource(R.string.report_submitted)
|
||||
SubmitUiState.FAILED -> stringResource(R.string.report_submit_failed)
|
||||
SubmitUiState.UNAVAILABLE -> stringResource(R.string.report_submit_unavailable)
|
||||
SubmitUiState.IDLE -> ""
|
||||
}
|
||||
val color = when (state) {
|
||||
SubmitUiState.SUCCEEDED -> MaterialTheme.colorScheme.primary
|
||||
SubmitUiState.FAILED, SubmitUiState.UNAVAILABLE -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(text, color = color, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reporting
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.reporting.ReportStore
|
||||
import org.libremail.reporting.ReportSubmitter
|
||||
import org.libremail.reporting.SubmitStatus
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import javax.inject.Inject
|
||||
|
||||
/** UI-facing status of a submission attempt. [UNAVAILABLE] means no endpoint is configured. */
|
||||
enum class SubmitUiState { IDLE, SUBMITTING, SUCCEEDED, FAILED, UNAVAILABLE }
|
||||
|
||||
data class ReportReviewState(
|
||||
val loaded: Boolean = false,
|
||||
val exists: Boolean = false,
|
||||
val payload: String = "",
|
||||
val comment: String = "",
|
||||
val canSubmitOnline: Boolean = false,
|
||||
val submit: SubmitUiState = SubmitUiState.IDLE,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ReportReviewViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val store: ReportStore,
|
||||
private val submitter: ReportSubmitter,
|
||||
) : ViewModel() {
|
||||
|
||||
private val reportId: String = checkNotNull(savedStateHandle[Routes.REPORT_REVIEW_ARG_ID])
|
||||
private val comment = MutableStateFlow(store.find(reportId)?.userComment.orEmpty())
|
||||
private val submitState = MutableStateFlow(SubmitUiState.IDLE)
|
||||
|
||||
val state: StateFlow<ReportReviewState> =
|
||||
combine(store.reports, comment, submitState) { reports, currentComment, submit ->
|
||||
val report = reports.firstOrNull { it.id == reportId }
|
||||
ReportReviewState(
|
||||
loaded = true,
|
||||
exists = report != null,
|
||||
// The comment is folded in so the preview is byte-for-byte what a submit would send.
|
||||
payload = report?.copy(userComment = currentComment)?.toSubmissionPayload().orEmpty(),
|
||||
comment = currentComment,
|
||||
canSubmitOnline = submitter.isEnabled,
|
||||
submit = submit,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), ReportReviewState())
|
||||
|
||||
fun updateComment(value: String) {
|
||||
comment.value = value
|
||||
}
|
||||
|
||||
fun discard() {
|
||||
viewModelScope.launch { store.delete(reportId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The only path that can send a report off-device, and only from an explicit Submit tap. Persists
|
||||
* the reviewed comment first so the upload matches exactly what was shown, then enqueues the
|
||||
* worker (unless no endpoint is configured, in which case it steers the user to Copy/Save).
|
||||
*/
|
||||
fun submit() {
|
||||
viewModelScope.launch {
|
||||
val report = store.find(reportId) ?: return@launch
|
||||
store.save(report.copy(userComment = comment.value))
|
||||
if (!submitter.isEnabled) {
|
||||
submitState.value = SubmitUiState.UNAVAILABLE
|
||||
return@launch
|
||||
}
|
||||
submitter.submit(reportId)
|
||||
submitState.value = SubmitUiState.SUBMITTING
|
||||
submitter.status(reportId).collect { submitState.value = it.toUi() }
|
||||
}
|
||||
}
|
||||
|
||||
/** The exact text shown for review — used for Copy and Save-to-file. */
|
||||
fun payload(): String = store.find(reportId)?.copy(userComment = comment.value)?.toSubmissionPayload().orEmpty()
|
||||
|
||||
private fun SubmitStatus.toUi(): SubmitUiState = when (this) {
|
||||
SubmitStatus.IDLE, SubmitStatus.SUBMITTING -> SubmitUiState.SUBMITTING
|
||||
SubmitStatus.SUCCEEDED -> SubmitUiState.SUCCEEDED
|
||||
SubmitStatus.FAILED -> SubmitUiState.FAILED
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SUBSCRIBE_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reporting
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.reporting.ReportKind
|
||||
import org.libremail.reporting.ReportStore
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Surfaces a pending crash report (if any) so the app can offer it for review on launch. */
|
||||
@HiltViewModel
|
||||
class StartupReportViewModel @Inject constructor(private val store: ReportStore) : ViewModel() {
|
||||
|
||||
private val dismissed = MutableStateFlow(false)
|
||||
|
||||
val pendingCrash: StateFlow<ReportSummary?> =
|
||||
combine(store.reports, dismissed) { reports, isDismissed ->
|
||||
if (isDismissed) {
|
||||
null
|
||||
} else {
|
||||
reports.firstOrNull { it.kind == ReportKind.CRASH }
|
||||
?.let { ReportSummary(it.id, it.kind, it.createdAtMillis) }
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), null)
|
||||
|
||||
/** Hides the prompt for this launch; the report stays saved and is offered again next launch. */
|
||||
fun dismiss() {
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
fun discard(id: String) {
|
||||
dismissed.value = true
|
||||
viewModelScope.launch { store.delete(id) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SUBSCRIBE_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -17,7 +16,6 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -26,17 +24,21 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewModel = hiltViewModel()) {
|
||||
fun AccountSettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onManageSignatures: () -> Unit,
|
||||
viewModel: AccountSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val account by viewModel.account.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val signature by viewModel.signature.collectAsStateWithLifecycle()
|
||||
val signatureCount by viewModel.signatureCount.collectAsStateWithLifecycle()
|
||||
val defaultSignatureName by viewModel.defaultSignatureName.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val fallbackTitle = stringResource(R.string.settings_account_title)
|
||||
|
||||
@@ -67,15 +69,14 @@ fun AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewMode
|
||||
checked = settings.signatureEnabled,
|
||||
onCheckedChange = viewModel::setSignatureEnabled,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = signature ?: "",
|
||||
onValueChange = viewModel::onSignatureChange,
|
||||
enabled = settings.signatureEnabled,
|
||||
label = { Text(stringResource(R.string.settings_signature_hint)) },
|
||||
minLines = 3,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
ClickRow(
|
||||
title = stringResource(R.string.settings_signatures_manage),
|
||||
subtitle = if (signatureCount == 0) {
|
||||
stringResource(R.string.settings_signatures_summary_none)
|
||||
} else {
|
||||
stringResource(R.string.settings_signatures_summary, signatureCount, defaultSignatureName)
|
||||
},
|
||||
onClick = onManageSignatures,
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
|
||||
@@ -5,15 +5,13 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
@@ -26,6 +24,7 @@ class AccountSettingsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
signatureRepository: SignatureRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val accountId: String =
|
||||
@@ -38,26 +37,20 @@ class AccountSettingsViewModel @Inject constructor(
|
||||
val settings: StateFlow<AccountSettings> = accountSettingsRepository.observe(accountId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AccountSettings(accountId))
|
||||
|
||||
// The signature text is edited locally (seeded once from persistence) so the field stays
|
||||
// responsive — a fully DB-driven value would lag each keystroke and jump the cursor.
|
||||
private val _signature = MutableStateFlow<String?>(null)
|
||||
val signature: StateFlow<String?> = _signature.asStateFlow()
|
||||
private val signaturesFlow = signatureRepository.observeForAccount(accountId)
|
||||
|
||||
val signatureCount: StateFlow<Int> = signaturesFlow
|
||||
.map { it.size }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
|
||||
|
||||
/** The name of the account's default signature (for the settings summary line), or "". */
|
||||
val defaultSignatureName: StateFlow<String> = signaturesFlow
|
||||
.map { list -> list.firstOrNull { it.isDefault }?.name ?: list.firstOrNull()?.name ?: "" }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "")
|
||||
|
||||
/** This account's notification channel id, for deep-linking into Android's system settings. */
|
||||
val notificationChannelId: String = MailNotifier.channelId(accountId)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val loaded = accountSettingsRepository.get(accountId).signature
|
||||
_signature.update { it ?: loaded } // don't clobber any text typed before the load returned
|
||||
}
|
||||
}
|
||||
|
||||
fun onSignatureChange(value: String) {
|
||||
_signature.value = value
|
||||
viewModelScope.launch { accountSettingsRepository.setSignature(accountId, value) }
|
||||
}
|
||||
|
||||
fun setSignatureEnabled(value: Boolean) {
|
||||
viewModelScope.launch { accountSettingsRepository.setSignatureEnabled(accountId, value) }
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
import org.libremail.data.settings.FetchPolicy
|
||||
@@ -46,15 +48,17 @@ fun SettingsScreen(
|
||||
onAddAccount: () -> Unit,
|
||||
onOpenAccount: (String) -> Unit,
|
||||
onSelectTab: (TopDest) -> Unit,
|
||||
onReportProblem: () -> Unit,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val advancedExpanded by viewModel.advancedExpanded.collectAsStateWithLifecycle()
|
||||
val appLockMessage by viewModel.appLockMessage.collectAsStateWithLifecycle()
|
||||
|
||||
val batteryUnrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
|
||||
LaunchedEffect(appLockMessage) {
|
||||
appLockMessage?.let {
|
||||
Toast.makeText(context, resources.getString(it), Toast.LENGTH_LONG).show()
|
||||
@@ -62,6 +66,9 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read the battery status on resume so it reflects any change made in system settings.
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() }
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) },
|
||||
bottomBar = { LibreMailBottomBar(current = TopDest.SETTINGS, onSelect = onSelectTab) },
|
||||
@@ -127,6 +134,23 @@ fun SettingsScreen(
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
SectionHeader(stringResource(R.string.settings_diagnostics))
|
||||
ClickRow(
|
||||
title = stringResource(R.string.settings_report_problem),
|
||||
subtitle = stringResource(R.string.settings_report_problem_summary),
|
||||
onClick = onReportProblem,
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
SectionHeader(stringResource(R.string.settings_backup))
|
||||
SwitchRow(
|
||||
title = stringResource(R.string.settings_backup_include),
|
||||
checked = settings.includeInBackup,
|
||||
onCheckedChange = viewModel::setIncludeInBackup,
|
||||
subtitle = stringResource(R.string.settings_backup_include_summary),
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced)
|
||||
AnimatedVisibility(visible = advancedExpanded) {
|
||||
Column {
|
||||
@@ -135,6 +159,17 @@ fun SettingsScreen(
|
||||
checked = settings.pushIdle,
|
||||
onCheckedChange = viewModel::setPushIdle,
|
||||
)
|
||||
ClickRow(
|
||||
title = stringResource(R.string.settings_adv_battery),
|
||||
subtitle = stringResource(
|
||||
if (batteryUnrestricted) {
|
||||
R.string.settings_adv_battery_unrestricted
|
||||
} else {
|
||||
R.string.settings_adv_battery_optimized
|
||||
},
|
||||
),
|
||||
onClick = { runCatching { context.startActivity(viewModel.batterySettingsIntent()) } },
|
||||
)
|
||||
SwitchRow(
|
||||
title = stringResource(R.string.settings_adv_starttls),
|
||||
checked = settings.allowStartTls,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -20,6 +21,7 @@ import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.push.BatteryOptimizationManager
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -28,6 +30,7 @@ class SettingsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val appLockManager: AppLockManager,
|
||||
private val databaseKeyStore: DatabaseKeyStore,
|
||||
private val batteryOptimizationManager: BatteryOptimizationManager,
|
||||
) : ViewModel() {
|
||||
|
||||
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
|
||||
@@ -43,14 +46,28 @@ class SettingsViewModel @Inject constructor(
|
||||
private val _appLockMessage = MutableStateFlow<Int?>(null)
|
||||
val appLockMessage: StateFlow<Int?> = _appLockMessage.asStateFlow()
|
||||
|
||||
private val _batteryUnrestricted = MutableStateFlow(batteryOptimizationManager.isIgnoringBatteryOptimizations())
|
||||
|
||||
/** Whether this app is exempt from battery optimization ("Unrestricted"). */
|
||||
val batteryUnrestricted: StateFlow<Boolean> = _batteryUnrestricted.asStateFlow()
|
||||
|
||||
fun toggleAdvanced() = _advancedExpanded.update { !it }
|
||||
|
||||
/** Re-read the battery-optimization status; call when the screen resumes (e.g. back from Settings). */
|
||||
fun refreshBatteryStatus() {
|
||||
_batteryUnrestricted.value = batteryOptimizationManager.isIgnoringBatteryOptimizations()
|
||||
}
|
||||
|
||||
/** Intent to the system screen where the user flips this app to "Unrestricted". */
|
||||
fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent()
|
||||
|
||||
fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) }
|
||||
fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) }
|
||||
fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) }
|
||||
fun setAllowStartTls(value: Boolean) = update { settingsRepository.setAllowStartTls(value) }
|
||||
fun setLoadRemoteImages(value: Boolean) = update { settingsRepository.setLoadRemoteImages(value) }
|
||||
fun setEncryptCache(value: Boolean) = update { settingsRepository.setEncryptCache(value) }
|
||||
fun setIncludeInBackup(value: Boolean) = update { settingsRepository.setIncludeInBackup(value) }
|
||||
fun setFetchPolicy(value: FetchPolicy) = update { settingsRepository.setFetchPolicy(value) }
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
import org.libremail.ui.compose.RichTextBodyField
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SignatureEditScreen(onBack: () -> Unit, viewModel: SignatureEditViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (viewModel.isNew) R.string.signature_new_title else R.string.signature_edit_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = { viewModel.save(onBack) }) {
|
||||
Text(stringResource(R.string.signature_save))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = viewModel::onNameChange,
|
||||
label = { Text(stringResource(R.string.signature_name)) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Reuse the compose editor so signatures are rich in exactly the same way message bodies are.
|
||||
if (state.loaded) {
|
||||
RichTextBodyField(
|
||||
body = state.body,
|
||||
bodyHtml = state.bodyHtml,
|
||||
onBodyChange = viewModel::onBodyChange,
|
||||
label = stringResource(R.string.signature_content),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SignatureEditUiState(
|
||||
val name: String = "",
|
||||
val body: String = "",
|
||||
val bodyHtml: String? = null,
|
||||
val loaded: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SignatureEditViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val signatureRepository: SignatureRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURE_EDIT_ARG_ACCOUNT])
|
||||
private val signatureId: String? =
|
||||
savedStateHandle.get<String>(Routes.SIGNATURE_EDIT_ARG_ID)?.takeIf { it.isNotBlank() }
|
||||
|
||||
val isNew: Boolean = signatureId == null
|
||||
|
||||
private val _state = MutableStateFlow(SignatureEditUiState(loaded = isNew))
|
||||
val state: StateFlow<SignatureEditUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
if (signatureId != null) {
|
||||
viewModelScope.launch {
|
||||
signatureRepository.get(signatureId)?.let { signature ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
name = signature.name,
|
||||
body = signature.plainText(),
|
||||
bodyHtml = signature.html.ifBlank { null },
|
||||
loaded = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNameChange(value: String) = _state.update { it.copy(name = value) }
|
||||
|
||||
fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) }
|
||||
|
||||
/** Persists the signature (create or update), then invokes [onSaved]. */
|
||||
fun save(onSaved: () -> Unit) {
|
||||
val s = _state.value
|
||||
val name = s.name.trim().ifBlank { DEFAULT_NAME }
|
||||
// Store real HTML so the signature round-trips; derive it from the plaintext when unformatted.
|
||||
val html = s.bodyHtml ?: RichTextHtml.toHtml(RichTextContent(s.body))
|
||||
viewModelScope.launch {
|
||||
if (signatureId == null) {
|
||||
signatureRepository.create(accountId, name, html)
|
||||
} else {
|
||||
signatureRepository.update(signatureId, name, html)
|
||||
}
|
||||
onSaved()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_NAME = "Signature"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
import org.libremail.domain.model.Signature
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SignaturesScreen(
|
||||
onBack: () -> Unit,
|
||||
onEdit: (signatureId: String) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
viewModel: SignaturesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val signatures by viewModel.signatures.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.signatures_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onAdd) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.signatures_add))
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
if (signatures.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringResource(R.string.signatures_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
|
||||
items(signatures, key = { it.id }) { signature ->
|
||||
SignatureRow(
|
||||
signature = signature,
|
||||
onClick = { onEdit(signature.id) },
|
||||
onMakeDefault = { viewModel.setDefault(signature.id) },
|
||||
onDelete = { viewModel.delete(signature.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignatureRow(signature: Signature, onClick: () -> Unit, onMakeDefault: () -> Unit, onDelete: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = signature.isDefault,
|
||||
onClick = onMakeDefault,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
signature.name.ifBlank { stringResource(R.string.signature_content) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val preview = signature.plainText().replace('\n', ' ').trim()
|
||||
if (preview.isNotEmpty()) {
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (signature.isDefault) {
|
||||
Text(
|
||||
stringResource(R.string.signature_default_badge),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.signature_delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Signature
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SignaturesViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val signatureRepository: SignatureRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURES_ARG_ACCOUNT])
|
||||
|
||||
val signatures: StateFlow<List<Signature>> = signatureRepository.observeForAccount(accountId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun setDefault(id: String) {
|
||||
viewModelScope.launch { signatureRepository.setDefault(accountId, id) }
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
viewModelScope.launch { signatureRepository.delete(id) }
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,6 @@
|
||||
<string name="search_hint">Search mail</string>
|
||||
<string name="search_no_results">No results</string>
|
||||
<string name="mailbox_pull_to_refresh">Pull down to refresh</string>
|
||||
<string name="mailbox_welcome_title">Welcome to LibreMail</string>
|
||||
<string name="mailbox_welcome_subtitle">Add an account to start reading your mail.</string>
|
||||
|
||||
<!-- Mailbox multi-select contextual action bar -->
|
||||
<string name="cab_selected_count">%1$d selected</string>
|
||||
@@ -71,12 +69,26 @@
|
||||
<string name="compose_from">From</string>
|
||||
<string name="compose_to">To</string>
|
||||
<string name="compose_cc">Cc</string>
|
||||
<string name="compose_bcc">Bcc</string>
|
||||
<string name="compose_subject">Subject</string>
|
||||
<string name="compose_body">Message</string>
|
||||
<string name="compose_no_account">Add an account before composing</string>
|
||||
<string name="compose_attach">Attach file</string>
|
||||
<string name="compose_attachment_remove">Remove attachment</string>
|
||||
|
||||
<!-- Compose formatting toolbar -->
|
||||
<string name="format_bold">Bold</string>
|
||||
<string name="format_italic">Italic</string>
|
||||
<string name="format_underline">Underline</string>
|
||||
<string name="format_bullet_list">Bulleted list</string>
|
||||
<string name="format_numbered_list">Numbered list</string>
|
||||
<string name="format_quote">Block quote</string>
|
||||
<string name="format_link">Insert link</string>
|
||||
<string name="format_link_title">Insert link</string>
|
||||
<string name="format_link_url">Link address</string>
|
||||
<string name="format_link_apply">Link</string>
|
||||
<string name="format_link_needs_selection">Select some text first, then add a link.</string>
|
||||
|
||||
<!-- Drafts -->
|
||||
<string name="drafts_title">Drafts</string>
|
||||
<string name="drafts_empty">No drafts</string>
|
||||
@@ -102,10 +114,46 @@
|
||||
<string name="attachment_download_failed">Couldn\'t download %1$s</string>
|
||||
<string name="attachment_no_app">No app can open this file</string>
|
||||
|
||||
<!-- Account setup -->
|
||||
<string name="account_setup_outlook">Sign in with Microsoft</string>
|
||||
<!-- Onboarding -->
|
||||
<string name="onboarding_welcome_title">Welcome to LibreMail</string>
|
||||
<string name="onboarding_welcome_subtitle">A private, open-source home for all your email. Add your first account to get started.</string>
|
||||
<string name="onboarding_add_account">Add an email account</string>
|
||||
<string name="onboarding_account_added_title">Account added</string>
|
||||
<string name="onboarding_add_another_prompt">Would you like to add another email account?</string>
|
||||
<string name="onboarding_add_another_yes">Add another account</string>
|
||||
<string name="onboarding_add_another_no">No, take me to my inbox</string>
|
||||
|
||||
<!-- Onboarding: unrestricted background/battery opt-in (#49) -->
|
||||
<string name="onboarding_battery_title">Get mail the instant it arrives</string>
|
||||
<string name="onboarding_battery_body">LibreMail keeps a lightweight connection open to deliver mail the moment it lands. Android\'s battery optimization can pause or close that connection, so messages may show up late — or only when you open the app.</string>
|
||||
<string name="onboarding_battery_guidance">On the next screen, open Battery and choose “Unrestricted”.</string>
|
||||
<string name="onboarding_battery_take_me">Take me there</string>
|
||||
<string name="onboarding_battery_not_now">Not now</string>
|
||||
<string name="onboarding_battery_done_title">You\'re all set</string>
|
||||
<string name="onboarding_battery_done_body">Background usage is unrestricted — new mail will arrive instantly.</string>
|
||||
<string name="onboarding_battery_continue">Continue to inbox</string>
|
||||
|
||||
<!-- Account setup (vendor picker) -->
|
||||
<string name="account_setup_outlook">Outlook or Hotmail</string>
|
||||
<string name="account_setup_other">Other (IMAP/SMTP)</string>
|
||||
<string name="account_setup_subtitle">Choose how you want to connect your mailbox.</string>
|
||||
<string name="account_setup_subtitle">Choose your email provider to get started.</string>
|
||||
|
||||
<!-- App-password guided setup (Gmail/Yahoo/iCloud) -->
|
||||
<string name="app_password_title">Connect %1$s</string>
|
||||
<string name="app_password_unknown_provider">Unknown email provider.</string>
|
||||
<string name="app_password_intro_gmail">To connect Gmail, create an app password in your Google Account. Gmail requires 2-Step Verification to be turned on before you can create one.</string>
|
||||
<string name="app_password_intro_yahoo">To connect Yahoo Mail, generate an app password from your Yahoo Account security settings.</string>
|
||||
<string name="app_password_intro_icloud">To connect iCloud Mail, create an app-specific password from your Apple ID account page.</string>
|
||||
<string name="app_password_what_is">An app password is a one-off password that lets an app sign in to your account without your main password or a two-factor code.</string>
|
||||
<string name="app_password_warning">Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device.</string>
|
||||
<string name="app_password_open_page">Create an app password for %1$s</string>
|
||||
<string name="app_password_open_failed">Couldn\'t open your browser</string>
|
||||
<string name="app_password_email">Email address</string>
|
||||
<string name="app_password_field">App password</string>
|
||||
<string name="app_password_show_servers">Server settings</string>
|
||||
<string name="app_password_server_imap">Incoming (IMAP): %1$s:%2$d (%3$s)</string>
|
||||
<string name="app_password_server_smtp">Outgoing (SMTP): %1$s:%2$d (%3$s)</string>
|
||||
<string name="app_password_test_and_add">Test & add account</string>
|
||||
|
||||
<!-- Manual IMAP/SMTP setup -->
|
||||
<string name="manual_setup_title">IMAP / SMTP</string>
|
||||
@@ -143,11 +191,33 @@
|
||||
<string name="settings_no_accounts">No accounts yet</string>
|
||||
<string name="account_remove">Remove account</string>
|
||||
|
||||
<!-- Backup -->
|
||||
<string name="settings_backup">Backup</string>
|
||||
<string name="settings_backup_include">Include settings in Android Backup</string>
|
||||
<string name="settings_backup_include_summary">Let Android back up your LibreMail preferences (Google Auto Backup) so they restore when you set up a new device. Your mail, accounts, passwords, and encryption keys are never backed up — only app settings. Off by default; uses Google infrastructure.</string>
|
||||
|
||||
<!-- Per-account settings -->
|
||||
<string name="settings_account_title">Account</string>
|
||||
<string name="settings_signature">Signature</string>
|
||||
<string name="settings_signature_enable">Append signature</string>
|
||||
<string name="settings_signature_enable">Append signature automatically</string>
|
||||
<string name="settings_signature_hint">Your signature</string>
|
||||
<string name="settings_signatures_manage">Manage signatures</string>
|
||||
<string name="settings_signatures_summary_none">No signatures yet</string>
|
||||
<string name="settings_signatures_summary">%1$d signature(s), default: %2$s</string>
|
||||
|
||||
<!-- Signatures management -->
|
||||
<string name="signatures_title">Signatures</string>
|
||||
<string name="signatures_empty">No signatures yet. Add one to append it to your messages.</string>
|
||||
<string name="signatures_add">Add signature</string>
|
||||
<string name="signature_new_title">New signature</string>
|
||||
<string name="signature_edit_title">Edit signature</string>
|
||||
<string name="signature_name">Name</string>
|
||||
<string name="signature_content">Signature</string>
|
||||
<string name="signature_save">Save</string>
|
||||
<string name="signature_delete">Delete signature</string>
|
||||
<string name="signature_default">Default</string>
|
||||
<string name="signature_make_default">Set as default</string>
|
||||
<string name="signature_default_badge">Default</string>
|
||||
<string name="settings_account_notifications">Notifications</string>
|
||||
<string name="settings_account_new_mail">New mail notifications</string>
|
||||
<string name="settings_account_new_mail_summary">Show notifications for this account</string>
|
||||
@@ -176,4 +246,38 @@
|
||||
<string name="app_lock_prompt_subtitle">Confirm your screen lock to decrypt your mail.</string>
|
||||
<string name="app_lock_needs_device_lock">Set up a device screen lock in Android settings before enabling this.</string>
|
||||
<string name="app_lock_disable_failed">Couldn\'t turn off the screen lock right now. Please try again.</string>
|
||||
|
||||
<string name="settings_adv_battery">Background battery usage</string>
|
||||
<string name="settings_adv_battery_unrestricted">Unrestricted — instant background mail is allowed.</string>
|
||||
<string name="settings_adv_battery_optimized">Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage.</string>
|
||||
|
||||
<!-- Diagnostics / debug reporting -->
|
||||
<string name="settings_diagnostics">Diagnostics</string>
|
||||
<string name="settings_report_problem">Report a problem</string>
|
||||
<string name="settings_report_problem_summary">Capture diagnostics you can review and optionally send</string>
|
||||
<string name="reports_title">Problem reports</string>
|
||||
<string name="reports_empty">No problem reports yet</string>
|
||||
<string name="reports_create">Create a report</string>
|
||||
<string name="report_kind_crash">Crash report</string>
|
||||
<string name="report_kind_manual">Manual report</string>
|
||||
<string name="report_review_title">Review report</string>
|
||||
<string name="report_pii_disclaimer_title">May contain personal information</string>
|
||||
<string name="report_pii_disclaimer">This report can include email addresses, server names, and other details from your device. Read the whole thing below before sending. Nothing is sent unless you tap Submit.</string>
|
||||
<string name="report_comment_label">What went wrong? (optional)</string>
|
||||
<string name="report_payload_label">Exactly what will be sent</string>
|
||||
<string name="report_submit">Submit</string>
|
||||
<string name="report_discard">Discard report</string>
|
||||
<string name="report_copy">Copy</string>
|
||||
<string name="report_save">Save to file</string>
|
||||
<string name="report_submitting">Submitting…</string>
|
||||
<string name="report_submitted">Report sent. Thank you!</string>
|
||||
<string name="report_submit_failed">Couldn\'t send — it will be retried.</string>
|
||||
<string name="report_submit_unavailable">Online submission isn\'t available in this build. Use Copy or Save to share the report.</string>
|
||||
<string name="report_copied">Copied to clipboard</string>
|
||||
<string name="report_saved">Saved</string>
|
||||
<string name="crash_prompt_title">LibreMail closed unexpectedly</string>
|
||||
<string name="crash_prompt_message">A problem report from the last crash is ready for you to review. Nothing is sent automatically.</string>
|
||||
<string name="crash_prompt_review">Review</string>
|
||||
<string name="crash_prompt_later">Not now</string>
|
||||
<string name="crash_prompt_discard">Discard</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!--
|
||||
Auto Backup allowlist for API 29-30 (pre-Android 12), mirroring res/xml/data_extraction_rules.xml
|
||||
which applies on API 31+. Backup is still gated by LibreMailBackupAgent (opt-in, OFF by default).
|
||||
|
||||
<include> makes this a strict allowlist: ONLY the libremail_settings DataStore is backed up. The
|
||||
Keystore-sealed cache passphrase (datastore/libremail_dbkey.preferences_pb) and the encrypted
|
||||
credentials + mail-cache database (libremail.db and its -wal/-shm/-journal side files) are kept
|
||||
off-device by being omitted from the allowlist; the mail cache re-downloads on the next sync and
|
||||
accounts are re-added on a new device. (Lint's FullBackupContent rule forbids <exclude> paths
|
||||
outside an <include>, so exclusion is expressed by omission rather than explicit <exclude> entries.)
|
||||
-->
|
||||
<full-backup-content>
|
||||
<include domain="file" path="datastore/libremail_settings.preferences_pb" />
|
||||
</full-backup-content>
|
||||
@@ -1,17 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!--
|
||||
LibreMail stores encrypted IMAP/OAuth credentials and cached mail in its Room database, encrypted
|
||||
with a non-exportable Android Keystore key. The key never leaves the device, so backing up or
|
||||
transferring the ciphertext would only restore undecryptable data (and needlessly ship mail and
|
||||
credential blobs off-device). Exclude all app data from both cloud backup and device-to-device
|
||||
transfer; accounts are simply re-added on a new device.
|
||||
Android Backup allowlist for LibreMail (API 31+). Backup is opt-in and OFF by default:
|
||||
LibreMailBackupAgent only lets a backup or device transfer run once the user enables "Include
|
||||
settings in Android Backup", so nothing here leaves the device unless explicitly chosen.
|
||||
|
||||
<include> makes each section a strict allowlist: ONLY the listed path — the libremail_settings
|
||||
DataStore of re-creatable user preferences — is ever backed up. Everything else is excluded by
|
||||
omission, which deliberately keeps these off-device:
|
||||
- datastore/libremail_dbkey.preferences_pb: the Keystore-sealed SQLCipher passphrase for the
|
||||
encrypted cache. The wrapping Keystore key is non-exportable and device-bound, so the
|
||||
ciphertext is useless anywhere else.
|
||||
- libremail.db (+ -wal/-shm/-journal): encrypted IMAP/OAuth credentials and the cached mail.
|
||||
The cache re-downloads on the next sync; accounts are re-added on a new device.
|
||||
(Lint's FullBackupContent rule forbids <exclude> paths outside an <include>, so the exclusions are
|
||||
expressed by simply not listing those paths rather than as explicit <exclude> entries.)
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<include domain="file" path="datastore/libremail_settings.preferences_pb" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<include domain="file" path="datastore/libremail_settings.preferences_pb" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.backup
|
||||
|
||||
import org.junit.Test
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BackupPolicyTest {
|
||||
|
||||
@Test
|
||||
fun `backup is off by default`() {
|
||||
assertFalse(AppSettings().includeInBackup, "the opt-in default must be off")
|
||||
assertFalse(BackupPolicy.shouldBackUp(AppSettings()), "no backup runs without opting in")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup runs only when the user opts in`() {
|
||||
assertTrue(BackupPolicy.shouldBackUp(AppSettings(includeInBackup = true)))
|
||||
assertFalse(BackupPolicy.shouldBackUp(AppSettings(includeInBackup = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the settings datastore is eligible for backup`() {
|
||||
assertEquals("datastore/libremail_settings.preferences_pb", BackupPolicy.SAFE_SETTINGS_FILE)
|
||||
// The safe file must not be, or resemble, a secret store.
|
||||
assertFalse(BackupPolicy.SAFE_SETTINGS_FILE.contains("dbkey"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the keystore-sealed db key is never eligible for backup`() {
|
||||
assertTrue(
|
||||
BackupPolicy.EXCLUDED_FILE_PATHS.any { it.contains("libremail_dbkey") },
|
||||
"the sealed cache passphrase DataStore must be excluded",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the credentials and mail-cache database is never eligible for backup`() {
|
||||
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db"))
|
||||
// WAL/SHM/journal side-files can hold recently written rows too.
|
||||
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-wal"))
|
||||
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-shm"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.backup
|
||||
|
||||
import org.junit.Test
|
||||
import org.w3c.dom.Element
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Validates the shipped Android Backup rule resources directly, so they can't silently drift from
|
||||
* [BackupPolicy] or from the acceptance criteria of issue #21: only the settings DataStore may be
|
||||
* eligible, and the Keystore-sealed cache key plus the credentials/mail database must be excluded.
|
||||
*/
|
||||
class DataExtractionRulesTest {
|
||||
|
||||
private data class Rules(val includes: Set<String>, val excludes: Set<String>)
|
||||
|
||||
private fun resource(name: String): File {
|
||||
// Gradle runs unit tests with the module dir (app/) as the working dir; fall back to the repo
|
||||
// root in case a runner starts elsewhere.
|
||||
val candidates = listOf(
|
||||
File("src/main/res/xml/$name"),
|
||||
File("app/src/main/res/xml/$name"),
|
||||
)
|
||||
return candidates.firstOrNull { it.exists() }
|
||||
?: error("Could not locate $name; looked in ${candidates.map { it.absolutePath }}")
|
||||
}
|
||||
|
||||
/** Collects the `domain:path` pairs of every <include>/<exclude> under the given section element. */
|
||||
private fun parseSection(file: File, sectionTag: String): Rules {
|
||||
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
|
||||
val section = doc.getElementsByTagName(sectionTag).item(0) as Element
|
||||
fun collect(tag: String): Set<String> {
|
||||
val nodes = section.getElementsByTagName(tag)
|
||||
return (0 until nodes.length).map { i ->
|
||||
val e = nodes.item(i) as Element
|
||||
"${e.getAttribute("domain")}:${e.getAttribute("path")}"
|
||||
}.toSet()
|
||||
}
|
||||
return Rules(includes = collect("include"), excludes = collect("exclude"))
|
||||
}
|
||||
|
||||
private val safeFile = "file:${BackupPolicy.SAFE_SETTINGS_FILE}"
|
||||
private val secretPaths: List<String> =
|
||||
BackupPolicy.EXCLUDED_FILE_PATHS.map { "file:$it" } +
|
||||
BackupPolicy.EXCLUDED_DATABASE_PATHS.map { "database:$it" }
|
||||
|
||||
private fun assertSafe(rules: Rules) {
|
||||
// Strict allowlist: the settings DataStore is the ONLY thing eligible for backup/transfer.
|
||||
// Everything else — crucially the Keystore-sealed cache key and the credentials/mail
|
||||
// database — is excluded simply by not being listed.
|
||||
assertEquals(setOf(safeFile), rules.includes, "only the settings DataStore may be backed up")
|
||||
assertTrue(rules.excludes.isEmpty(), "rules are allowlist-only; no <exclude> entries expected")
|
||||
secretPaths.forEach { secret ->
|
||||
assertFalse(secret in rules.includes, "$secret must never be eligible for backup")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data extraction rules (API 31+) back up only settings for cloud backup`() {
|
||||
assertSafe(parseSection(resource("data_extraction_rules.xml"), "cloud-backup"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data extraction rules (API 31+) back up only settings for device transfer`() {
|
||||
assertSafe(parseSection(resource("data_extraction_rules.xml"), "device-transfer"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full backup content (API 29-30) mirrors the same exclusions`() {
|
||||
assertSafe(parseSection(resource("backup_rules.xml"), "full-backup-content"))
|
||||
}
|
||||
}
|
||||
@@ -105,4 +105,26 @@ class ReplyBuilderTest {
|
||||
assertTrue(result.body.contains("Hello"), "body=${result.body}")
|
||||
assertTrue(result.body.contains("there"), "body=${result.body}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `html original is quoted into a blockquote without leaking original tags`() {
|
||||
val result = ReplyBuilder.build(
|
||||
context(body = "<p>Hello <b>there</b></p>", isHtml = true),
|
||||
ReplyMode.REPLY,
|
||||
"me@example.org",
|
||||
)
|
||||
|
||||
// The HTML alternative wraps the (tag-stripped) original in a blockquote — never raw tags.
|
||||
assertTrue(result.bodyHtml.contains("<blockquote>"), "html=${result.bodyHtml}")
|
||||
assertTrue(result.bodyHtml.contains("Hello there"), "html=${result.bodyHtml}")
|
||||
assertFalse(result.bodyHtml.contains("<p>Hello"), "html=${result.bodyHtml}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plaintext reply also carries an html blockquote alternative`() {
|
||||
val result = ReplyBuilder.build(context(body = "First line\nSecond line"), ReplyMode.REPLY, "me@example.org")
|
||||
|
||||
assertTrue(result.bodyHtml.contains("<blockquote>"), "html=${result.bodyHtml}")
|
||||
assertTrue(result.bodyHtml.contains("First line"), "html=${result.bodyHtml}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data
|
||||
|
||||
import org.junit.Test
|
||||
import org.libremail.domain.model.Signature
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SignatureBlockTest {
|
||||
|
||||
private fun signature(html: String) =
|
||||
Signature(id = "s", accountId = "a", name = "Sig", html = html, isDefault = true)
|
||||
|
||||
@Test
|
||||
fun `plain form opens with the RFC 3676 delimiter`() {
|
||||
val block = SignatureBlock.of(signature("Cheers, Alice"))
|
||||
assertEquals("\n\n-- \nCheers, Alice", block.plain)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `html form parses back to exactly the plain form`() {
|
||||
val block = SignatureBlock.of(signature("Cheers, Alice"))
|
||||
assertEquals(block.plain, RichTextHtml.fromHtml(block.html).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rich signature keeps its formatting in the html form`() {
|
||||
val block = SignatureBlock.of(signature("Cheers, <b>Alice</b>"))
|
||||
assertTrue(block.html.endsWith("Cheers, <b>Alice</b>"), block.html)
|
||||
assertEquals("\n\n-- \nCheers, Alice", block.plain)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null or blank signature yields the empty block`() {
|
||||
assertTrue(SignatureBlock.of(null).isEmpty)
|
||||
assertTrue(SignatureBlock.of(signature("")).isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import org.junit.Test
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.domain.model.Draft
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class MappersHtmlBodyTest {
|
||||
|
||||
@Test
|
||||
fun `draft html body round-trips through the entity`() {
|
||||
val draft = Draft(
|
||||
id = "d1",
|
||||
accountId = "acct",
|
||||
to = "a@x.com",
|
||||
cc = "",
|
||||
subject = "Hi",
|
||||
body = "Hello",
|
||||
updatedAt = 1L,
|
||||
bodyHtml = "<p>Hello <b>there</b></p>",
|
||||
)
|
||||
|
||||
val restored = draft.toEntity().toDomain()
|
||||
|
||||
assertEquals("<p>Hello <b>there</b></p>", restored.bodyHtml)
|
||||
assertEquals("Hello", restored.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plaintext draft keeps a null html body`() {
|
||||
val draft = Draft(
|
||||
id = "d2",
|
||||
accountId = "acct",
|
||||
to = "a@x.com",
|
||||
cc = "",
|
||||
subject = "Hi",
|
||||
body = "Hello",
|
||||
updatedAt = 1L,
|
||||
)
|
||||
assertNull(draft.toEntity().toDomain().bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `outbox entity maps its html body to the domain`() {
|
||||
val entity = OutboxEntity(
|
||||
id = "o1",
|
||||
accountId = "acct",
|
||||
toAddresses = "a@x.com",
|
||||
ccAddresses = "",
|
||||
subject = "Hi",
|
||||
body = "Hello",
|
||||
createdAt = 1L,
|
||||
bodyHtml = "<p>Hi</p>",
|
||||
)
|
||||
|
||||
assertEquals("<p>Hi</p>", entity.toDomain().bodyHtml)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.data.sync.MailConnectionFactory
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.FolderRole
|
||||
@@ -54,6 +55,7 @@ class MailRepositoryImplTest {
|
||||
private val connectionFactory = mockk<MailConnectionFactory>()
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
private val accountSettingsRepository = mockk<AccountSettingsRepository>()
|
||||
private val signatureRepository = mockk<SignatureRepository>()
|
||||
private val repository = MailRepositoryImpl(
|
||||
context = context,
|
||||
messageDao = messageDao,
|
||||
@@ -66,6 +68,7 @@ class MailRepositoryImplTest {
|
||||
connectionFactory = connectionFactory,
|
||||
sendScheduler = mockk(),
|
||||
accountSettingsRepository = accountSettingsRepository,
|
||||
signatureRepository = signatureRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -202,6 +205,7 @@ class MailRepositoryImplTest {
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
|
||||
coEvery { signatureRepository.getDefault(any()) } returns null
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { imapClient.fetchForReply(any(), "INBOX", "2") } returns ReplyContext(
|
||||
fromEmail = "boss@example.org",
|
||||
@@ -220,6 +224,42 @@ class MailRepositoryImplTest {
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals("boss@example.org", draft.captured.toAddresses)
|
||||
assertEquals("Re: Plan", draft.captured.subject)
|
||||
// The reply carries an HTML alternative with the quote rendered as a blockquote.
|
||||
assertTrue(draft.captured.bodyHtml?.contains("<blockquote>") == true, "html=${draft.captured.bodyHtml}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildReplyDraft bakes the account default signature above the quote`() = runTest {
|
||||
val id = "acct:INBOX:3"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
|
||||
coEvery { signatureRepository.getDefault("acct") } returns org.libremail.domain.model.Signature(
|
||||
id = "acct:sig",
|
||||
accountId = "acct",
|
||||
name = "Signature",
|
||||
html = "Regards, Ada",
|
||||
isDefault = true,
|
||||
)
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { imapClient.fetchForReply(any(), "INBOX", "3") } returns ReplyContext(
|
||||
fromEmail = "boss@example.org",
|
||||
toRecipients = listOf("ada@example.org"),
|
||||
ccRecipients = emptyList(),
|
||||
subject = "Plan",
|
||||
sentDateMillis = 0L,
|
||||
body = "Original",
|
||||
isHtml = false,
|
||||
)
|
||||
val draft = slot<DraftEntity>()
|
||||
coEvery { draftDao.upsert(capture(draft)) } just Runs
|
||||
|
||||
repository.buildReplyDraft(id, ReplyMode.REPLY)
|
||||
|
||||
val body = draft.captured.body
|
||||
// Signature is placed before (above) the quoted original.
|
||||
assertTrue(body.contains("Regards, Ada"), "body=$body")
|
||||
assertTrue(body.indexOf("Regards, Ada") < body.indexOf("> Original"), "body=$body")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.settings
|
||||
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.libremail.data.local.dao.SignatureDao
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SignatureRepositoryTest {
|
||||
|
||||
private val dao = mockk<SignatureDao>(relaxed = true)
|
||||
private val repository = SignatureRepository(dao)
|
||||
|
||||
private fun entity(id: String, isDefault: Boolean) =
|
||||
SignatureEntity(id, accountId = "acct", name = "N", contentHtml = "<p>x</p>", isDefault = isDefault)
|
||||
|
||||
@Test
|
||||
fun `the first signature for an account becomes its default`() = runTest {
|
||||
coEvery { dao.countForAccount("acct") } returns 0
|
||||
val saved = slot<SignatureEntity>()
|
||||
coEvery { dao.upsert(capture(saved)) } just Runs
|
||||
|
||||
repository.create("acct", "Work", "<p>hi</p>")
|
||||
|
||||
assertTrue(saved.captured.isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `later signatures are not made default`() = runTest {
|
||||
coEvery { dao.countForAccount("acct") } returns 2
|
||||
val saved = slot<SignatureEntity>()
|
||||
coEvery { dao.upsert(capture(saved)) } just Runs
|
||||
|
||||
repository.create("acct", "Personal", "<p>hey</p>")
|
||||
|
||||
assertFalse(saved.captured.isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the default promotes the first remaining signature`() = runTest {
|
||||
coEvery { dao.getById("s1") } returns entity("s1", isDefault = true)
|
||||
coEvery { dao.firstForAccount("acct") } returns entity("s2", isDefault = false)
|
||||
|
||||
repository.delete("s1")
|
||||
|
||||
coVerify { dao.delete("s1") }
|
||||
coVerify { dao.markDefault("s2") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting a non-default signature promotes nothing`() = runTest {
|
||||
coEvery { dao.getById("s2") } returns entity("s2", isDefault = false)
|
||||
|
||||
repository.delete("s2")
|
||||
|
||||
coVerify { dao.delete("s2") }
|
||||
coVerify(exactly = 0) { dao.firstForAccount(any()) }
|
||||
coVerify(exactly = 0) { dao.markDefault(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setDefault delegates to the dao's atomic swap`() = runTest {
|
||||
repository.setDefault("acct", "s1")
|
||||
coVerify { dao.setDefault("acct", "s1") }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user