From 657af48052a812dce198caa74ea65c60c935ab52 Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Tue, 30 Jun 2026 23:44:17 -0500
Subject: [PATCH 01/11] refactor(auth): remove dead Gmail OAuth code path
Gmail authenticates via app password + preconfigured IMAP/SMTP (decision
in #9), never OAuth, so the Gmail-OAuth implementation was unreachable
dead code. Remove it while keeping Outlook's AppAuth OAuth path intact.
- Delete auth/GmailAuthManager.kt (shared OAuthResult/FreshToken kept).
- Drop AuthType.OAUTH_GMAIL and its exhaustive `when` branch, the
gmailAuthManager injection, and the SCOPE_GMAIL constant in
MailConnectionFactory.
- Remove GMAIL_OAUTH_* BuildConfig fields, gmailOAuthClientId, and the
gmailRedirectScheme val from app/build.gradle.kts.
- Repoint the AppAuth manifestPlaceholders["appAuthRedirectScheme"] to
the Outlook scheme (org.libremail.outlook). AppAuth's bundled manifest
now registers that scheme on RedirectUriReceiverActivity, so the app
manifest's now-redundant Outlook intent-filter is removed; the
AppCompat theme override (crash fix) is preserved. Verified the merged
manifest.
- Drop GMAIL_OAUTH_CLIENT_ID from secrets.properties.example.
authType persists as the enum name in a TEXT column, so removing a
constant needs no Room schema change or migration.
Closes #39
Co-Authored-By: Claude Opus 4.8
---
app/build.gradle.kts | 26 ++--
app/src/main/AndroidManifest.xml | 27 ++---
.../org/libremail/auth/GmailAuthManager.kt | 111 ------------------
.../data/sync/MailConnectionFactory.kt | 5 -
.../org/libremail/domain/model/Account.kt | 3 -
secrets.properties.example | 9 +-
6 files changed, 23 insertions(+), 158 deletions(-)
delete mode 100644 app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4f0f640..ca415ff 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -14,21 +14,12 @@ plugins {
alias(libs.plugins.detekt)
}
-// Read the Gmail OAuth client id from secrets.properties (git-ignored). Empty when absent.
+// Read optional build secrets (Outlook client id, release signing) from secrets.properties
+// (git-ignored). Absent values fall back to the defaults below.
val secretsFile = rootProject.file("secrets.properties")
val secrets = Properties().apply {
if (secretsFile.exists()) secretsFile.inputStream().use { load(it) }
}
-val gmailOAuthClientId: String = secrets.getProperty("GMAIL_OAUTH_CLIENT_ID", "")
-
-// For a Google installed-app OAuth client, AppAuth's redirect is the reversed client
-// id as a custom URI scheme. Fall back to a placeholder so the manifest stays valid
-// until a real client id is set in secrets.properties.
-val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleusercontent.com")) {
- "com.googleusercontent.apps." + gmailOAuthClientId.removeSuffix(".apps.googleusercontent.com")
-} else {
- "org.libremail.oauth"
-}
// Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via
// secrets.properties; defaults to the app's registered client id.
@@ -37,6 +28,10 @@ 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"
+
// 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 +50,11 @@ 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\"")
+ // 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 {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8ee562c..434581c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -61,25 +61,20 @@
android:resource="@xml/file_paths" />
-
+
-
-
-
-
-
-
-
+ tools:node="merge" />
diff --git a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt
deleted file mode 100644
index 080dd36..0000000
--- a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt
+++ /dev/null
@@ -1,111 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-package org.libremail.auth
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.util.Base64
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.suspendCancellableCoroutine
-import net.openid.appauth.AuthState
-import net.openid.appauth.AuthorizationException
-import net.openid.appauth.AuthorizationRequest
-import net.openid.appauth.AuthorizationResponse
-import net.openid.appauth.AuthorizationService
-import net.openid.appauth.AuthorizationServiceConfiguration
-import net.openid.appauth.ResponseTypeValues
-import org.json.JSONObject
-import org.libremail.BuildConfig
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-
-/**
- * Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted
- * `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2.
- */
-@Singleton
-class GmailAuthManager @Inject constructor(@ApplicationContext private val context: Context) {
- private val serviceConfig = AuthorizationServiceConfiguration(
- Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
- Uri.parse("https://oauth2.googleapis.com/token"),
- )
-
- /** False until a Google OAuth client id is provided in secrets.properties (see README). */
- val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank()
-
- fun createAuthIntent(): Intent {
- val request = AuthorizationRequest.Builder(
- serviceConfig,
- BuildConfig.GMAIL_OAUTH_CLIENT_ID,
- ResponseTypeValues.CODE,
- Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI),
- )
- .setScope("openid email profile https://mail.google.com/")
- .build()
- return AuthorizationService(context).getAuthorizationRequestIntent(request)
- }
-
- suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
- val response = AuthorizationResponse.fromIntent(responseIntent)
- val exception = AuthorizationException.fromIntent(responseIntent)
- if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
-
- val service = AuthorizationService(context)
- try {
- val tokenResponse = suspendCancellableCoroutine { continuation ->
- service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
- if (token != null) {
- continuation.resume(token)
- } else {
- continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed"))
- }
- }
- }
- val authState = AuthState(response, exception).apply { update(tokenResponse, null) }
- val email = emailFromIdToken(tokenResponse.idToken)
- ?: throw IllegalStateException("Could not read the account email from the token")
- return OAuthResult(
- email = email,
- accessToken = tokenResponse.accessToken.orEmpty(),
- authStateJson = authState.jsonSerializeString(),
- )
- } finally {
- service.dispose()
- }
- }
-
- /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */
- suspend fun freshAccessToken(authStateJson: String): FreshToken {
- val authState = AuthState.jsonDeserialize(authStateJson)
- val service = AuthorizationService(context)
- try {
- val accessToken = suspendCancellableCoroutine { continuation ->
- authState.performActionWithFreshTokens(service) { token, _, error ->
- if (token != null) {
- continuation.resume(token)
- } else {
- continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed"))
- }
- }
- }
- return FreshToken(
- accessToken = accessToken,
- authStateJson = authState.jsonSerializeString(),
- accessTokenExpiry = authState.accessTokenExpirationTime,
- )
- } finally {
- service.dispose()
- }
- }
-
- private fun emailFromIdToken(idToken: String?): String? {
- if (idToken.isNullOrBlank()) return null
- return runCatching {
- val payload = idToken.split(".").getOrNull(1) ?: return null
- val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
- JSONObject(json).optString("email").ifBlank { null }
- }.getOrNull()
- }
-}
diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt
index beacac7..26bdf62 100644
--- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt
+++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt
@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.libremail.auth.FreshToken
-import org.libremail.auth.GmailAuthManager
import org.libremail.auth.OutlookAuthManager
import org.libremail.data.local.toImapParams
import org.libremail.data.local.toSmtpParams
@@ -23,7 +22,6 @@ import javax.inject.Singleton
@Singleton
class MailConnectionFactory @Inject constructor(
private val credentialStore: CredentialStore,
- private val gmailAuthManager: GmailAuthManager,
private val outlookAuthManager: OutlookAuthManager,
private val settingsRepository: SettingsRepository,
) {
@@ -54,8 +52,6 @@ class MailConnectionFactory @Inject constructor(
private suspend fun resolveSecret(account: Account): String = when (account.authType) {
AuthType.PASSWORD_IMAP ->
credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}")
- AuthType.OAUTH_GMAIL ->
- cachedAccessToken(account.id, SCOPE_GMAIL, gmailAuthManager::freshAccessToken)
AuthType.OAUTH_OUTLOOK ->
cachedAccessToken(account.id, SCOPE_OUTLOOK, outlookAuthManager::freshOutlookToken)
}
@@ -91,7 +87,6 @@ class MailConnectionFactory @Inject constructor(
private suspend fun strictStartTls(): Boolean = !settingsRepository.settings.first().allowStartTls
private companion object {
- const val SCOPE_GMAIL = "gmail"
const val SCOPE_OUTLOOK = "outlook"
const val SCOPE_GRAPH = "graph"
const val EXPIRY_BUFFER_MS = 60_000L
diff --git a/app/src/main/kotlin/org/libremail/domain/model/Account.kt b/app/src/main/kotlin/org/libremail/domain/model/Account.kt
index 3110427..3ccb80e 100644
--- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt
+++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt
@@ -3,9 +3,6 @@ package org.libremail.domain.model
/** How an account authenticates with its mail server. */
enum class AuthType {
- /** Gmail via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
- OAUTH_GMAIL,
-
/** Outlook / Microsoft via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
OAUTH_OUTLOOK,
diff --git a/secrets.properties.example b/secrets.properties.example
index 48df0a9..d7f3dd1 100644
--- a/secrets.properties.example
+++ b/secrets.properties.example
@@ -1,11 +1,6 @@
-# Copy this file to `secrets.properties` (which is git-ignored) and fill in the value.
+# Copy this file to `secrets.properties` (which is git-ignored) and fill in the values you need.
+# Every value below is optional — the build works with the defaults when this file is absent.
#
-# Gmail OAuth 2.0 *Android* client ID created in Google Cloud Console.
-# See the README ("Gmail account setup") for the exact steps. Used by the app for
-# the Authorization Code + PKCE login flow; no client secret is required for an
-# installed Android app.
-GMAIL_OAUTH_CLIENT_ID=
-
# Optional: Microsoft (Outlook) OAuth public client id. A working default ships with the build;
# set this only to use your own Azure app registration.
#OUTLOOK_OAUTH_CLIENT_ID=
From ffbb88222778389ee0941f21ff64c3f9ed5f0f01 Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Tue, 30 Jun 2026 23:56:18 -0500
Subject: [PATCH 02/11] feat(backup): opt-in Android Backup for settings only
Add an opt-in (off by default) toggle to include app data in system
Android Backup / Auto Backup, gated so only re-creatable user
preferences are ever backed up.
- Flip allowBackup to true and add LibreMailBackupAgent, which enforces
the runtime opt-in: onFullBackup runs only when the user enables
"Include settings in Android Backup" (default off), so no data leaves
the device otherwise. allowBackup is a manifest flag and can't be
toggled at runtime, hence the agent.
- Rewrite data_extraction_rules.xml (API 31+) and add backup_rules.xml
(API 29-30) as strict allowlists that back up ONLY the
libremail_settings DataStore. The Keystore-sealed cache passphrase
(libremail_dbkey) and the encrypted credentials + mail-cache database
(libremail.db) are excluded by omission; the cache re-downloads on
next sync.
- Add includeInBackup preference + setter (nudges BackupManager on
change) and a "Backup" settings section with F-Droid-honest copy
(off by default, uses Google infrastructure).
- BackupPolicy is the single source of truth for eligible/excluded
paths; unit tests cover the toggle default and assert the shipped XML
resources include only settings and never the secrets/DB.
Co-Authored-By: Claude Opus 4.8
---
app/src/main/AndroidManifest.xml | 11 ++-
.../org/libremail/backup/BackupPolicy.kt | 40 +++++++++
.../libremail/backup/LibreMailBackupAgent.kt | 42 +++++++++
.../data/settings/SettingsRepository.kt | 87 ++++++++++++-------
.../libremail/ui/settings/SettingsScreen.kt | 9 ++
.../ui/settings/SettingsViewModel.kt | 1 +
app/src/main/res/values/strings.xml | 5 ++
app/src/main/res/xml/backup_rules.xml | 16 ++++
.../main/res/xml/data_extraction_rules.xml | 23 +++--
.../org/libremail/backup/BackupPolicyTest.kt | 46 ++++++++++
.../backup/DataExtractionRulesTest.kt | 76 ++++++++++++++++
11 files changed, 318 insertions(+), 38 deletions(-)
create mode 100644 app/src/main/kotlin/org/libremail/backup/BackupPolicy.kt
create mode 100644 app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt
create mode 100644 app/src/main/res/xml/backup_rules.xml
create mode 100644 app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8ee562c..a942157 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,10 +10,19 @@
+
= listOf(
+ // Keystore-sealed SQLCipher passphrase for the encrypted cache: the wrapping key is
+ // non-exportable and device-bound, so this ciphertext is useless anywhere else.
+ "datastore/libremail_dbkey.preferences_pb",
+ )
+
+ /** `databases`-dir-relative names that must never leave the device (encrypted credentials + mail cache). */
+ val EXCLUDED_DATABASE_PATHS: List = listOf(
+ "libremail.db",
+ "libremail.db-wal",
+ "libremail.db-shm",
+ "libremail.db-journal",
+ )
+
+ /**
+ * Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up
+ * (or transferred device-to-device) unless the user has explicitly enabled it in Settings.
+ */
+ fun shouldBackUp(settings: AppSettings): Boolean = settings.includeInBackup
+}
diff --git a/app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt b/app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt
new file mode 100644
index 0000000..c62629d
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.backup
+
+import android.app.backup.BackupAgentHelper
+import android.app.backup.FullBackupDataOutput
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.libremail.data.settings.settingsDataStore
+import org.libremail.data.settings.toAppSettings
+
+/**
+ * Enforces the runtime backup opt-in on top of Android Auto Backup.
+ *
+ * `android:allowBackup` is a manifest flag that can't be toggled at runtime, so the "include settings
+ * in Android Backup" preference is enforced here instead: [onFullBackup] runs the backup only when the
+ * user has opted in (the default is off, so no app data leaves the device). When opted in, it defers to
+ * the framework, which applies the allowlist in `res/xml/data_extraction_rules.xml` (and
+ * `res/xml/backup_rules.xml` on API < 31) — backing up the user-preferences DataStore only, never the
+ * mail cache, the encrypted credentials, or the Keystore-sealed cache passphrase.
+ *
+ * Extends [BackupAgentHelper] (rather than raw `BackupAgent`) so the unused key/value backup/restore
+ * paths inherit safe no-op implementations; only full-data backup is used (`fullBackupOnly=true`), and
+ * full-data restore uses the default `onRestoreFile` handling.
+ *
+ * The opt-in flag is read directly from the shared [settingsDataStore] singleton so it does not depend
+ * on Hilt or `Application.onCreate` having run in the framework's restricted backup mode.
+ */
+class LibreMailBackupAgent : BackupAgentHelper() {
+
+ override fun onFullBackup(data: FullBackupDataOutput) {
+ if (backupOptedIn()) {
+ super.onFullBackup(data)
+ }
+ }
+
+ /** Reads the opt-in flag; any failure defaults to "not opted in" so we never back up by accident. */
+ private fun backupOptedIn(): Boolean = runCatching {
+ runBlocking {
+ BackupPolicy.shouldBackUp(applicationContext.settingsDataStore.data.first().toAppSettings())
+ }
+ }.getOrDefault(false)
+}
diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
index 6df882d..553ae00 100644
--- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
+++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.settings
+import android.app.backup.BackupManager
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
@@ -15,7 +16,15 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
-private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "libremail_settings")
+/**
+ * User-preferences DataStore. Exposed as `internal` (not `private`) and read via [toAppSettings] so
+ * that [org.libremail.backup.LibreMailBackupAgent] can consult the backup opt-in flag through the
+ * exact same singleton instance. The system instantiates the backup agent in the app process while
+ * the app may already hold this DataStore open; constructing a second DataStore for the same file
+ * would crash with "There are multiple DataStores active for the same file", so both sides must go
+ * through this one delegate.
+ */
+internal val Context.settingsDataStore: DataStore by preferencesDataStore(name = "libremail_settings")
/**
* How aggressively the app downloads message content during sync.
@@ -33,23 +42,40 @@ data class AppSettings(
val allowStartTls: Boolean = false,
val loadRemoteImages: Boolean = false,
val encryptCache: 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 INCLUDE_IN_BACKUP = booleanPreferencesKey("include_in_backup")
+ val FETCH_POLICY = stringPreferencesKey("fetch_policy")
+}
+
+/**
+ * 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,
+ includeInBackup = this[Keys.INCLUDE_IN_BACKUP] ?: false,
+ fetchPolicy = this[Keys.FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
+ ?: FetchPolicy.ALWAYS,
+)
+
@Singleton
class SettingsRepository @Inject constructor(@ApplicationContext private val context: Context) {
- val settings: Flow = context.settingsDataStore.data.map { prefs ->
- AppSettings(
- dynamicColor = prefs[DYNAMIC_COLOR] ?: true,
- newMailNotifications = prefs[NEW_MAIL_NOTIFICATIONS] ?: true,
- pushIdle = prefs[PUSH_IDLE] ?: true,
- allowStartTls = prefs[ALLOW_STARTTLS] ?: false,
- loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false,
- encryptCache = prefs[ENCRYPT_CACHE] ?: false,
- fetchPolicy = prefs[FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
- ?: FetchPolicy.ALWAYS,
- )
- }
+ val settings: Flow = context.settingsDataStore.data.map { it.toAppSettings() }
val dynamicColor: Flow = settings.map { it.dynamicColor }
@@ -57,27 +83,28 @@ 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 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)
+
+ /**
+ * Opts this app in/out of system Android Backup. Off by default. After persisting, nudges the
+ * framework so the change takes effect on the next backup pass — enabling schedules a backup of
+ * the safe settings, disabling schedules one that ships nothing (clearing any prior cloud copy).
+ */
+ suspend fun setIncludeInBackup(value: Boolean) {
+ put(Keys.INCLUDE_IN_BACKUP, value)
+ runCatching { BackupManager(context).dataChanged() }
+ }
+
suspend fun setFetchPolicy(value: FetchPolicy) {
- context.settingsDataStore.edit { it[FETCH_POLICY] = value.name }
+ context.settingsDataStore.edit { it[Keys.FETCH_POLICY] = value.name }
}
private suspend fun put(key: Preferences.Key, value: Boolean) {
context.settingsDataStore.edit { it[key] = value }
}
-
- private companion object {
- val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
- val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications")
- val PUSH_IDLE = booleanPreferencesKey("push_idle")
- val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls")
- val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images")
- val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache")
- val FETCH_POLICY = stringPreferencesKey("fetch_policy")
- }
}
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
index 32d925b..cab7c02 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
@@ -113,6 +113,15 @@ fun SettingsScreen(
)
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 {
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
index 6006e83..52da6d4 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
@@ -41,6 +41,7 @@ class SettingsViewModel @Inject constructor(
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) }
private inline fun update(crossinline action: suspend () -> Unit) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a4df47f..33b1ab9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -143,6 +143,11 @@
No accounts yet
Remove account
+
+ Backup
+ Include settings in Android Backup
+ Let Android back up your LibreMail preferences (Google Auto Backup) so they restore when you set up a new device. Your mail, accounts, passwords, and encryption keys are never backed up — only app settings. Off by default; uses Google infrastructure.
+
Account
Signature
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..0059fd1
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
index 39841b1..0f07896 100644
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -1,17 +1,26 @@
-
+
-
+
diff --git a/app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt b/app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt
new file mode 100644
index 0000000..c9d0924
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.backup
+
+import org.junit.Test
+import org.libremail.data.settings.AppSettings
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class BackupPolicyTest {
+
+ @Test
+ fun `backup is off by default`() {
+ assertFalse(AppSettings().includeInBackup, "the opt-in default must be off")
+ assertFalse(BackupPolicy.shouldBackUp(AppSettings()), "no backup runs without opting in")
+ }
+
+ @Test
+ fun `backup runs only when the user opts in`() {
+ assertTrue(BackupPolicy.shouldBackUp(AppSettings(includeInBackup = true)))
+ assertFalse(BackupPolicy.shouldBackUp(AppSettings(includeInBackup = false)))
+ }
+
+ @Test
+ fun `only the settings datastore is eligible for backup`() {
+ assertEquals("datastore/libremail_settings.preferences_pb", BackupPolicy.SAFE_SETTINGS_FILE)
+ // The safe file must not be, or resemble, a secret store.
+ assertFalse(BackupPolicy.SAFE_SETTINGS_FILE.contains("dbkey"))
+ }
+
+ @Test
+ fun `the keystore-sealed db key is never eligible for backup`() {
+ assertTrue(
+ BackupPolicy.EXCLUDED_FILE_PATHS.any { it.contains("libremail_dbkey") },
+ "the sealed cache passphrase DataStore must be excluded",
+ )
+ }
+
+ @Test
+ fun `the credentials and mail-cache database is never eligible for backup`() {
+ assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db"))
+ // WAL/SHM/journal side-files can hold recently written rows too.
+ assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-wal"))
+ assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-shm"))
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt b/app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt
new file mode 100644
index 0000000..2de642c
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.backup
+
+import org.junit.Test
+import org.w3c.dom.Element
+import java.io.File
+import javax.xml.parsers.DocumentBuilderFactory
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Validates the shipped Android Backup rule resources directly, so they can't silently drift from
+ * [BackupPolicy] or from the acceptance criteria of issue #21: only the settings DataStore may be
+ * eligible, and the Keystore-sealed cache key plus the credentials/mail database must be excluded.
+ */
+class DataExtractionRulesTest {
+
+ private data class Rules(val includes: Set, val excludes: Set)
+
+ private fun resource(name: String): File {
+ // Gradle runs unit tests with the module dir (app/) as the working dir; fall back to the repo
+ // root in case a runner starts elsewhere.
+ val candidates = listOf(
+ File("src/main/res/xml/$name"),
+ File("app/src/main/res/xml/$name"),
+ )
+ return candidates.firstOrNull { it.exists() }
+ ?: error("Could not locate $name; looked in ${candidates.map { it.absolutePath }}")
+ }
+
+ /** Collects the `domain:path` pairs of every / under the given section element. */
+ private fun parseSection(file: File, sectionTag: String): Rules {
+ val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
+ val section = doc.getElementsByTagName(sectionTag).item(0) as Element
+ fun collect(tag: String): Set {
+ val nodes = section.getElementsByTagName(tag)
+ return (0 until nodes.length).map { i ->
+ val e = nodes.item(i) as Element
+ "${e.getAttribute("domain")}:${e.getAttribute("path")}"
+ }.toSet()
+ }
+ return Rules(includes = collect("include"), excludes = collect("exclude"))
+ }
+
+ private val safeFile = "file:${BackupPolicy.SAFE_SETTINGS_FILE}"
+ private val secretPaths: List =
+ BackupPolicy.EXCLUDED_FILE_PATHS.map { "file:$it" } +
+ BackupPolicy.EXCLUDED_DATABASE_PATHS.map { "database:$it" }
+
+ private fun assertSafe(rules: Rules) {
+ // Strict allowlist: the settings DataStore is the ONLY thing eligible for backup/transfer.
+ // Everything else — crucially the Keystore-sealed cache key and the credentials/mail
+ // database — is excluded simply by not being listed.
+ assertEquals(setOf(safeFile), rules.includes, "only the settings DataStore may be backed up")
+ assertTrue(rules.excludes.isEmpty(), "rules are allowlist-only; no entries expected")
+ secretPaths.forEach { secret ->
+ assertFalse(secret in rules.includes, "$secret must never be eligible for backup")
+ }
+ }
+
+ @Test
+ fun `data extraction rules (API 31+) back up only settings for cloud backup`() {
+ assertSafe(parseSection(resource("data_extraction_rules.xml"), "cloud-backup"))
+ }
+
+ @Test
+ fun `data extraction rules (API 31+) back up only settings for device transfer`() {
+ assertSafe(parseSection(resource("data_extraction_rules.xml"), "device-transfer"))
+ }
+
+ @Test
+ fun `full backup content (API 29-30) mirrors the same exclusions`() {
+ assertSafe(parseSection(resource("backup_rules.xml"), "full-backup-content"))
+ }
+}
From d1b0593a8c01bceaa13d2947a1a62c3d4696cca7 Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Wed, 1 Jul 2026 00:03:13 -0500
Subject: [PATCH 03/11] feat(reporting): opt-in debug reporting client (capture
+ review/submit)
Implements #32 and #33: a strictly opt-in, F-Droid-safe debug reporting
client. Nothing ever leaves the device unless the user taps Submit.
#32 capture:
- CrashReporter installs a Thread.setDefaultUncaughtExceptionHandler (wired in
LibreMailApplication) that persists a structured crash record (stack trace +
app/version/device metadata + recent log ring buffer) locally, then delegates
to the previous handler. Never auto-sent.
- RingLogBuffer + AppLog: a bounded in-memory, non-PII log ring buffer.
- DiagnosticsCollector assembles a minimal bundle: app version, Android/device,
a fixed non-PII settings allow-list, and the log buffer.
- ReportStore persists pending reports as JSON files (not Room, so crash-time
saves are robust and independent of the encrypted/migrating DB).
- "Report a problem" entry point in Settings creates a report on demand.
#33 review & submit:
- ReportReviewScreen shows the full payload verbatim (exactly what would be
sent), a free-text comment field, and a prominent PII disclaimer, with
explicit Submit / Discard and Copy / Save-to-file alternatives.
- Submission is user-initiated only: ReportUploadWorker (WorkManager, queue +
retry, success/failure surfaced) POSTs to BuildConfig.DEBUG_REPORT_ENDPOINT,
which is EMPTY by default (ingest server #34 is out of scope for this repo).
- On next launch a saved crash report is offered for review via a dialog.
Tests: 23 JVM unit tests covering crash capture + persistence (offered next
launch), diagnostic-bundle assembly (minimal, non-PII), JSON round-trip, and
the "nothing sent without Submit" invariant.
Closes #32
Closes #33
Co-Authored-By: Claude Opus 4.8
---
app/build.gradle.kts | 6 +
.../org/libremail/LibreMailApplication.kt | 21 ++
.../org/libremail/di/ReportingModule.kt | 22 ++
.../kotlin/org/libremail/reporting/AppLog.kt | 39 +++
.../libremail/reporting/AppVersionProvider.kt | 31 +++
.../org/libremail/reporting/CrashReporter.kt | 53 ++++
.../org/libremail/reporting/DebugReport.kt | 94 +++++++
.../reporting/DiagnosticsCollector.kt | 69 ++++++
.../org/libremail/reporting/ReportStore.kt | 50 ++++
.../libremail/reporting/ReportSubmitter.kt | 38 +++
.../reporting/ReportUploadScheduler.kt | 47 ++++
.../libremail/reporting/ReportUploadWorker.kt | 76 ++++++
.../org/libremail/reporting/RingLogBuffer.kt | 39 +++
.../kotlin/org/libremail/ui/LibreMailApp.kt | 56 ++++-
.../org/libremail/ui/navigation/Routes.kt | 5 +
.../ui/reporting/ProblemReportsScreen.kt | 122 ++++++++++
.../ui/reporting/ProblemReportsViewModel.kt | 54 ++++
.../ui/reporting/ReportReviewScreen.kt | 230 ++++++++++++++++++
.../ui/reporting/ReportReviewViewModel.kt | 96 ++++++++
.../ui/reporting/StartupReportViewModel.kt | 46 ++++
.../libremail/ui/settings/SettingsScreen.kt | 9 +
app/src/main/res/values/strings.xml | 30 +++
.../libremail/reporting/CrashReporterTest.kt | 58 +++++
.../libremail/reporting/DebugReportTest.kt | 72 ++++++
.../reporting/DiagnosticsCollectorTest.kt | 81 ++++++
.../libremail/reporting/ReportStoreTest.kt | 81 ++++++
.../libremail/reporting/RingLogBufferTest.kt | 51 ++++
.../ui/reporting/ReportReviewViewModelTest.kt | 124 ++++++++++
28 files changed, 1699 insertions(+), 1 deletion(-)
create mode 100644 app/src/main/kotlin/org/libremail/di/ReportingModule.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/AppLog.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/DebugReport.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/ReportStore.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt
create mode 100644 app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt
create mode 100644 app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4f0f640..f4e7a93 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -37,6 +37,11 @@ val outlookOAuthClientId: String = secrets.getProperty(
"04e4aa5e-ed1f-47f9-b567-b99a0b29b3df",
)
+// 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")
@@ -59,6 +64,7 @@ android {
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\"")
+ buildConfigField("String", "DEBUG_REPORT_ENDPOINT", "\"$debugReportEndpoint\"")
// AppAuth captures the OAuth redirect via this custom scheme.
manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme
}
diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt
index f7e7d4c..8c24366 100644
--- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt
+++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt
@@ -16,6 +16,10 @@ import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.domain.repository.AccountRepository
import org.libremail.push.IdlePushManager
+import org.libremail.reporting.AppLog
+import org.libremail.reporting.CrashReporter
+import org.libremail.reporting.DiagnosticsCollector
+import org.libremail.reporting.RingLogBuffer
import javax.inject.Inject
@HiltAndroidApp
@@ -33,6 +37,12 @@ class LibreMailApplication :
@Inject lateinit var idlePushManager: IdlePushManager
+ @Inject lateinit var ringLogBuffer: RingLogBuffer
+
+ @Inject lateinit var crashReporter: CrashReporter
+
+ @Inject lateinit var diagnosticsCollector: DiagnosticsCollector
+
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
@@ -46,6 +56,13 @@ class LibreMailApplication :
override fun onCreate() {
super.onCreate()
+ // Wire up debug reporting first so crashes during the rest of startup are still captured.
+ AppLog.install(ringLogBuffer)
+ crashReporter.install()
+ AppLog.i(TAG, "Application created")
+ // Warm the settings cache so a later crash report can include non-PII settings without
+ // touching DataStore on the crashing thread.
+ appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } }
syncScheduler.schedulePeriodicSync()
// Run the IMAP IDLE push service only while it has something to do: the push setting is on
// AND at least one account exists. This starts it when the first account is added and stops
@@ -72,4 +89,8 @@ class LibreMailApplication :
fun ensurePushStarted() {
if (pushShouldBeActive) idlePushManager.start()
}
+
+ private companion object {
+ const val TAG = "LibreMail"
+ }
}
diff --git a/app/src/main/kotlin/org/libremail/di/ReportingModule.kt b/app/src/main/kotlin/org/libremail/di/ReportingModule.kt
new file mode 100644
index 0000000..cf79ebd
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/di/ReportingModule.kt
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import org.libremail.reporting.ReportStore
+import java.io.File
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ReportingModule {
+
+ @Provides
+ @Singleton
+ fun provideReportStore(@ApplicationContext context: Context): ReportStore =
+ ReportStore(File(context.filesDir, "debug_reports"))
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/AppLog.kt b/app/src/main/kotlin/org/libremail/reporting/AppLog.kt
new file mode 100644
index 0000000..74a2b7f
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/AppLog.kt
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import android.util.Log
+
+/**
+ * Logging facade that mirrors Logcat output into the process [RingLogBuffer] so recent activity can
+ * be attached to a user-reviewed [DebugReport]. Call [install] once at startup. Never pass PII
+ * (email addresses, message content, credentials) to these methods — the buffer can end up in a
+ * report the user reviews and may submit.
+ */
+object AppLog {
+ @Volatile
+ private var buffer: RingLogBuffer? = null
+
+ fun install(buffer: RingLogBuffer) {
+ this.buffer = buffer
+ }
+
+ fun d(tag: String, message: String) {
+ Log.d(tag, message)
+ buffer?.record('D', tag, message)
+ }
+
+ fun i(tag: String, message: String) {
+ Log.i(tag, message)
+ buffer?.record('I', tag, message)
+ }
+
+ fun w(tag: String, message: String) {
+ Log.w(tag, message)
+ buffer?.record('W', tag, message)
+ }
+
+ fun e(tag: String, message: String, throwable: Throwable? = null) {
+ Log.e(tag, message, throwable)
+ buffer?.record('E', tag, message)
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt b/app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt
new file mode 100644
index 0000000..3d6e7ee
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/AppVersionProvider.kt
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Resolves the app's version once, tolerating any lookup failure. */
+@Singleton
+class AppVersionProvider @Inject constructor(@ApplicationContext context: Context) {
+ val versionName: String
+ val versionCode: Long
+
+ init {
+ var name = UNKNOWN
+ var code = 0L
+ runCatching {
+ @Suppress("DEPRECATION") // getPackageInfo(String, int) is fine for a plain version lookup.
+ val info = context.packageManager.getPackageInfo(context.packageName, 0)
+ name = info.versionName ?: UNKNOWN
+ code = info.longVersionCode
+ }
+ versionName = name
+ versionCode = code
+ }
+
+ private companion object {
+ const val UNKNOWN = "unknown"
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt b/app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt
new file mode 100644
index 0000000..d889dd4
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/CrashReporter.kt
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import android.os.Process
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.system.exitProcess
+
+/**
+ * Installs a process-wide uncaught-exception handler that persists a crash [DebugReport] locally for
+ * review on next launch. Reports are NEVER sent automatically — submission is strictly user-initiated
+ * (see [ReportSubmitter]). The previous handler is always invoked so the system still shows the crash
+ * and terminates the process.
+ */
+@Singleton
+class CrashReporter @Inject constructor(
+ private val collector: DiagnosticsCollector,
+ private val store: ReportStore,
+ private val logBuffer: RingLogBuffer,
+) {
+ fun install() {
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ persist(throwable)
+ if (previous != null) {
+ previous.uncaughtException(thread, throwable)
+ } else {
+ terminate()
+ }
+ }
+ }
+
+ /**
+ * Saves a crash report. Wrapped so a failure here can never mask the original crash. Exposed for
+ * tests; installing the handler simply routes uncaught exceptions here.
+ */
+ fun persist(throwable: Throwable) {
+ runCatching {
+ logBuffer.record('E', TAG, "Uncaught exception: ${throwable.javaClass.name}")
+ store.save(collector.collectCrash(throwable))
+ }
+ }
+
+ private fun terminate() {
+ Process.killProcess(Process.myPid())
+ exitProcess(EXIT_FAILURE)
+ }
+
+ private companion object {
+ const val TAG = "CrashReporter"
+ const val EXIT_FAILURE = 10
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt b/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt
new file mode 100644
index 0000000..3b6d929
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import org.json.JSONArray
+import org.json.JSONObject
+import java.time.Instant
+
+/** Whether a report was produced by an uncaught crash or requested manually by the user. */
+enum class ReportKind { CRASH, MANUAL }
+
+/**
+ * A locally-stored diagnostic report. It never leaves the device unless the user explicitly submits
+ * it (see [ReportSubmitter]); [toSubmissionPayload] is the single rendering that is shown for
+ * review, copied, saved to a file, and POSTed on submit, so what the user reads is exactly what is
+ * sent. Only the fields assembled by `DiagnosticsCollector` are captured — no message content.
+ */
+@Suppress("LongParameterList") // A flat diagnostic DTO; grouping fields would only obscure the payload.
+data class DebugReport(
+ val id: String,
+ val createdAtMillis: Long,
+ val kind: ReportKind,
+ val appVersionName: String,
+ val appVersionCode: Long,
+ val androidRelease: String,
+ val androidSdkInt: Int,
+ val deviceManufacturer: String,
+ val deviceModel: String,
+ val stackTrace: String?,
+ val settings: Map,
+ val logs: List,
+ val userComment: String = "",
+) {
+ /** The exact text shown for review, copied, saved to a file, and POSTed on submit. */
+ fun toSubmissionPayload(): String = toJson().toString(JSON_INDENT)
+
+ /** Compact form used for on-disk persistence. */
+ fun toStorageJson(): String = toJson().toString()
+
+ private fun toJson(): JSONObject {
+ val app = JSONObject()
+ .put("versionName", appVersionName)
+ .put("versionCode", appVersionCode)
+ val device = JSONObject()
+ .put("manufacturer", deviceManufacturer)
+ .put("model", deviceModel)
+ .put("androidRelease", androidRelease)
+ .put("sdkInt", androidSdkInt)
+ val settingsJson = JSONObject()
+ settings.forEach { (key, value) -> settingsJson.put(key, value) }
+ val json = JSONObject()
+ .put("id", id)
+ .put("createdAt", Instant.ofEpochMilli(createdAtMillis).toString())
+ .put("createdAtMillis", createdAtMillis)
+ .put("kind", kind.name)
+ .put("app", app)
+ .put("device", device)
+ .put("userComment", userComment)
+ .put("settings", settingsJson)
+ .put("logs", JSONArray(logs))
+ if (stackTrace != null) json.put("stackTrace", stackTrace)
+ return json
+ }
+
+ companion object {
+ private const val JSON_INDENT = 2
+
+ fun fromStorageJson(raw: String): DebugReport {
+ val json = JSONObject(raw)
+ val app = json.getJSONObject("app")
+ val device = json.getJSONObject("device")
+ val settingsJson = json.getJSONObject("settings")
+ val settings = LinkedHashMap()
+ settingsJson.keys().forEach { key -> settings[key] = settingsJson.getString(key) }
+ val logsJson = json.getJSONArray("logs")
+ val logs = ArrayList(logsJson.length())
+ for (i in 0 until logsJson.length()) logs.add(logsJson.getString(i))
+ return DebugReport(
+ id = json.getString("id"),
+ createdAtMillis = json.getLong("createdAtMillis"),
+ kind = ReportKind.valueOf(json.getString("kind")),
+ appVersionName = app.getString("versionName"),
+ appVersionCode = app.getLong("versionCode"),
+ androidRelease = device.getString("androidRelease"),
+ androidSdkInt = device.getInt("sdkInt"),
+ deviceManufacturer = device.getString("manufacturer"),
+ deviceModel = device.getString("model"),
+ stackTrace = if (json.has("stackTrace")) json.getString("stackTrace") else null,
+ settings = settings,
+ logs = logs,
+ userComment = json.optString("userComment", ""),
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt b/app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt
new file mode 100644
index 0000000..6b09218
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/DiagnosticsCollector.kt
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import android.os.Build
+import kotlinx.coroutines.flow.first
+import org.libremail.data.settings.AppSettings
+import org.libremail.data.settings.SettingsRepository
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Assembles a [DebugReport] from app/device metadata, a stack trace (for crashes), a minimal
+ * non-PII settings summary, and the recent in-app log buffer. Only the fields listed in [summarize]
+ * are captured — deliberately no account emails, server names, or message content.
+ */
+@Singleton
+class DiagnosticsCollector @Inject constructor(
+ private val appVersion: AppVersionProvider,
+ private val settingsRepository: SettingsRepository,
+ private val logBuffer: RingLogBuffer,
+) {
+ // Cached so a crash report (built synchronously on the crashing thread) can still include
+ // settings without touching DataStore. Refreshed whenever settings are read for a manual
+ // report or explicitly warmed at startup.
+ @Volatile
+ private var cachedSettings: Map = emptyMap()
+
+ /** Pre-reads settings so a later crash report can include them. Safe to call and ignore. */
+ suspend fun warmSettingsCache() {
+ cachedSettings = summarize(settingsRepository.settings.first())
+ }
+
+ /** Builds a report for a user-initiated ("Report a problem") request; includes live settings. */
+ suspend fun collectManual(): DebugReport {
+ val settings = summarize(settingsRepository.settings.first())
+ cachedSettings = settings
+ return build(ReportKind.MANUAL, throwable = null, settings = settings)
+ }
+
+ /** Builds a crash report synchronously; it must not block or throw on the crashing thread. */
+ fun collectCrash(throwable: Throwable): DebugReport =
+ build(ReportKind.CRASH, throwable = throwable, settings = cachedSettings)
+
+ private fun build(kind: ReportKind, throwable: Throwable?, settings: Map) = DebugReport(
+ id = UUID.randomUUID().toString(),
+ createdAtMillis = System.currentTimeMillis(),
+ kind = kind,
+ appVersionName = appVersion.versionName,
+ appVersionCode = appVersion.versionCode,
+ androidRelease = Build.VERSION.RELEASE ?: "",
+ androidSdkInt = Build.VERSION.SDK_INT,
+ deviceManufacturer = Build.MANUFACTURER ?: "",
+ deviceModel = Build.MODEL ?: "",
+ stackTrace = throwable?.stackTraceToString(),
+ settings = settings,
+ logs = logBuffer.snapshot().map { it.formatted() },
+ )
+
+ private fun summarize(settings: AppSettings): Map = linkedMapOf(
+ "dynamicColor" to settings.dynamicColor.toString(),
+ "newMailNotifications" to settings.newMailNotifications.toString(),
+ "pushIdle" to settings.pushIdle.toString(),
+ "allowStartTls" to settings.allowStartTls.toString(),
+ "loadRemoteImages" to settings.loadRemoteImages.toString(),
+ "encryptCache" to settings.encryptCache.toString(),
+ "fetchPolicy" to settings.fetchPolicy.name,
+ )
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt
new file mode 100644
index 0000000..247b1ed
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.io.File
+
+/**
+ * File-backed store of pending [DebugReport]s — one JSON file per report under [directory].
+ * Deliberately NOT Room-backed: a crash-time save must be simple and robust, independent of the
+ * (possibly encrypted, possibly mid-migration) app database. Reports persist until the user submits
+ * or discards them. [reports] is a snapshot that updates on every [save]/[delete].
+ */
+class ReportStore(private val directory: File) {
+ private val lock = Any()
+ private val _reports = MutableStateFlow(scan())
+ val reports: StateFlow> = _reports.asStateFlow()
+
+ fun save(report: DebugReport) {
+ synchronized(lock) {
+ directory.mkdirs()
+ File(directory, fileName(report.id)).writeText(report.toStorageJson())
+ _reports.value = scan()
+ }
+ }
+
+ fun find(id: String): DebugReport? = _reports.value.firstOrNull { it.id == id }
+
+ fun delete(id: String) {
+ synchronized(lock) {
+ File(directory, fileName(id)).delete()
+ _reports.value = scan()
+ }
+ }
+
+ private fun scan(): List {
+ val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) }
+ ?: return emptyList()
+ return files
+ .mapNotNull { file -> runCatching { DebugReport.fromStorageJson(file.readText()) }.getOrNull() }
+ .sortedByDescending { it.createdAtMillis }
+ }
+
+ private fun fileName(id: String) = "$id$SUFFIX"
+
+ private companion object {
+ const val SUFFIX = ".json"
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt b/app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt
new file mode 100644
index 0000000..b116381
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/ReportSubmitter.kt
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import androidx.work.WorkInfo
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.libremail.BuildConfig
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Coarse state of a user-initiated report submission. */
+enum class SubmitStatus { IDLE, SUBMITTING, SUCCEEDED, FAILED }
+
+/**
+ * The single seam through which a report can leave the device, and only when the user taps Submit.
+ * [isEnabled] is false when no ingest endpoint is configured (the default in this repo), so the UI
+ * can steer the user to Copy/Save instead of a submission that would only fail.
+ */
+@Singleton
+class ReportSubmitter @Inject constructor(private val scheduler: ReportUploadScheduler) {
+ val isEnabled: Boolean get() = BuildConfig.DEBUG_REPORT_ENDPOINT.isNotBlank()
+
+ fun submit(reportId: String) {
+ scheduler.enqueue(reportId)
+ }
+
+ fun status(reportId: String): Flow = scheduler.statusFlow(reportId).map(::toStatus)
+
+ private fun toStatus(infos: List): SubmitStatus {
+ if (infos.isEmpty()) return SubmitStatus.IDLE
+ return when {
+ infos.any { it.state == WorkInfo.State.FAILED || it.state == WorkInfo.State.CANCELLED } ->
+ SubmitStatus.FAILED
+ infos.all { it.state == WorkInfo.State.SUCCEEDED } -> SubmitStatus.SUCCEEDED
+ else -> SubmitStatus.SUBMITTING
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt b/app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt
new file mode 100644
index 0000000..370354a
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/ReportUploadScheduler.kt
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import android.content.Context
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Enqueues a user-initiated report upload as a retrying WorkManager job and observes its state. */
+@Singleton
+class ReportUploadScheduler @Inject constructor(@ApplicationContext private val context: Context) {
+ fun enqueue(reportId: String) {
+ val request = OneTimeWorkRequestBuilder()
+ .setConstraints(
+ Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
+ )
+ .setInputData(workDataOf(ReportUploadWorker.KEY_REPORT_ID to reportId))
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
+ .addTag(TAG)
+ .build()
+ // REPLACE: a fresh Submit tap starts a clean attempt for this report, overriding any pending
+ // retry-backoff. Reports are keyed per id, so distinct reports never collide.
+ WorkManager.getInstance(context)
+ .enqueueUniqueWork(uniqueName(reportId), ExistingWorkPolicy.REPLACE, request)
+ }
+
+ fun statusFlow(reportId: String): Flow> =
+ WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(uniqueName(reportId))
+
+ private fun uniqueName(reportId: String) = "$WORK_PREFIX$reportId"
+
+ companion object {
+ const val TAG = "libremail_report_upload"
+ private const val WORK_PREFIX = "libremail_report_upload_"
+ private const val BACKOFF_SECONDS = 30L
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt b/app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt
new file mode 100644
index 0000000..444b3c7
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.libremail.BuildConfig
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Posts a single user-submitted report to the ingest endpoint. It runs ONLY when the user tapped
+ * Submit (enqueued by [ReportUploadScheduler]); nothing here runs automatically. The endpoint is
+ * [BuildConfig.DEBUG_REPORT_ENDPOINT] — empty by default, because the ingest server (issue #34) is
+ * out of scope for this repo, so submissions no-op with a clear failure until an endpoint is set.
+ */
+@HiltWorker
+class ReportUploadWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted params: WorkerParameters,
+ private val store: ReportStore,
+) : CoroutineWorker(appContext, params) {
+
+ override suspend fun doWork(): Result {
+ val id = inputData.getString(KEY_REPORT_ID) ?: return Result.success()
+ val report = store.find(id) ?: return Result.success() // discarded before the job ran
+ val endpoint = BuildConfig.DEBUG_REPORT_ENDPOINT
+ if (endpoint.isBlank()) return Result.failure() // no ingest server configured in this build
+ return withContext(Dispatchers.IO) {
+ runCatching { post(endpoint, report.toSubmissionPayload()) }.fold(
+ onSuccess = { code -> onResponse(code, id) },
+ onFailure = { retryOrFail() }, // network error — retry with backoff
+ )
+ }
+ }
+
+ private fun onResponse(code: Int, id: String): Result = when {
+ code in SUCCESS_CODES -> {
+ store.delete(id) // delivered — drop the local copy
+ Result.success()
+ }
+ code in SERVER_ERROR_CODES -> retryOrFail() // transient server-side failure
+ else -> Result.failure() // client error (4xx) — retrying won't help
+ }
+
+ private fun retryOrFail(): Result = if (runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
+
+ private fun post(endpoint: String, body: String): Int {
+ val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ doOutput = true
+ connectTimeout = TIMEOUT_MS
+ readTimeout = TIMEOUT_MS
+ setRequestProperty("Content-Type", "application/json; charset=utf-8")
+ }
+ return try {
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
+ connection.responseCode
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ companion object {
+ const val KEY_REPORT_ID = "report_id"
+ private val SUCCESS_CODES = 200..299
+ private val SERVER_ERROR_CODES = 500..599
+ private const val TIMEOUT_MS = 15_000
+ private const val MAX_ATTEMPTS = 5
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt b/app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt
new file mode 100644
index 0000000..390bbb3
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/reporting/RingLogBuffer.kt
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import java.time.Instant
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** One captured log line. */
+data class LogEntry(val timeMillis: Long, val level: Char, val tag: String, val message: String) {
+ fun formatted(): String = "${Instant.ofEpochMilli(timeMillis)} $level/$tag: $message"
+}
+
+/**
+ * A small, thread-safe, in-memory ring buffer of recent app log lines. It is held in memory only
+ * (never written to disk on its own) and copied into a [DebugReport] on request. Callers must never
+ * record PII (email addresses, message content, credentials) — see [AppLog].
+ */
+@Singleton
+class RingLogBuffer @Inject constructor() {
+ private val lock = Any()
+ private val entries = ArrayDeque()
+
+ fun record(level: Char, tag: String, message: String) {
+ synchronized(lock) {
+ if (entries.size >= CAPACITY) entries.removeFirst()
+ entries.addLast(LogEntry(System.currentTimeMillis(), level, tag, message))
+ }
+ }
+
+ fun snapshot(): List = synchronized(lock) { entries.toList() }
+
+ fun clear() {
+ synchronized(lock) { entries.clear() }
+ }
+
+ private companion object {
+ const val CAPACITY = 200
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
index 0301b0f..282fe69 100644
--- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
+++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
@@ -1,12 +1,18 @@
// 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.getValue
import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
@@ -14,6 +20,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
+import org.libremail.R
import org.libremail.ui.accountsetup.AccountSetupScreen
import org.libremail.ui.accountsetup.ManualSetupScreen
import org.libremail.ui.compose.ComposeScreen
@@ -22,12 +29,16 @@ import org.libremail.ui.mailbox.MailboxScreen
import org.libremail.ui.navigation.Routes
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
@Composable
-fun LibreMailApp() {
+fun LibreMailApp(startupViewModel: StartupReportViewModel = hiltViewModel()) {
val navController = rememberNavController()
+ val pendingCrash by startupViewModel.pendingCrash.collectAsStateWithLifecycle()
NavHost(
navController = navController,
@@ -81,8 +92,21 @@ 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 }),
@@ -120,6 +144,36 @@ 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)) }
+ }
+ },
+ )
}
/** Navigate between top-level tabs, preserving each tab's back stack and state. */
diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt
index 5ddb2d5..e03afc0 100644
--- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt
+++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt
@@ -11,6 +11,7 @@ object Routes {
const val MANUAL_SETUP = "manual_setup"
const val DRAFTS = "drafts"
const val OUTBOX = "outbox"
+ const val PROBLEM_REPORTS = "problem_reports"
const val READER_ARG_ID = "messageId"
const val READER_PATTERN = "reader/{$READER_ARG_ID}"
@@ -20,6 +21,10 @@ object Routes {
const val ACCOUNT_SETTINGS_PATTERN = "account_settings/{$ACCOUNT_SETTINGS_ARG_ID}"
fun accountSettings(accountId: String) = "account_settings/${Uri.encode(accountId)}"
+ 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_SUBJECT = "subject"
const val COMPOSE_ARG_FROM = "from"
diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt
new file mode 100644
index 0000000..71367e1
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt
@@ -0,0 +1,122 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.reporting
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.libremail.R
+import org.libremail.reporting.ReportKind
+import java.text.DateFormat
+import java.util.Date
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProblemReportsScreen(
+ onBack: () -> Unit,
+ onOpenReport: (String) -> Unit,
+ viewModel: ProblemReportsViewModel = hiltViewModel(),
+) {
+ val reports by viewModel.reports.collectAsStateWithLifecycle()
+
+ // A newly created manual report opens straight into review.
+ LaunchedEffect(Unit) {
+ viewModel.created.collect { onOpenReport(it) }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.reports_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.action_back),
+ )
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ Button(
+ onClick = viewModel::createManualReport,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Text(stringResource(R.string.reports_create))
+ }
+ if (reports.isEmpty()) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ stringResource(R.string.reports_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ LazyColumn(Modifier.fillMaxSize()) {
+ items(reports, key = { it.id }) { report ->
+ ReportRow(report = report, onClick = { onOpenReport(report.id) })
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ReportRow(report: ReportSummary, onClick: () -> Unit) {
+ val kindLabel = when (report.kind) {
+ ReportKind.CRASH -> stringResource(R.string.report_kind_crash)
+ ReportKind.MANUAL -> stringResource(R.string.report_kind_manual)
+ }
+ val timestamp = remember(report.createdAtMillis) {
+ DateFormat.getDateTimeInstance().format(Date(report.createdAtMillis))
+ }
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ ) {
+ Text(kindLabel, style = MaterialTheme.typography.bodyLarge)
+ Text(
+ timestamp,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt
new file mode 100644
index 0000000..0b5c175
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsViewModel.kt
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.reporting
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.libremail.reporting.DebugReport
+import org.libremail.reporting.DiagnosticsCollector
+import org.libremail.reporting.ReportKind
+import org.libremail.reporting.ReportStore
+import javax.inject.Inject
+
+/** A row in the problem-reports list. */
+data class ReportSummary(val id: String, val kind: ReportKind, val createdAtMillis: Long)
+
+@HiltViewModel
+class ProblemReportsViewModel @Inject constructor(
+ private val store: ReportStore,
+ private val collector: DiagnosticsCollector,
+) : ViewModel() {
+
+ val reports: StateFlow> = store.reports
+ .map { list -> list.map { it.toSummary() } }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), emptyList())
+
+ // Emits the id of a freshly created report so the screen can open it for review immediately.
+ private val _created = MutableSharedFlow(extraBufferCapacity = 1)
+ val created: SharedFlow = _created
+
+ fun createManualReport() {
+ viewModelScope.launch {
+ val report = collector.collectManual()
+ store.save(report)
+ _created.tryEmit(report.id)
+ }
+ }
+
+ fun discard(id: String) {
+ viewModelScope.launch { store.delete(id) }
+ }
+
+ private fun DebugReport.toSummary() = ReportSummary(id, kind, createdAtMillis)
+
+ private companion object {
+ const val SUBSCRIBE_MS = 5_000L
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt
new file mode 100644
index 0000000..559bf64
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt
@@ -0,0 +1,230 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.reporting
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.libremail.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hiltViewModel()) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val clipboard = LocalClipboardManager.current
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ val savedMessage = stringResource(R.string.report_saved)
+ val copiedMessage = stringResource(R.string.report_copied)
+
+ val saveLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument("application/json"),
+ ) { uri ->
+ if (uri != null) {
+ val text = viewModel.payload()
+ scope.launch {
+ withContext(Dispatchers.IO) {
+ runCatching {
+ context.contentResolver.openOutputStream(uri)?.use {
+ it.write(text.toByteArray())
+ }
+ }
+ }
+ snackbarHostState.showSnackbar(savedMessage)
+ }
+ }
+ }
+
+ // Once the report has been submitted (deleted by the worker) or discarded, leave the screen.
+ LaunchedEffect(state.loaded, state.exists) {
+ if (state.loaded && !state.exists) onDone()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.report_review_title)) },
+ navigationIcon = {
+ IconButton(onClick = onDone) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.action_back),
+ )
+ }
+ },
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ ) {
+ PiiDisclaimer()
+ Spacer(Modifier.height(16.dp))
+ OutlinedTextField(
+ value = state.comment,
+ onValueChange = viewModel::updateComment,
+ label = { Text(stringResource(R.string.report_comment_label)) },
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 2,
+ )
+ Spacer(Modifier.height(16.dp))
+ Text(
+ stringResource(R.string.report_payload_label),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.height(8.dp))
+ PayloadBox(payload = state.payload)
+ SubmitStatusText(state.submit)
+ Spacer(Modifier.height(16.dp))
+ Button(
+ onClick = viewModel::submit,
+ enabled = state.submit != SubmitUiState.SUBMITTING,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.report_submit))
+ }
+ Spacer(Modifier.height(8.dp))
+ Row(Modifier.fillMaxWidth()) {
+ TextButton(
+ onClick = {
+ clipboard.setText(AnnotatedString(viewModel.payload()))
+ scope.launch { snackbarHostState.showSnackbar(copiedMessage) }
+ },
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(stringResource(R.string.report_copy))
+ }
+ Spacer(Modifier.width(8.dp))
+ TextButton(
+ onClick = { saveLauncher.launch("libremail-report.json") },
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(stringResource(R.string.report_save))
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ TextButton(
+ onClick = viewModel::discard,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ stringResource(R.string.report_discard),
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PiiDisclaimer() {
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(Modifier.padding(16.dp)) {
+ Icon(Icons.Filled.Warning, contentDescription = null)
+ Spacer(Modifier.width(12.dp))
+ Column {
+ Text(
+ stringResource(R.string.report_pii_disclaimer_title),
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ stringResource(R.string.report_pii_disclaimer),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PayloadBox(payload: String) {
+ SelectionContainer {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.small,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = payload,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.padding(12.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun SubmitStatusText(state: SubmitUiState) {
+ if (state == SubmitUiState.IDLE) return
+ val text = when (state) {
+ SubmitUiState.SUBMITTING -> stringResource(R.string.report_submitting)
+ SubmitUiState.SUCCEEDED -> stringResource(R.string.report_submitted)
+ SubmitUiState.FAILED -> stringResource(R.string.report_submit_failed)
+ SubmitUiState.UNAVAILABLE -> stringResource(R.string.report_submit_unavailable)
+ SubmitUiState.IDLE -> ""
+ }
+ val color = when (state) {
+ SubmitUiState.SUCCEEDED -> MaterialTheme.colorScheme.primary
+ SubmitUiState.FAILED, SubmitUiState.UNAVAILABLE -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(text, color = color, style = MaterialTheme.typography.bodyMedium)
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt
new file mode 100644
index 0000000..e42a8b7
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewViewModel.kt
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.reporting
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.libremail.reporting.ReportStore
+import org.libremail.reporting.ReportSubmitter
+import org.libremail.reporting.SubmitStatus
+import org.libremail.ui.navigation.Routes
+import javax.inject.Inject
+
+/** UI-facing status of a submission attempt. [UNAVAILABLE] means no endpoint is configured. */
+enum class SubmitUiState { IDLE, SUBMITTING, SUCCEEDED, FAILED, UNAVAILABLE }
+
+data class ReportReviewState(
+ val loaded: Boolean = false,
+ val exists: Boolean = false,
+ val payload: String = "",
+ val comment: String = "",
+ val canSubmitOnline: Boolean = false,
+ val submit: SubmitUiState = SubmitUiState.IDLE,
+)
+
+@HiltViewModel
+class ReportReviewViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val store: ReportStore,
+ private val submitter: ReportSubmitter,
+) : ViewModel() {
+
+ private val reportId: String = checkNotNull(savedStateHandle[Routes.REPORT_REVIEW_ARG_ID])
+ private val comment = MutableStateFlow(store.find(reportId)?.userComment.orEmpty())
+ private val submitState = MutableStateFlow(SubmitUiState.IDLE)
+
+ val state: StateFlow =
+ combine(store.reports, comment, submitState) { reports, currentComment, submit ->
+ val report = reports.firstOrNull { it.id == reportId }
+ ReportReviewState(
+ loaded = true,
+ exists = report != null,
+ // The comment is folded in so the preview is byte-for-byte what a submit would send.
+ payload = report?.copy(userComment = currentComment)?.toSubmissionPayload().orEmpty(),
+ comment = currentComment,
+ canSubmitOnline = submitter.isEnabled,
+ submit = submit,
+ )
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), ReportReviewState())
+
+ fun updateComment(value: String) {
+ comment.value = value
+ }
+
+ fun discard() {
+ viewModelScope.launch { store.delete(reportId) }
+ }
+
+ /**
+ * The only path that can send a report off-device, and only from an explicit Submit tap. Persists
+ * the reviewed comment first so the upload matches exactly what was shown, then enqueues the
+ * worker (unless no endpoint is configured, in which case it steers the user to Copy/Save).
+ */
+ fun submit() {
+ viewModelScope.launch {
+ val report = store.find(reportId) ?: return@launch
+ store.save(report.copy(userComment = comment.value))
+ if (!submitter.isEnabled) {
+ submitState.value = SubmitUiState.UNAVAILABLE
+ return@launch
+ }
+ submitter.submit(reportId)
+ submitState.value = SubmitUiState.SUBMITTING
+ submitter.status(reportId).collect { submitState.value = it.toUi() }
+ }
+ }
+
+ /** The exact text shown for review — used for Copy and Save-to-file. */
+ fun payload(): String = store.find(reportId)?.copy(userComment = comment.value)?.toSubmissionPayload().orEmpty()
+
+ private fun SubmitStatus.toUi(): SubmitUiState = when (this) {
+ SubmitStatus.IDLE, SubmitStatus.SUBMITTING -> SubmitUiState.SUBMITTING
+ SubmitStatus.SUCCEEDED -> SubmitUiState.SUCCEEDED
+ SubmitStatus.FAILED -> SubmitUiState.FAILED
+ }
+
+ private companion object {
+ const val SUBSCRIBE_MS = 5_000L
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt
new file mode 100644
index 0000000..89cfdec
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.reporting
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.libremail.reporting.ReportKind
+import org.libremail.reporting.ReportStore
+import javax.inject.Inject
+
+/** Surfaces a pending crash report (if any) so the app can offer it for review on launch. */
+@HiltViewModel
+class StartupReportViewModel @Inject constructor(private val store: ReportStore) : ViewModel() {
+
+ private val dismissed = MutableStateFlow(false)
+
+ val pendingCrash: StateFlow =
+ combine(store.reports, dismissed) { reports, isDismissed ->
+ if (isDismissed) {
+ null
+ } else {
+ reports.firstOrNull { it.kind == ReportKind.CRASH }
+ ?.let { ReportSummary(it.id, it.kind, it.createdAtMillis) }
+ }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), null)
+
+ /** Hides the prompt for this launch; the report stays saved and is offered again next launch. */
+ fun dismiss() {
+ dismissed.value = true
+ }
+
+ fun discard(id: String) {
+ dismissed.value = true
+ viewModelScope.launch { store.delete(id) }
+ }
+
+ private companion object {
+ const val SUBSCRIBE_MS = 5_000L
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
index 32d925b..3b13e91 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
@@ -42,6 +42,7 @@ fun SettingsScreen(
onAddAccount: () -> Unit,
onOpenAccount: (String) -> Unit,
onSelectTab: (TopDest) -> Unit,
+ onReportProblem: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val settings by viewModel.settings.collectAsStateWithLifecycle()
@@ -113,6 +114,14 @@ 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()
+
AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced)
AnimatedVisibility(visible = advancedExpanded) {
Column {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a4df47f..a29b5d4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -165,4 +165,34 @@
Load remote images by default
Encrypt local cache
Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail.
+
+
+ Diagnostics
+ Report a problem
+ Capture diagnostics you can review and optionally send
+ Problem reports
+ No problem reports yet
+ Create a report
+ Crash report
+ Manual report
+ Review report
+ May contain personal information
+ This report can include email addresses, server names, and other details from your device. Read the whole thing below before sending. Nothing is sent unless you tap Submit.
+ What went wrong? (optional)
+ Exactly what will be sent
+ Submit
+ Discard report
+ Copy
+ Save to file
+ Submitting…
+ Report sent. Thank you!
+ Couldn\'t send — it will be retried.
+ Online submission isn\'t available in this build. Use Copy or Save to share the report.
+ Copied to clipboard
+ Saved
+ LibreMail closed unexpectedly
+ A problem report from the last crash is ready for you to review. Nothing is sent automatically.
+ Review
+ Not now
+ Discard
diff --git a/app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt b/app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt
new file mode 100644
index 0000000..b09dd3a
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/reporting/CrashReporterTest.kt
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.libremail.data.settings.SettingsRepository
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class CrashReporterTest {
+
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private val appVersion = mockk {
+ every { versionName } returns "0.1.0"
+ every { versionCode } returns 1L
+ }
+ private val settingsRepository = mockk()
+
+ @Test
+ fun `persisting a forced crash saves a report offered on next launch`() {
+ val store = ReportStore(tempFolder.root)
+ val buffer = RingLogBuffer()
+ val collector = DiagnosticsCollector(appVersion, settingsRepository, buffer)
+ val reporter = CrashReporter(collector, store, buffer)
+
+ reporter.persist(IllegalStateException("forced crash"))
+
+ // Saved locally, with the stack trace and a crash breadcrumb captured.
+ val saved = store.reports.value.single()
+ assertEquals(ReportKind.CRASH, saved.kind)
+ assertTrue(saved.stackTrace.orEmpty().contains("forced crash"))
+ assertTrue(saved.logs.any { it.contains("Uncaught exception") })
+
+ // Still available to a fresh store instance, simulating the next app launch.
+ val nextLaunch = ReportStore(tempFolder.root)
+ assertEquals(1, nextLaunch.reports.value.size)
+ assertEquals(ReportKind.CRASH, nextLaunch.reports.value.single().kind)
+ }
+
+ @Test
+ fun `capture only persists — it has no path to transmit`() {
+ // CrashReporter is constructed without any submitter/scheduler, so a crash can only ever be
+ // written to the local store. Nothing here can send data off the device.
+ val store = ReportStore(tempFolder.root)
+ val buffer = RingLogBuffer()
+ val collector = DiagnosticsCollector(appVersion, settingsRepository, buffer)
+ val reporter = CrashReporter(collector, store, buffer)
+
+ reporter.persist(RuntimeException("boom"))
+
+ assertEquals(1, store.reports.value.size)
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt b/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt
new file mode 100644
index 0000000..b582078
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt
@@ -0,0 +1,72 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class DebugReportTest {
+
+ private fun sample(
+ kind: ReportKind = ReportKind.CRASH,
+ stackTrace: String? = "java.lang.RuntimeException: boom\n\tat Foo.bar(Foo.kt:1)",
+ comment: String = "",
+ ) = DebugReport(
+ id = "abc-123",
+ createdAtMillis = 1_700_000_000_000L,
+ kind = kind,
+ appVersionName = "0.1.0",
+ appVersionCode = 7,
+ androidRelease = "14",
+ androidSdkInt = 34,
+ deviceManufacturer = "Google",
+ deviceModel = "Pixel 8",
+ stackTrace = stackTrace,
+ settings = linkedMapOf("pushIdle" to "true", "fetchPolicy" to "ALWAYS"),
+ logs = listOf("line 1", "line 2"),
+ userComment = comment,
+ )
+
+ @Test
+ fun `round-trips through storage json`() {
+ val original = sample(comment = "please fix")
+
+ val restored = DebugReport.fromStorageJson(original.toStorageJson())
+
+ assertEquals(original, restored)
+ }
+
+ @Test
+ fun `round-trips a manual report with no stack trace`() {
+ val original = sample(kind = ReportKind.MANUAL, stackTrace = null)
+
+ val restored = DebugReport.fromStorageJson(original.toStorageJson())
+
+ assertEquals(ReportKind.MANUAL, restored.kind)
+ assertNull(restored.stackTrace)
+ assertEquals(original, restored)
+ }
+
+ @Test
+ fun `submission payload contains the fields the user should see`() {
+ val payload = sample(comment = "it froze").toSubmissionPayload()
+
+ assertTrue(payload.contains("\"kind\": \"CRASH\""), payload)
+ assertTrue(payload.contains("0.1.0"))
+ assertTrue(payload.contains("Pixel 8"))
+ assertTrue(payload.contains("it froze"))
+ assertTrue(payload.contains("boom"))
+ assertTrue(payload.contains("pushIdle"))
+ assertTrue(payload.contains("line 1"))
+ }
+
+ @Test
+ fun `submission payload reflects the edited comment`() {
+ val base = sample()
+
+ val edited = base.copy(userComment = "edited note").toSubmissionPayload()
+
+ assertTrue(edited.contains("edited note"))
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt b/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt
new file mode 100644
index 0000000..d478790
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.libremail.data.settings.AppSettings
+import org.libremail.data.settings.FetchPolicy
+import org.libremail.data.settings.SettingsRepository
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class DiagnosticsCollectorTest {
+
+ private val appVersion = mockk {
+ every { versionName } returns "1.2.3"
+ every { versionCode } returns 42L
+ }
+ private val settingsRepository = mockk()
+ private val logBuffer = RingLogBuffer()
+ private val collector = DiagnosticsCollector(appVersion, settingsRepository, logBuffer)
+
+ @Test
+ fun `crash report includes stack trace and app version`() = runTest {
+ val report = collector.collectCrash(RuntimeException("kaboom"))
+
+ assertEquals(ReportKind.CRASH, report.kind)
+ assertEquals("1.2.3", report.appVersionName)
+ assertEquals(42L, report.appVersionCode)
+ assertTrue(report.stackTrace.orEmpty().contains("kaboom"))
+ }
+
+ @Test
+ fun `manual report includes a minimal non-PII settings summary and no stack trace`() = runTest {
+ every { settingsRepository.settings } returns
+ flowOf(AppSettings(pushIdle = false, fetchPolicy = FetchPolicy.ON_DEMAND))
+
+ val report = collector.collectManual()
+
+ assertEquals(ReportKind.MANUAL, report.kind)
+ assertNull(report.stackTrace)
+ assertEquals("false", report.settings["pushIdle"])
+ assertEquals("ON_DEMAND", report.settings["fetchPolicy"])
+ // The summary is a fixed allow-list of non-PII flags — no account/server fields.
+ assertEquals(
+ setOf(
+ "dynamicColor",
+ "newMailNotifications",
+ "pushIdle",
+ "allowStartTls",
+ "loadRemoteImages",
+ "encryptCache",
+ "fetchPolicy",
+ ),
+ report.settings.keys,
+ )
+ assertTrue(report.settings.values.none { it.contains("@") })
+ }
+
+ @Test
+ fun `crash report includes settings once the cache is warmed`() = runTest {
+ every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true))
+ collector.warmSettingsCache()
+
+ val report = collector.collectCrash(RuntimeException("x"))
+
+ assertEquals("true", report.settings["encryptCache"])
+ }
+
+ @Test
+ fun `crash report captures recent in-app log lines`() = runTest {
+ logBuffer.record('I', "Startup", "hello-breadcrumb")
+
+ val report = collector.collectCrash(RuntimeException("x"))
+
+ assertTrue(report.logs.any { it.contains("hello-breadcrumb") })
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt
new file mode 100644
index 0000000..02e7072
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class ReportStoreTest {
+
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private fun report(id: String, createdAt: Long = 1L, kind: ReportKind = ReportKind.MANUAL) = DebugReport(
+ id = id,
+ createdAtMillis = createdAt,
+ kind = kind,
+ appVersionName = "0.1.0",
+ appVersionCode = 1,
+ androidRelease = "14",
+ androidSdkInt = 34,
+ deviceManufacturer = "Google",
+ deviceModel = "Pixel",
+ stackTrace = null,
+ settings = emptyMap(),
+ logs = emptyList(),
+ )
+
+ @Test
+ fun `save then find and list`() {
+ val store = ReportStore(tempFolder.root)
+
+ store.save(report("a"))
+
+ assertEquals("a", store.find("a")?.id)
+ assertEquals(listOf("a"), store.reports.value.map { it.id })
+ }
+
+ @Test
+ fun `lists newest first`() {
+ val store = ReportStore(tempFolder.root)
+
+ store.save(report("old", createdAt = 1L))
+ store.save(report("new", createdAt = 2L))
+
+ assertEquals(listOf("new", "old"), store.reports.value.map { it.id })
+ }
+
+ @Test
+ fun `delete removes the report`() {
+ val store = ReportStore(tempFolder.root)
+ store.save(report("a"))
+
+ store.delete("a")
+
+ assertNull(store.find("a"))
+ assertTrue(store.reports.value.isEmpty())
+ }
+
+ @Test
+ fun `survives a fresh instance over the same directory (next launch)`() {
+ ReportStore(tempFolder.root).save(report("persisted"))
+
+ val reopened = ReportStore(tempFolder.root)
+
+ assertEquals("persisted", reopened.find("persisted")?.id)
+ }
+
+ @Test
+ fun `ignores unparseable files`() {
+ File(tempFolder.root, "garbage.json").writeText("not json at all")
+ val store = ReportStore(tempFolder.root)
+
+ store.save(report("valid"))
+
+ assertEquals(listOf("valid"), store.reports.value.map { it.id })
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt b/app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt
new file mode 100644
index 0000000..f9386e0
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/reporting/RingLogBufferTest.kt
@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.reporting
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class RingLogBufferTest {
+
+ @Test
+ fun `records lines in order`() {
+ val buffer = RingLogBuffer()
+
+ buffer.record('I', "Tag", "first")
+ buffer.record('W', "Tag", "second")
+
+ val snapshot = buffer.snapshot()
+ assertEquals(2, snapshot.size)
+ assertEquals("first", snapshot[0].message)
+ assertEquals("second", snapshot[1].message)
+ assertEquals('W', snapshot[1].level)
+ }
+
+ @Test
+ fun `caps capacity and drops the oldest entries`() {
+ val buffer = RingLogBuffer()
+
+ repeat(TOTAL) { buffer.record('D', "Tag", "msg-$it") }
+
+ val snapshot = buffer.snapshot()
+ assertEquals(CAPACITY, snapshot.size)
+ // The most recent entry is retained; the very first was dropped.
+ assertEquals("msg-${TOTAL - 1}", snapshot.last().message)
+ assertTrue(snapshot.none { it.message == "msg-0" })
+ }
+
+ @Test
+ fun `clear empties the buffer`() {
+ val buffer = RingLogBuffer()
+ buffer.record('I', "Tag", "x")
+
+ buffer.clear()
+
+ assertTrue(buffer.snapshot().isEmpty())
+ }
+
+ private companion object {
+ const val CAPACITY = 200
+ const val TOTAL = 250
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt
new file mode 100644
index 0000000..79eb032
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelTest.kt
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.reporting
+
+import androidx.lifecycle.SavedStateHandle
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.libremail.reporting.DebugReport
+import org.libremail.reporting.ReportKind
+import org.libremail.reporting.ReportStore
+import org.libremail.reporting.ReportSubmitter
+import org.libremail.ui.navigation.Routes
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReportReviewViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Before fun setUp() = Dispatchers.setMain(testDispatcher)
+
+ @After fun tearDown() = Dispatchers.resetMain()
+
+ private val report = DebugReport(
+ id = "rid",
+ createdAtMillis = 1L,
+ kind = ReportKind.CRASH,
+ appVersionName = "0.1.0",
+ appVersionCode = 1,
+ androidRelease = "14",
+ androidSdkInt = 34,
+ deviceManufacturer = "Google",
+ deviceModel = "Pixel",
+ stackTrace = "boom",
+ settings = emptyMap(),
+ logs = emptyList(),
+ )
+
+ private val store = mockk(relaxed = false)
+ private val submitter = mockk()
+
+ private fun viewModel(): ReportReviewViewModel {
+ every { store.reports } returns MutableStateFlow(listOf(report))
+ every { store.find("rid") } returns report
+ return ReportReviewViewModel(
+ savedStateHandle = SavedStateHandle(mapOf(Routes.REPORT_REVIEW_ARG_ID to "rid")),
+ store = store,
+ submitter = submitter,
+ )
+ }
+
+ @Test
+ fun `editing the comment sends nothing and saves nothing`() = runTest(testDispatcher) {
+ every { submitter.isEnabled } returns true
+ val vm = viewModel()
+
+ vm.updateComment("just typing")
+
+ verify(exactly = 0) { submitter.submit(any()) }
+ verify(exactly = 0) { store.save(any()) }
+ }
+
+ @Test
+ fun `payload shown is exactly what would be submitted, including the comment`() = runTest(testDispatcher) {
+ val vm = viewModel()
+
+ vm.updateComment("my note")
+
+ assertEquals(report.copy(userComment = "my note").toSubmissionPayload(), vm.payload())
+ }
+
+ @Test
+ fun `submit enqueues the upload exactly once and persists the reviewed comment`() = runTest(testDispatcher) {
+ every { submitter.isEnabled } returns true
+ every { submitter.submit("rid") } just Runs
+ every { submitter.status("rid") } returns emptyFlow()
+ every { store.save(any()) } just Runs
+ val vm = viewModel()
+ vm.updateComment("edited before submit")
+
+ vm.submit()
+
+ verify(exactly = 1) { store.save(match { it.userComment == "edited before submit" }) }
+ verify(exactly = 1) { submitter.submit("rid") }
+ }
+
+ @Test
+ fun `submit with no endpoint configured never transmits`() = runTest(testDispatcher) {
+ every { submitter.isEnabled } returns false
+ every { store.save(any()) } just Runs
+ val vm = viewModel()
+ vm.updateComment("please send")
+
+ vm.submit()
+
+ // The comment is still persisted for Copy/Save, but nothing is enqueued for upload.
+ verify(exactly = 1) { store.save(match { it.userComment == "please send" }) }
+ verify(exactly = 0) { submitter.submit(any()) }
+ }
+
+ @Test
+ fun `discard deletes the report and does not submit`() = runTest(testDispatcher) {
+ every { store.delete("rid") } just Runs
+ val vm = viewModel()
+
+ vm.discard()
+
+ verify(exactly = 1) { store.delete("rid") }
+ verify(exactly = 0) { submitter.submit(any()) }
+ }
+}
From 51c790d6bf09b1e0a62b2cc8ac41c57f53835a6b Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Wed, 1 Jul 2026 00:08:06 -0500
Subject: [PATCH 04/11] feat(mailto): handle mailto: links and email share
intents
Add intent filters so LibreMail handles mailto: (ACTION_VIEW / SENDTO)
and email SEND / SEND_MULTIPLE intents, opening a prefilled compose screen.
- MailtoParser: pure RFC 6068 parser (multiple recipients, to/cc/bcc/
subject/body, percent-encoding; preserves a literal '+'); JVM-tested.
- IntentComposeParser: builds a ComposePrefill from a mailto: URI or the
EXTRA_EMAIL/CC/BCC/SUBJECT/TEXT share extras.
- MainActivity parses the launch/new intent and hands a one-shot prefill to
the NavHost (guarded against config-change duplication).
- Compose form gains a Bcc field; Routes carry cc/bcc/body deep-link args.
- bcc wired end-to-end: OutgoingMessage, SMTP + Graph senders, and outbox +
drafts persistence via Room migration v9 -> v10.
- Multi-account send is served by the existing From picker on compose.
Default mail app: Android exposes no public RoleManager email role, so the
intent filters are what make LibreMail appear on the system "Open by default"
/ default-apps screen where the platform/OEM supports it.
Co-Authored-By: Claude Opus 4.8
---
.../10.json | 523 ++++++++++++++++++
app/src/main/AndroidManifest.xml | 32 ++
.../main/kotlin/org/libremail/MainActivity.kt | 26 +-
.../libremail/data/local/LibreMailDatabase.kt | 2 +-
.../org/libremail/data/local/Mappers.kt | 2 +
.../org/libremail/data/local/Migrations.kt | 13 +
.../data/local/entity/DraftEntity.kt | 2 +
.../data/local/entity/OutboxEntity.kt | 2 +
.../data/repository/MailRepositoryImpl.kt | 1 +
.../org/libremail/data/sync/SendWorker.kt | 1 +
.../kotlin/org/libremail/di/DatabaseModule.kt | 2 +
.../org/libremail/domain/model/Draft.kt | 1 +
.../libremail/domain/model/OutgoingMessage.kt | 3 +-
.../kotlin/org/libremail/mail/GraphSender.kt | 3 +
.../kotlin/org/libremail/mail/SmtpSender.kt | 3 +
.../kotlin/org/libremail/ui/LibreMailApp.kt | 32 +-
.../org/libremail/ui/compose/ComposeScreen.kt | 9 +
.../libremail/ui/compose/ComposeViewModel.kt | 17 +-
.../ui/compose/IntentComposeParser.kt | 64 +++
.../org/libremail/ui/compose/MailtoParser.kt | 111 ++++
.../org/libremail/ui/navigation/Routes.kt | 25 +-
app/src/main/res/values/strings.xml | 1 +
.../org/libremail/mail/GraphSenderTest.kt | 14 +-
.../org/libremail/mail/SmtpSenderTest.kt | 32 ++
.../ui/compose/ComposeViewModelTest.kt | 47 ++
.../libremail/ui/compose/MailtoParserTest.kt | 132 +++++
26 files changed, 1091 insertions(+), 9 deletions(-)
create mode 100644 app/schemas/org.libremail.data.local.LibreMailDatabase/10.json
create mode 100644 app/src/main/kotlin/org/libremail/ui/compose/IntentComposeParser.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/compose/MailtoParser.kt
create mode 100644 app/src/test/kotlin/org/libremail/ui/compose/MailtoParserTest.kt
diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json
new file mode 100644
index 0000000..1f87ef9
--- /dev/null
+++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json
@@ -0,0 +1,523 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 10,
+ "identityHash": "aff2afe197ff1b0b41630e5a165d09f3",
+ "entities": [
+ {
+ "tableName": "accounts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "email",
+ "columnName": "email",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "displayName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "authType",
+ "columnName": "authType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imap.host",
+ "columnName": "imap_host",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imap.port",
+ "columnName": "imap_port",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imap.security",
+ "columnName": "imap_security",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "smtp.host",
+ "columnName": "smtp_host",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "smtp.port",
+ "columnName": "smtp_port",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "smtp.security",
+ "columnName": "smtp_security",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "account_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signatureEnabled",
+ "columnName": "signatureEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "notificationsEnabled",
+ "columnName": "notificationsEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "accountId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "accountId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "messages",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sender",
+ "columnName": "sender",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "senderEmail",
+ "columnName": "senderEmail",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subject",
+ "columnName": "subject",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snippet",
+ "columnName": "snippet",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "body",
+ "columnName": "body",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isHtml",
+ "columnName": "isHtml",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestampMillis",
+ "columnName": "timestampMillis",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isRead",
+ "columnName": "isRead",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isStarred",
+ "columnName": "isStarred",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "folder",
+ "columnName": "folder",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'INBOX'"
+ },
+ {
+ "fieldPath": "inInbox",
+ "columnName": "inInbox",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bodyFetched",
+ "columnName": "bodyFetched",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_messages_accountId",
+ "unique": false,
+ "columnNames": [
+ "accountId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
+ },
+ {
+ "name": "index_messages_timestampMillis",
+ "unique": false,
+ "columnNames": [
+ "timestampMillis"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
+ }
+ ]
+ },
+ {
+ "tableName": "credentials",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
+ "fields": [
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "encryptedSecret",
+ "columnName": "encryptedSecret",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "accountId"
+ ]
+ }
+ },
+ {
+ "tableName": "attachments",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "messageId",
+ "columnName": "messageId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partIndex",
+ "columnName": "partIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filename",
+ "columnName": "filename",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mimeType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sizeBytes",
+ "columnName": "sizeBytes",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "messageId",
+ "partIndex"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_attachments_messageId",
+ "unique": false,
+ "columnNames": [
+ "messageId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "messages",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "messageId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "outbox",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "toAddresses",
+ "columnName": "toAddresses",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ccAddresses",
+ "columnName": "ccAddresses",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bccAddresses",
+ "columnName": "bccAddresses",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "subject",
+ "columnName": "subject",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "body",
+ "columnName": "body",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastError",
+ "columnName": "lastError",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "drafts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "toAddresses",
+ "columnName": "toAddresses",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ccAddresses",
+ "columnName": "ccAddresses",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bccAddresses",
+ "columnName": "bccAddresses",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "subject",
+ "columnName": "subject",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "body",
+ "columnName": "body",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "attachments",
+ "columnName": "attachments",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "folders",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
+ "fields": [
+ {
+ "fieldPath": "accountId",
+ "columnName": "accountId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fullName",
+ "columnName": "fullName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "displayName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "role",
+ "columnName": "role",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "selectable",
+ "columnName": "selectable",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sortOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "accountId",
+ "fullName"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aff2afe197ff1b0b41630e5a165d09f3')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8ee562c..07daede 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,6 +29,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
%1$d selected
@@ -102,10 +100,36 @@
Couldn\'t download %1$s
No app can open this file
-
- Sign in with Microsoft
+
+ Welcome to LibreMail
+ A private, open-source home for all your email. Add your first account to get started.
+ Add an email account
+ Account added
+ Would you like to add another email account?
+ Add another account
+ No, take me to my inbox
+
+
+ Outlook or Hotmail
Other (IMAP/SMTP)
- Choose how you want to connect your mailbox.
+ Choose your email provider to get started.
+
+
+ Connect %1$s
+ Unknown email provider.
+ To connect Gmail, create an app password in your Google Account. Gmail requires 2-Step Verification to be turned on before you can create one.
+ To connect Yahoo Mail, generate an app password from your Yahoo Account security settings.
+ To connect iCloud Mail, create an app-specific password from your Apple ID account page.
+ An app password is a one-off password that lets an app sign in to your account without your main password or a two-factor code.
+ Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device.
+ Create an app password for %1$s
+ Couldn\'t open your browser
+ Email address
+ App password
+ Server settings
+ Incoming (IMAP): %1$s:%2$d (%3$s)
+ Outgoing (SMTP): %1$s:%2$d (%3$s)
+ Test & add account
IMAP / SMTP
diff --git a/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt
new file mode 100644
index 0000000..52a8519
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt
@@ -0,0 +1,122 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.domain.model
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Locks down the preconfigured server settings for the app-password vendors. These are easy to get
+ * subtly wrong (a swapped port or the wrong transport security) and painful to debug on-device, so
+ * they are asserted explicitly here rather than trusted to a code review.
+ */
+class MailProviderTest {
+
+ @Test
+ fun `gmail preset uses documented imap and starttls smtp endpoints`() {
+ val account = MailProvider.GMAIL.createAccount("user@gmail.com")
+
+ assertEquals("imap.gmail.com", account.imap.host)
+ assertEquals(993, account.imap.port)
+ assertEquals(MailSecurity.SSL_TLS, account.imap.security)
+
+ assertEquals("smtp.gmail.com", account.smtp.host)
+ assertEquals(587, account.smtp.port)
+ assertEquals(MailSecurity.STARTTLS, account.smtp.security)
+ }
+
+ @Test
+ fun `yahoo preset uses documented imap and implicit-tls smtp endpoints`() {
+ val account = MailProvider.YAHOO.createAccount("user@yahoo.com")
+
+ assertEquals("imap.mail.yahoo.com", account.imap.host)
+ assertEquals(993, account.imap.port)
+ assertEquals(MailSecurity.SSL_TLS, account.imap.security)
+
+ assertEquals("smtp.mail.yahoo.com", account.smtp.host)
+ assertEquals(465, account.smtp.port)
+ assertEquals(MailSecurity.SSL_TLS, account.smtp.security)
+ }
+
+ @Test
+ fun `icloud preset uses documented imap and starttls smtp endpoints`() {
+ val account = MailProvider.ICLOUD.createAccount("user@icloud.com")
+
+ assertEquals("imap.mail.me.com", account.imap.host)
+ assertEquals(993, account.imap.port)
+ assertEquals(MailSecurity.SSL_TLS, account.imap.security)
+
+ assertEquals("smtp.mail.me.com", account.smtp.host)
+ assertEquals(587, account.smtp.port)
+ assertEquals(MailSecurity.STARTTLS, account.smtp.security)
+ }
+
+ @Test
+ fun `no provider ever uses insecure transport`() {
+ MailProvider.entries.forEach { provider ->
+ val account = provider.createAccount("user@example.com")
+ assertTrue(
+ account.imap.security != MailSecurity.NONE,
+ "${provider.key} IMAP must not use MailSecurity.NONE",
+ )
+ assertTrue(
+ account.smtp.security != MailSecurity.NONE,
+ "${provider.key} SMTP must not use MailSecurity.NONE",
+ )
+ }
+ }
+
+ @Test
+ fun `every provider resolves to a password-imap account with a help url`() {
+ MailProvider.entries.forEach { provider ->
+ val account = provider.createAccount("user@example.com")
+ assertEquals(AuthType.PASSWORD_IMAP, account.authType)
+ assertTrue(
+ provider.appPasswordHelpUrl.startsWith("https://"),
+ "${provider.key} must expose an https app-password help URL",
+ )
+ }
+ }
+
+ @Test
+ fun `createAccount trims the email and derives a stable id and display name`() {
+ val account = MailProvider.GMAIL.createAccount(" User@Gmail.com ")
+
+ assertEquals("User@Gmail.com", account.email)
+ assertEquals("imap:User@Gmail.com", account.id)
+ assertEquals("User@Gmail.com", account.displayName)
+ }
+
+ @Test
+ fun `createAccount keeps an explicit non-blank display name`() {
+ val account = MailProvider.GMAIL.createAccount("user@gmail.com", displayName = "Work")
+
+ assertEquals("Work", account.displayName)
+ }
+
+ @Test
+ fun `fromKey looks up providers case-insensitively and returns null for unknowns`() {
+ assertEquals(MailProvider.GMAIL, MailProvider.fromKey("gmail"))
+ assertEquals(MailProvider.YAHOO, MailProvider.fromKey("YAHOO"))
+ assertEquals(MailProvider.ICLOUD, MailProvider.fromKey("iCloud"))
+ assertNull(MailProvider.fromKey("outlook"))
+ assertNull(MailProvider.fromKey(""))
+ }
+
+ @Test
+ fun `provider keys are unique and lowercase`() {
+ val keys = MailProvider.entries.map { it.key }
+ assertEquals(keys.toSet().size, keys.size, "provider keys must be unique")
+ keys.forEach { key -> assertEquals(key.lowercase(), key, "provider key must be lowercase") }
+ }
+
+ @Test
+ fun `display names are present for the picker`() {
+ MailProvider.entries.forEach { provider ->
+ assertNotNull(provider.displayName)
+ assertTrue(provider.displayName.isNotBlank())
+ }
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt
new file mode 100644
index 0000000..81b13ec
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt
@@ -0,0 +1,127 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.accountsetup
+
+import androidx.lifecycle.SavedStateHandle
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.libremail.domain.model.Account
+import org.libremail.domain.model.MailProvider
+import org.libremail.domain.model.MailSecurity
+import org.libremail.domain.repository.AccountRepository
+import org.libremail.ui.navigation.Routes
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AppPasswordViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Before
+ fun setUp() = Dispatchers.setMain(testDispatcher)
+
+ @After
+ fun tearDown() = Dispatchers.resetMain()
+
+ private fun viewModel(repo: AccountRepository, providerKey: String = MailProvider.GMAIL.key) = AppPasswordViewModel(
+ SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to providerKey)),
+ repo,
+ )
+
+ @Test
+ fun `provider is resolved from the nav argument`() {
+ val vm = viewModel(mockk(relaxed = true), providerKey = "icloud")
+ assertEquals(MailProvider.ICLOUD, vm.provider)
+ }
+
+ @Test
+ fun `valid input builds the preset account and persists it`() = runTest(testDispatcher) {
+ val repo = mockk()
+ val account = slot()
+ coEvery { repo.addImapAccount(capture(account), "app-pass") } returns Result.success(listOf("INBOX"))
+ val vm = viewModel(repo)
+
+ vm.onEmail(" user@gmail.com ")
+ vm.onAppPassword("app-pass")
+ vm.testAndSave()
+
+ coVerify { repo.addImapAccount(any(), "app-pass") }
+ // Servers come from the Gmail preset, not from any user input.
+ assertEquals("imap.gmail.com", account.captured.imap.host)
+ assertEquals(993, account.captured.imap.port)
+ assertEquals(MailSecurity.SSL_TLS, account.captured.imap.security)
+ assertEquals("smtp.gmail.com", account.captured.smtp.host)
+ assertEquals(587, account.captured.smtp.port)
+ assertEquals(MailSecurity.STARTTLS, account.captured.smtp.security)
+ assertEquals("user@gmail.com", account.captured.email)
+
+ assertEquals(SetupStatus.DONE, vm.form.value.status)
+ assertEquals("imap:user@gmail.com", vm.form.value.addedAccountId)
+ }
+
+ @Test
+ fun `blank email or app password surfaces an error without contacting the server`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ val vm = viewModel(repo)
+
+ vm.onEmail("")
+ vm.onAppPassword("app-pass")
+ vm.testAndSave()
+
+ assertTrue(vm.form.value.error != null)
+ assertEquals(SetupStatus.IDLE, vm.form.value.status)
+ assertNull(vm.form.value.addedAccountId)
+ coVerify(exactly = 0) { repo.addImapAccount(any(), any()) }
+ }
+
+ @Test
+ fun `a connection failure is surfaced inline and the account is not marked added`() = runTest(testDispatcher) {
+ val repo = mockk()
+ coEvery { repo.addImapAccount(any(), any()) } returns Result.failure(RuntimeException("Login failed"))
+ val vm = viewModel(repo)
+
+ vm.onEmail("user@gmail.com")
+ vm.onAppPassword("wrong")
+ vm.testAndSave()
+
+ assertEquals("Login failed", vm.form.value.error)
+ assertEquals(SetupStatus.IDLE, vm.form.value.status)
+ assertNull(vm.form.value.addedAccountId)
+ }
+
+ @Test
+ fun `an unknown provider key surfaces an error and never contacts the server`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ val vm = viewModel(repo, providerKey = "bogus")
+
+ assertNull(vm.provider)
+ vm.onEmail("user@example.com")
+ vm.onAppPassword("app-pass")
+ vm.testAndSave()
+
+ assertTrue(vm.form.value.error != null)
+ coVerify(exactly = 0) { repo.addImapAccount(any(), any()) }
+ }
+
+ @Test
+ fun `isValid requires both an email and an app password`() {
+ val vm = viewModel(mockk(relaxed = true))
+ assertTrue(!vm.form.value.isValid)
+ vm.onEmail("user@gmail.com")
+ assertTrue(!vm.form.value.isValid)
+ vm.onAppPassword("app-pass")
+ assertTrue(vm.form.value.isValid)
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt
index e60501c..25cb649 100644
--- a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt
+++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.mailbox
+import androidx.lifecycle.SavedStateHandle
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -29,6 +30,7 @@ import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.ServerConfig
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
+import org.libremail.ui.navigation.Routes
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -374,12 +376,27 @@ class MailboxViewModelTest {
coVerify { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.REPLY_ALL) }
}
+ @Test
+ fun `opens filtered to the account passed as a nav argument`() = runTest(testDispatcher) {
+ val vm = createViewModel(
+ accounts = listOf(alice, bob),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:b:INBOX:1", "imap:b", "INBOX")),
+ initialAccountId = "imap:a",
+ )
+ backgroundScope.launch { vm.messages.collect {} }
+
+ assertEquals("imap:a", vm.selectedAccountId.value)
+ assertEquals("INBOX", vm.selectedFolder.value)
+ assertEquals(listOf("imap:a:INBOX:1"), vm.messages.value.map { it.id })
+ }
+
private fun createViewModel(
accounts: List,
messages: List,
folders: Map> = emptyMap(),
syncer: MailSyncer = mockk(relaxed = true),
repo: MailRepository = mockk(relaxed = true),
+ initialAccountId: String? = null,
): MailboxViewModel {
every { repo.observeMessages() } returns MutableStateFlow(messages)
every { repo.observeDrafts() } returns flowOf(emptyList())
@@ -389,7 +406,10 @@ class MailboxViewModelTest {
}
val accountRepository = mockk(relaxed = true)
every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts)
- return MailboxViewModel(repo, accountRepository, syncer)
+ val savedState = initialAccountId?.let {
+ SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to it))
+ } ?: SavedStateHandle()
+ return MailboxViewModel(repo, accountRepository, syncer, savedState)
}
private fun account(id: String, email: String) = Account(
From bee5737ec41a0169d6dc86b4eafa188a1cc79829 Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Wed, 1 Jul 2026 00:30:33 -0500
Subject: [PATCH 06/11] feat(compose): rich HTML editor, multipart send, and
signatures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bring rich composition to LibreMail (issues #36, #37, #38, and #23).
#36 HTML editor + toolbar
- New pure, JVM-testable rich-text model (`richtext/`): RichTextContent with
inline styles + links and block markers ("• ", "N. ", "> "), serializing to a
narrow email-safe HTML subset and back (fromHtml is a faithful inverse).
- Rich editor in ComposeScreen with a bold/italic/underline, bulleted/numbered
list, block-quote, and link toolbar, backed by AnnotatedString. Unformatted
text stays plaintext-only (null HTML) so it feels unchanged and is accessible.
- #23: rounded corners on the compose fields/body via MaterialTheme.shapes.
#37 multipart/alternative + reply/forward quoting
- SmtpSender builds multipart/alternative (text/plain + text/html), nested in
multipart/mixed when there are attachments; GraphSender sends HTML content.
- HtmlToText produces a readable text/plain fallback; ReplyBuilder quotes HTML
originals as clean blockquotes (tags stripped) without corruption.
- HTML body persists/restores through drafts and the outbox (new nullable
bodyHtml columns; Room v9->v10 migration + schema).
#38 signatures
- New signatures table (multiple per account, one default) + repository/DAO;
migration backfills the existing per-account signature as the default.
- Rich signatures reuse the #36 editor; a Signatures management screen (list,
add/edit/delete, set default) is linked from per-account settings.
- The account's default signature auto-inserts on new compose / reply / forward
(honoring the enable toggle), placed above the quote, and stays editable.
Co-Authored-By: Claude Opus 4.8
---
.../10.json | 585 ++++++++++++++++++
.../libremail/ui/compose/ComposeScreenTest.kt | 2 +
.../ui/settings/AccountSettingsScreenTest.kt | 21 +-
.../kotlin/org/libremail/data/ReplyBuilder.kt | 64 +-
.../org/libremail/data/SignatureBlock.kt | 35 ++
.../libremail/data/local/LibreMailDatabase.kt | 6 +-
.../org/libremail/data/local/Mappers.kt | 3 +
.../org/libremail/data/local/Migrations.kt | 32 +
.../libremail/data/local/dao/SignatureDao.kt | 47 ++
.../data/local/entity/DraftEntity.kt | 2 +
.../data/local/entity/OutboxEntity.kt | 2 +
.../data/local/entity/SignatureEntity.kt | 32 +
.../data/repository/MailRepositoryImpl.kt | 19 +-
.../data/settings/SignatureRepository.kt | 53 ++
.../org/libremail/data/sync/SendWorker.kt | 1 +
.../kotlin/org/libremail/di/DatabaseModule.kt | 6 +
.../org/libremail/domain/model/Draft.kt | 2 +
.../libremail/domain/model/OutboxMessage.kt | 2 +
.../libremail/domain/model/OutgoingMessage.kt | 9 +-
.../org/libremail/domain/model/Signature.kt | 20 +
.../kotlin/org/libremail/mail/GraphSender.kt | 9 +-
.../kotlin/org/libremail/mail/HtmlToText.kt | 45 ++
.../kotlin/org/libremail/mail/SmtpSender.kt | 46 +-
.../kotlin/org/libremail/richtext/RichText.kt | 335 ++++++++++
.../org/libremail/richtext/RichTextEditing.kt | 200 ++++++
.../kotlin/org/libremail/ui/LibreMailApp.kt | 31 +-
.../org/libremail/ui/compose/ComposeScreen.kt | 12 +-
.../libremail/ui/compose/ComposeViewModel.kt | 78 ++-
.../libremail/ui/compose/RichTextEditor.kt | 322 ++++++++++
.../org/libremail/ui/navigation/Routes.kt | 11 +
.../ui/settings/AccountSettingsScreen.kt | 29 +-
.../ui/settings/AccountSettingsViewModel.kt | 31 +-
.../ui/settings/SignatureEditScreen.kt | 89 +++
.../ui/settings/SignatureEditViewModel.kt | 81 +++
.../libremail/ui/settings/SignaturesScreen.kt | 135 ++++
.../ui/settings/SignaturesViewModel.kt | 35 ++
app/src/main/res/values/strings.xml | 32 +-
.../org/libremail/data/ReplyBuilderTest.kt | 22 +
.../org/libremail/data/SignatureBlockTest.kt | 39 ++
.../data/local/MappersHtmlBodyTest.kt | 52 ++
.../data/repository/MailRepositoryImplTest.kt | 40 ++
.../data/settings/SignatureRepositoryTest.kt | 74 +++
.../org/libremail/mail/GraphSenderTest.kt | 22 +
.../org/libremail/mail/HtmlToTextTest.kt | 41 ++
.../org/libremail/mail/SmtpSenderTest.kt | 68 ++
.../libremail/richtext/RichTextEditingTest.kt | 70 +++
.../libremail/richtext/RichTextHtmlTest.kt | 105 ++++
.../ui/compose/ComposeViewModelTest.kt | 66 +-
48 files changed, 2954 insertions(+), 109 deletions(-)
create mode 100644 app/schemas/org.libremail.data.local.LibreMailDatabase/10.json
create mode 100644 app/src/main/kotlin/org/libremail/data/SignatureBlock.kt
create mode 100644 app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt
create mode 100644 app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt
create mode 100644 app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt
create mode 100644 app/src/main/kotlin/org/libremail/domain/model/Signature.kt
create mode 100644 app/src/main/kotlin/org/libremail/mail/HtmlToText.kt
create mode 100644 app/src/main/kotlin/org/libremail/richtext/RichText.kt
create mode 100644 app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt
create mode 100644 app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt
diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json
new file mode 100644
index 0000000..65389fd
--- /dev/null
+++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json
@@ -0,0 +1,585 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 10,
+ "identityHash": "dda823c68d6b6207f3b648b46a0182e6",
+ "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, `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": "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, `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": "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, 'dda823c68d6b6207f3b648b46a0182e6')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
index f79725a..97777c4 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
@@ -24,6 +24,7 @@ import org.libremail.R
import org.libremail.contacts.ContactsRepository
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.settings.AccountSettingsRepository
+import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
@@ -81,6 +82,7 @@ class ComposeScreenTest {
accountRepository = FakeAccountRepository(accounts = listOf(account)),
contactsRepository = ContactsRepository(context),
accountSettingsRepository = AccountSettingsRepository(database.accountSettingsDao()),
+ signatureRepository = SignatureRepository(database.signatureDao()),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt
index d4f26b7..8bafcf7 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt
@@ -6,7 +6,6 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
@@ -19,6 +18,7 @@ import org.libremail.R
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.toEntity
import org.libremail.data.settings.AccountSettingsRepository
+import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
@@ -48,6 +48,8 @@ class AccountSettingsScreenTest {
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
+ private var manageSignaturesClicked = false
+
private fun setContent(): AccountSettingsRepository {
val context = ApplicationProvider.getApplicationContext()
// Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by
@@ -64,24 +66,27 @@ class AccountSettingsScreenTest {
savedStateHandle = SavedStateHandle(mapOf(Routes.ACCOUNT_SETTINGS_ARG_ID to account.id)),
accountRepository = FakeAccountRepository(accounts = listOf(account)),
accountSettingsRepository = repository,
+ signatureRepository = SignatureRepository(db.signatureDao()),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
- AccountSettingsScreen(onBack = {}, viewModel = viewModel)
+ AccountSettingsScreen(
+ onBack = {},
+ onManageSignatures = { manageSignaturesClicked = true },
+ viewModel = viewModel,
+ )
}
}
return repository
}
@Test
- fun editingSignature_persistsThroughTheRepository() {
- val repository = setContent()
+ fun manageSignatures_opensTheSignaturesScreen() {
+ setContent()
- composeTestRule.onNodeWithText(string(R.string.settings_signature_hint)).performTextInput("Cheers")
+ composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performClick()
- composeTestRule.waitUntil(5_000) {
- runBlocking { repository.get(account.id).signature } == "Cheers"
- }
+ composeTestRule.waitUntil(5_000) { manageSignaturesClicked }
}
@Test
diff --git a/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt
index ec0164e..a8067c7 100644
--- a/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt
+++ b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt
@@ -2,40 +2,55 @@
package org.libremail.data
import org.libremail.domain.model.ReplyMode
+import org.libremail.mail.HtmlToText
import org.libremail.mail.ReplyContext
+import org.libremail.richtext.RichTextContent
+import org.libremail.richtext.RichTextHtml
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
-/** The pre-filled compose fields for a reply/forward (everything else the user fills in). */
-data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String)
+/**
+ * The pre-filled compose fields for a reply/forward. [body] is the plaintext form; [bodyHtml] is the
+ * matching HTML (the quote rendered as a ``), so the reply can go out as
+ * `multipart/alternative` without the user having to re-format the quote.
+ */
+data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String, val bodyHtml: String)
/**
* Pure builder that turns an original message ([ReplyContext]) into the pre-filled compose fields for a
* reply, reply-all, or forward. Kept free of Android/IMAP dependencies so it can be unit-tested directly.
+ *
+ * HTML originals are quoted by first reducing them to readable text (via [HtmlToText]) and then
+ * quoting that — never by prefixing "> " onto raw tags — so the quote can never corrupt the markup.
+ * The plaintext quote's "> " / attribution structure is then rendered to a clean `` for
+ * the HTML alternative.
*/
object ReplyBuilder {
fun build(context: ReplyContext, mode: ReplyMode, selfEmail: String): ReplyContent = when (mode) {
- ReplyMode.REPLY -> ReplyContent(
- to = context.fromEmail,
- cc = "",
- subject = prefixedSubject(context.subject, "Re:"),
- body = quotedReply(context),
- )
+ ReplyMode.REPLY -> reply(context, cc = "")
+ ReplyMode.REPLY_ALL -> reply(context, cc = replyAllCc(context, selfEmail).joinToString(", "))
+ ReplyMode.FORWARD -> {
+ val body = forwardedBody(context)
+ ReplyContent(
+ to = "",
+ cc = "",
+ subject = prefixedSubject(context.subject, "Fwd:"),
+ body = body,
+ bodyHtml = htmlOf(body),
+ )
+ }
+ }
- ReplyMode.REPLY_ALL -> ReplyContent(
+ private fun reply(context: ReplyContext, cc: String): ReplyContent {
+ val body = quotedReply(context)
+ return ReplyContent(
to = context.fromEmail,
- cc = replyAllCc(context, selfEmail).joinToString(", "),
+ cc = cc,
subject = prefixedSubject(context.subject, "Re:"),
- body = quotedReply(context),
- )
-
- ReplyMode.FORWARD -> ReplyContent(
- to = "",
- cc = "",
- subject = prefixedSubject(context.subject, "Fwd:"),
- body = forwardedBody(context),
+ body = body,
+ bodyHtml = htmlOf(body),
)
}
@@ -68,15 +83,12 @@ object ReplyBuilder {
append(bodyText(context))
}
- /** The original body as plain text (HTML stripped), suitable for quoting in a plain-text compose. */
- private fun bodyText(context: ReplyContext): String = if (context.isHtml) htmlToText(context.body) else context.body
+ /** The original body as plain text (HTML stripped), suitable for quoting in a compose field. */
+ private fun bodyText(context: ReplyContext): String =
+ if (context.isHtml) HtmlToText.convert(context.body) else context.body
- private fun htmlToText(html: String): String = html
- .replace(Regex("(?i)
"), "\n")
- .replace(Regex("(?i)
"), "\n\n")
- .replace(Regex("<[^>]*>"), "")
- .replace(Regex("[ \\t]+"), " ")
- .trim()
+ /** Renders the plaintext quote (with its "> " markers) to the equivalent clean HTML. */
+ private fun htmlOf(body: String): String = RichTextHtml.toHtml(RichTextContent(body))
private fun formatDate(millis: Long): String =
SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis))
diff --git a/app/src/main/kotlin/org/libremail/data/SignatureBlock.kt b/app/src/main/kotlin/org/libremail/data/SignatureBlock.kt
new file mode 100644
index 0000000..a46b109
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/data/SignatureBlock.kt
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data
+
+import org.libremail.domain.model.Signature
+import org.libremail.richtext.RichTextContent
+import org.libremail.richtext.RichTextHtml
+
+/**
+ * A signature rendered into both forms the composer needs: [plain] for the plaintext body/fallback
+ * and [html] for the HTML body. The two are kept in sync — parsing [html] back through the rich-text
+ * model yields exactly [plain] — so the editor shows the same content whichever it seeds from, and a
+ * From-account swap can strip the previously applied block from either representation.
+ *
+ * The block opens with the RFC 3676 "-- " delimiter so receiving clients recognize it as a signature.
+ */
+data class SignatureBlock(val plain: String, val html: String) {
+ val isEmpty: Boolean get() = plain.isEmpty() && html.isEmpty()
+
+ companion object {
+ val EMPTY = SignatureBlock("", "")
+
+ /** The block for [signature], or [EMPTY] when there is none / it is blank. */
+ fun of(signature: Signature?): SignatureBlock {
+ if (signature == null) return EMPTY
+ val plainSig = signature.plainText().trimEnd()
+ if (plainSig.isBlank() && signature.html.isBlank()) return EMPTY
+ return SignatureBlock(
+ plain = "$DELIMITER_PLAIN$plainSig",
+ html = RichTextHtml.toHtml(RichTextContent(DELIMITER_PLAIN)) + signature.html,
+ )
+ }
+
+ private const val DELIMITER_PLAIN = "\n\n-- \n"
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt
index 0b1b2e5..2825a61 100644
--- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt
@@ -11,6 +11,7 @@ import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
+import org.libremail.data.local.dao.SignatureDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AccountSettingsEntity
import org.libremail.data.local.entity.AttachmentEntity
@@ -19,6 +20,7 @@ import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.OutboxEntity
+import org.libremail.data.local.entity.SignatureEntity
@Database(
entities = [
@@ -30,8 +32,9 @@ import org.libremail.data.local.entity.OutboxEntity
OutboxEntity::class,
DraftEntity::class,
FolderEntity::class,
+ SignatureEntity::class,
],
- version = 9,
+ version = 10,
exportSchema = true,
)
abstract class LibreMailDatabase : RoomDatabase() {
@@ -43,4 +46,5 @@ abstract class LibreMailDatabase : RoomDatabase() {
abstract fun outboxDao(): OutboxDao
abstract fun draftDao(): DraftDao
abstract fun folderDao(): FolderDao
+ abstract fun signatureDao(): SignatureDao
}
diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt
index bf975f1..9c4773d 100644
--- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt
@@ -162,6 +162,7 @@ internal fun DraftEntity.toDomain(): Draft = Draft(
subject = subject,
body = body,
updatedAt = updatedAt,
+ bodyHtml = bodyHtml,
attachments = attachments.toOutgoingAttachments(),
)
@@ -174,6 +175,7 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity(
body = body,
updatedAt = updatedAt,
attachments = attachments.toJson(),
+ bodyHtml = bodyHtml,
)
/** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */
@@ -202,6 +204,7 @@ internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage(
body = body,
createdAt = createdAt,
lastError = lastError,
+ bodyHtml = bodyHtml,
)
private fun String.toMailSecurity(): MailSecurity = runCatching {
diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt
index 0338ce3..7093a3a 100644
--- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt
@@ -171,3 +171,35 @@ val MIGRATION_8_9 = object : Migration(8, 9) {
)
}
}
+
+/**
+ * v9 -> v10: 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_9_10 = object : Migration(9, 10) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bodyHtml` TEXT")
+ db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bodyHtml` TEXT")
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `signatures` (" +
+ "`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
+ "`contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), " +
+ "FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
+ "ON UPDATE NO ACTION ON DELETE CASCADE)",
+ )
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `signatures` (`accountId`)")
+ // Preserve any existing plain-text per-account signature as that account's default signature.
+ // Newlines become
so the HTML keeps the original line breaks; other characters are rare
+ // in signatures and pass through unescaped.
+ db.execSQL(
+ "INSERT INTO `signatures` (`id`, `accountId`, `name`, `contentHtml`, `isDefault`) " +
+ "SELECT `accountId` || ':default-signature', `accountId`, 'Signature', " +
+ "replace(`signature`, char(10), '
'), 1 " +
+ "FROM `account_settings` WHERE `signature` <> ''",
+ )
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt
new file mode 100644
index 0000000..ddf12c9
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+import org.libremail.data.local.entity.SignatureEntity
+
+@Dao
+interface SignatureDao {
+ @Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY isDefault DESC, name COLLATE NOCASE")
+ fun observeForAccount(accountId: String): Flow>
+
+ @Query("SELECT * FROM signatures WHERE id = :id LIMIT 1")
+ suspend fun getById(id: String): SignatureEntity?
+
+ @Query("SELECT * FROM signatures WHERE accountId = :accountId AND isDefault = 1 LIMIT 1")
+ suspend fun getDefault(accountId: String): SignatureEntity?
+
+ @Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY name COLLATE NOCASE LIMIT 1")
+ suspend fun firstForAccount(accountId: String): SignatureEntity?
+
+ @Query("SELECT COUNT(*) FROM signatures WHERE accountId = :accountId")
+ suspend fun countForAccount(accountId: String): Int
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(signature: SignatureEntity)
+
+ @Query("DELETE FROM signatures WHERE id = :id")
+ suspend fun delete(id: String)
+
+ @Query("UPDATE signatures SET isDefault = 0 WHERE accountId = :accountId")
+ suspend fun clearDefault(accountId: String)
+
+ @Query("UPDATE signatures SET isDefault = 1 WHERE id = :id")
+ suspend fun markDefault(id: String)
+
+ /** Makes [id] the account's sole default in one transaction (clears the others first). */
+ @Transaction
+ suspend fun setDefault(accountId: String, id: String) {
+ clearDefault(accountId)
+ markDefault(id)
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt
index ed6dd3b..b781e8c 100644
--- a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt
@@ -16,4 +16,6 @@ data class DraftEntity(
val updatedAt: Long,
/** JSON array of the draft's attachments ([uri, name] pairs); empty string when there are none. */
val attachments: String = "",
+ /** HTML form of [body] when the draft carries formatting; null for plaintext drafts. */
+ val bodyHtml: String? = null,
)
diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt
index 96d5f44..6ade954 100644
--- a/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt
@@ -15,4 +15,6 @@ data class OutboxEntity(
val body: String,
val createdAt: Long,
val lastError: String? = null,
+ /** HTML form of [body] when composed with formatting; null sends `text/plain` only. */
+ val bodyHtml: String? = null,
)
diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt
new file mode 100644
index 0000000..23c1c13
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+/**
+ * One saved signature. The [accountId] foreign key cascades on delete, so removing an account drops
+ * its signatures. [isDefault] marks the one auto-inserted when composing from the account; the
+ * repository keeps at most one default per account.
+ */
+@Entity(
+ tableName = "signatures",
+ foreignKeys = [
+ ForeignKey(
+ entity = AccountEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["accountId"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index("accountId")],
+)
+data class SignatureEntity(
+ @PrimaryKey val id: String,
+ val accountId: String,
+ val name: String,
+ val contentHtml: String,
+ val isDefault: Boolean = false,
+)
diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt
index 94657d8..6f941da 100644
--- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt
+++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt
@@ -8,6 +8,7 @@ import jakarta.mail.Flags
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.data.ReplyBuilder
+import org.libremail.data.SignatureBlock
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.DraftDao
@@ -20,6 +21,7 @@ import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
import org.libremail.data.settings.AccountSettingsRepository
+import org.libremail.data.settings.SignatureRepository
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.data.sync.SendScheduler
import org.libremail.domain.model.Attachment
@@ -52,6 +54,7 @@ class MailRepositoryImpl @Inject constructor(
private val connectionFactory: MailConnectionFactory,
private val sendScheduler: SendScheduler,
private val accountSettingsRepository: AccountSettingsRepository,
+ private val signatureRepository: SignatureRepository,
) : MailRepository {
override fun observeMessages(): Flow> = messageDao.observeAll().map { rows ->
@@ -191,9 +194,15 @@ class MailRepositoryImpl @Inject constructor(
val params = connectionFactory.imapParamsFor(account)
val context = imapClient.fetchForReply(params, entity.folder, uidOf(messageId))
val content = ReplyBuilder.build(context, mode, account.email)
- // Bake the sending account's signature into the reply/forward body so it round-trips as part
- // of the draft (compose won't re-append for drafts).
- val signature = accountSettingsRepository.get(entity.accountId).signatureBlock()
+ // Bake the sending account's default signature into the reply/forward body — above the quoted
+ // original — so it round-trips as part of the draft (compose won't re-append for drafts). Both
+ // the plaintext and HTML forms are stored so the reply can go out as multipart/alternative.
+ val settings = accountSettingsRepository.get(entity.accountId)
+ val sig = if (settings.signatureEnabled) {
+ SignatureBlock.of(signatureRepository.getDefault(entity.accountId))
+ } else {
+ SignatureBlock.EMPTY
+ }
val draftId = UUID.randomUUID().toString()
saveDraft(
Draft(
@@ -202,8 +211,9 @@ class MailRepositoryImpl @Inject constructor(
to = content.to,
cc = content.cc,
subject = content.subject,
- body = content.body + signature,
+ body = sig.plain + content.body,
updatedAt = System.currentTimeMillis(),
+ bodyHtml = sig.html + content.bodyHtml,
attachments = emptyList(),
),
)
@@ -269,6 +279,7 @@ class MailRepositoryImpl @Inject constructor(
subject = outgoing.subject,
body = outgoing.body,
createdAt = System.currentTimeMillis(),
+ bodyHtml = outgoing.bodyHtml,
),
)
sendScheduler.sendNow()
diff --git a/app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt
new file mode 100644
index 0000000..5bc3955
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.settings
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.libremail.data.local.dao.SignatureDao
+import org.libremail.data.local.entity.SignatureEntity
+import org.libremail.domain.model.Signature
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * CRUD for per-account signatures, keeping the "at most one default per account" invariant. The
+ * first signature created for an account becomes its default automatically; deleting the default
+ * promotes another (if any) so an account with signatures always has one to auto-insert.
+ */
+@Singleton
+class SignatureRepository @Inject constructor(private val dao: SignatureDao) {
+
+ fun observeForAccount(accountId: String): Flow> =
+ dao.observeForAccount(accountId).map { rows -> rows.map { it.toDomain() } }
+
+ suspend fun get(id: String): Signature? = dao.getById(id)?.toDomain()
+
+ suspend fun getDefault(accountId: String): Signature? = dao.getDefault(accountId)?.toDomain()
+
+ /** Creates a signature; makes it the default when it is the account's first. Returns its id. */
+ suspend fun create(accountId: String, name: String, html: String): String {
+ val id = UUID.randomUUID().toString()
+ val isFirst = dao.countForAccount(accountId) == 0
+ dao.upsert(SignatureEntity(id, accountId, name, html, isDefault = isFirst))
+ return id
+ }
+
+ suspend fun update(id: String, name: String, html: String) {
+ val existing = dao.getById(id) ?: return
+ dao.upsert(existing.copy(name = name, contentHtml = html))
+ }
+
+ suspend fun delete(id: String) {
+ val existing = dao.getById(id) ?: return
+ dao.delete(id)
+ // If we removed the default, promote the account's first remaining signature.
+ if (existing.isDefault) {
+ dao.firstForAccount(existing.accountId)?.let { dao.markDefault(it.id) }
+ }
+ }
+
+ suspend fun setDefault(accountId: String, id: String) = dao.setDefault(accountId, id)
+
+ private fun SignatureEntity.toDomain() = Signature(id, accountId, name, contentHtml, isDefault)
+}
diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt
index 840182c..1af8bee 100644
--- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt
+++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt
@@ -56,6 +56,7 @@ class SendWorker @AssistedInject constructor(
cc = entity.ccAddresses,
subject = entity.subject,
body = entity.body,
+ bodyHtml = entity.bodyHtml,
)
val files = orderedAttachments(attachmentDir)
if (account.authType == AuthType.OAUTH_OUTLOOK) {
diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt
index 8e93ea3..68e8a01 100644
--- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt
+++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt
@@ -21,6 +21,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
@@ -29,6 +30,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.settings.SettingsRepository
import javax.inject.Singleton
@@ -54,6 +56,7 @@ object DatabaseModule {
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
+ MIGRATION_9_10,
)
// No destructive fallback: the migration chain is complete, and silently dropping the
// accounts/credentials/mail tables would lose stored secrets. A missing migration should
@@ -100,5 +103,8 @@ object DatabaseModule {
@Provides
fun provideFolderDao(database: LibreMailDatabase): FolderDao = database.folderDao()
+ @Provides
+ fun provideSignatureDao(database: LibreMailDatabase): SignatureDao = database.signatureDao()
+
private const val DB_NAME = "libremail.db"
}
diff --git a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt
index e6cecd9..29e1701 100644
--- a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt
+++ b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt
@@ -9,5 +9,7 @@ data class Draft(
val subject: String,
val body: String,
val updatedAt: Long,
+ /** HTML form of [body] when the draft was composed with formatting; null for plaintext drafts. */
+ val bodyHtml: String? = null,
val attachments: List = emptyList(),
)
diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt
index bfa25bb..89104de 100644
--- a/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt
+++ b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt
@@ -9,4 +9,6 @@ data class OutboxMessage(
val body: String,
val createdAt: Long,
val lastError: String?,
+ /** HTML form of [body] when composed with formatting; null for plaintext messages. */
+ val bodyHtml: String? = null,
)
diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt
index 559720d..e8b70d6 100644
--- a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt
+++ b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt
@@ -1,13 +1,20 @@
// 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] 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 subject: String,
val body: String,
+ val bodyHtml: String? = null,
val attachments: List = emptyList(),
)
diff --git a/app/src/main/kotlin/org/libremail/domain/model/Signature.kt b/app/src/main/kotlin/org/libremail/domain/model/Signature.kt
new file mode 100644
index 0000000..2537fab
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/domain/model/Signature.kt
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.domain.model
+
+import org.libremail.mail.HtmlToText
+
+/**
+ * A named, rich (HTML) email signature belonging to an account. Each account may have several; at
+ * most one is its [isDefault], which is the one auto-inserted when composing from that account.
+ */
+data class Signature(
+ val id: String,
+ val accountId: String,
+ val name: String,
+ /** The signature body as HTML (authored in the same rich editor as the message body). */
+ val html: String,
+ val isDefault: Boolean = false,
+) {
+ /** The signature as plain text, for the `text/plain` alternative and plaintext-only composing. */
+ fun plainText(): String = HtmlToText.convert(html)
+}
diff --git a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt
index b583df4..6d42818 100644
--- a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt
+++ b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt
@@ -84,9 +84,16 @@ class GraphSender @Inject constructor() {
/** Builds the Graph `sendMail` JSON body (a pure function, so it is unit-testable without a network). */
internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List): String {
+ // Graph carries a single body object: send HTML when the message was formatted (Outlook renders
+ // it and derives its own plaintext), otherwise plain text so unformatted mail is unchanged.
+ val body = if (message.bodyHtml != null) {
+ JSONObject().put("contentType", "HTML").put("content", message.bodyHtml)
+ } else {
+ JSONObject().put("contentType", "Text").put("content", message.body)
+ }
val mail = JSONObject()
.put("subject", message.subject)
- .put("body", JSONObject().put("contentType", "Text").put("content", message.body))
+ .put("body", body)
.put("toRecipients", recipientsJson(message.to))
if (message.cc.isNotBlank()) {
mail.put("ccRecipients", recipientsJson(message.cc))
diff --git a/app/src/main/kotlin/org/libremail/mail/HtmlToText.kt b/app/src/main/kotlin/org/libremail/mail/HtmlToText.kt
new file mode 100644
index 0000000..b61f3d5
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/mail/HtmlToText.kt
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.mail
+
+/**
+ * Converts an HTML body into readable plain text for the `text/plain` alternative part and for
+ * quoting HTML originals in replies/forwards. Pure (no Android/Compose), so it is unit-testable on
+ * the JVM.
+ *
+ * It is intentionally lightweight — block elements become line breaks, list items gain a "• "
+ * marker, tags are dropped and entities decoded — rather than a full HTML renderer. The goal is a
+ * legible fallback, not a faithful reproduction.
+ */
+object HtmlToText {
+
+ private val BLOCK_BREAK = Regex(
+ "(?i)?(p|div|tr|table|ul|ol|h[1-6]|blockquote)\\b[^>]*>|
",
+ )
+ private val LIST_ITEM = Regex("(?i)]*>")
+
+ fun convert(html: String): String {
+ var s = html
+ // Drop script/style contents outright so their text never leaks into the output.
+ s = s.replace(Regex("(?is)<(script|style)\\b[^>]*>.*?\\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("&", "&")
+}
diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt
index b034896..f243029 100644
--- a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt
+++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt
@@ -7,6 +7,7 @@ import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeBodyPart
import jakarta.mail.internet.MimeMessage
import jakarta.mail.internet.MimeMultipart
+import jakarta.mail.internet.MimePart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.libremail.domain.model.MailSecurity
@@ -62,16 +63,7 @@ class SmtpSender @Inject constructor() {
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 +74,40 @@ class SmtpSender @Inject constructor() {
}
}
+ /**
+ * Sets the message body:
+ * - plaintext-only ([OutgoingMessage.bodyHtml] null): a single `text/plain` part, exactly as
+ * before, so unformatted mail is unchanged on the wire;
+ * - formatted: a `multipart/alternative` of `text/plain` (fallback, first) + `text/html`
+ * (preferred, last, per RFC 2046).
+ * When there are attachments the body is nested inside a `multipart/mixed` as its first part.
+ */
+ private fun applyBody(mime: MimeMessage, message: OutgoingMessage, attachments: List) {
+ if (attachments.isEmpty()) {
+ setBody(mime, message)
+ return
+ }
+ val mixed = MimeMultipart("mixed")
+ mixed.addBodyPart(MimeBodyPart().also { setBody(it, message) })
+ attachments.forEach { file -> mixed.addBodyPart(MimeBodyPart().apply { attachFile(file) }) }
+ mime.setContent(mixed)
+ }
+
+ /** Writes the body onto [part]: plain text, or a text/plain + text/html alternative when formatted. */
+ private fun setBody(part: MimePart, message: OutgoingMessage) {
+ val html = message.bodyHtml
+ if (html == null) {
+ part.setText(message.body, "UTF-8")
+ } else {
+ part.setContent(alternative(message.body, html))
+ }
+ }
+
+ private fun alternative(plain: String, html: String): MimeMultipart = MimeMultipart("alternative").apply {
+ addBodyPart(MimeBodyPart().apply { setText(plain, "UTF-8") })
+ addBodyPart(MimeBodyPart().apply { setContent(html, "text/html; charset=UTF-8") })
+ }
+
private companion object {
const val TIMEOUT_MS = "15000"
}
diff --git a/app/src/main/kotlin/org/libremail/richtext/RichText.kt b/app/src/main/kotlin/org/libremail/richtext/RichText.kt
new file mode 100644
index 0000000..923ee13
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/richtext/RichText.kt
@@ -0,0 +1,335 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.richtext
+
+/** Inline character styles the compose editor supports. */
+enum class RichStyle { BOLD, ITALIC, UNDERLINE }
+
+/** A run of [style] over the half-open range [[start], [end]) of the plain text. */
+data class RichSpan(val start: Int, val end: Int, val style: RichStyle)
+
+/** A hyperlink over the half-open range [[start], [end]) pointing at [url]. */
+data class RichLink(val start: Int, val end: Int, val url: String)
+
+/**
+ * The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links].
+ *
+ * Block structure (unordered/ordered lists and block quotes) is encoded as recognizable line
+ * prefixes inside [text] — "• " for bullets, "N. " for numbered items, and "> " for quotes — so
+ * the editor can stay a single plain-text field (keeping the plaintext-only experience smooth)
+ * while the model still round-trips cleanly to real HTML and to a readable plaintext fallback.
+ */
+data class RichTextContent(
+ val text: String = "",
+ val spans: List = emptyList(),
+ val links: List = emptyList(),
+) {
+ val isBlank: Boolean get() = text.isBlank()
+
+ /**
+ * True when the content carries anything a plaintext field could not represent: inline styling,
+ * a link, or a block marker. When false, callers should send/persist plaintext only so an
+ * unformatted message stays byte-for-byte identical to the old plaintext-only path.
+ */
+ fun hasFormatting(): Boolean =
+ spans.isNotEmpty() || links.isNotEmpty() || text.lineSequence().any { lineMarker(it) != null }
+}
+
+/** Recognized block markers and the tags they map to. */
+internal const val BULLET_PREFIX = "• "
+internal const val QUOTE_PREFIX = "> "
+private val ORDERED_PREFIX = Regex("^\\d+\\. ")
+
+private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE }
+
+private data class Line(val kind: Kind, val contentStart: Int, val contentEnd: Int)
+
+/** The block marker prefixing [line], or null for an ordinary paragraph line. */
+internal fun lineMarker(line: String): String? = when {
+ line.startsWith(BULLET_PREFIX) -> BULLET_PREFIX
+ line.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX
+ else -> ORDERED_PREFIX.find(line)?.value
+}
+
+/**
+ * Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or
+ * Compose types), so the whole conversion is unit-testable on the JVM.
+ *
+ * The emitted subset — ` //-
` — is deliberately narrow
+ * so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts round-trip losslessly).
+ */
+object RichTextHtml {
+
+ fun toHtml(content: RichTextContent): String {
+ if (content.text.isEmpty()) return ""
+ val lines = classify(content.text)
+ val sb = StringBuilder()
+ var i = 0
+ while (i < lines.size) {
+ i = when (lines[i].kind) {
+ Kind.BULLET -> emitList(sb, content, lines, i, Kind.BULLET, "ul")
+ Kind.ORDERED -> emitList(sb, content, lines, i, Kind.ORDERED, "ol")
+ Kind.QUOTE -> emitQuote(sb, content, lines, i)
+ Kind.PARAGRAPH -> emitParagraph(sb, content, lines, i)
+ }
+ }
+ return sb.toString()
+ }
+
+ /** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */
+ fun toPlainText(content: RichTextContent): String = content.text
+
+ fun fromHtml(html: String): RichTextContent = HtmlToRichParser(html).parse()
+}
+
+private fun classify(text: String): List {
+ val lines = ArrayList()
+ var start = 0
+ while (true) {
+ val nl = text.indexOf('\n', start)
+ val end = if (nl == -1) text.length else nl
+ val marker = lineMarker(text.substring(start, end))
+ val kind = when (marker) {
+ BULLET_PREFIX -> Kind.BULLET
+ QUOTE_PREFIX -> Kind.QUOTE
+ null -> Kind.PARAGRAPH
+ else -> Kind.ORDERED
+ }
+ lines.add(Line(kind, start + (marker?.length ?: 0), end))
+ if (nl == -1) break
+ start = nl + 1
+ }
+ return lines
+}
+
+private fun emitList(
+ sb: StringBuilder,
+ content: RichTextContent,
+ lines: List,
+ from: Int,
+ kind: Kind,
+ tag: String,
+): Int {
+ sb.append("<").append(tag).append(">")
+ var i = from
+ while (i < lines.size && lines[i].kind == kind) {
+ sb.append("- ").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("
")
+ i++
+ }
+ sb.append("").append(tag).append(">")
+ return i
+}
+
+private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List, from: Int): Int {
+ sb.append("")
+ var i = from
+ while (i < lines.size && lines[i].kind == Kind.QUOTE) {
+ if (i > from) sb.append("
")
+ sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
+ i++
+ }
+ sb.append("
")
+ return i
+}
+
+private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List, from: Int): Int {
+ sb.append("")
+ var i = from
+ while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) {
+ if (i > from) sb.append("
")
+ sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
+ i++
+ }
+ sb.append("
")
+ return i
+}
+
+/** Renders the inline styling/links over [[from], [to]) as nested `///` tags. */
+private fun renderInline(content: RichTextContent, from: Int, to: Int): String {
+ if (from >= to) return ""
+ val points = cutPoints(content, from, to)
+ val sb = StringBuilder()
+ for (idx in 0 until points.size - 1) {
+ val a = points[idx]
+ val b = points[idx + 1]
+ if (a < b) appendRun(sb, content, a, b)
+ }
+ return sb.toString()
+}
+
+/** The sorted set of offsets where a span or link starts/ends within [[from], [to]]. */
+private fun cutPoints(content: RichTextContent, from: Int, to: Int): List {
+ val cuts = sortedSetOf(from, to)
+ fun add(start: Int, end: Int) {
+ if (end > from && start < to) {
+ cuts.add(start.coerceIn(from, to))
+ cuts.add(end.coerceIn(from, to))
+ }
+ }
+ content.spans.forEach { add(it.start, it.end) }
+ content.links.forEach { add(it.start, it.end) }
+ return cuts.toList()
+}
+
+/** Emits one constant-styling run [[a], [b]) with fully-closed tags, so nesting is always valid. */
+private fun appendRun(sb: StringBuilder, content: RichTextContent, a: Int, b: Int) {
+ val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }.toSet()
+ val link = content.links.firstOrNull { it.start <= a && b <= it.end }
+ if (link != null) sb.append("")
+ if (RichStyle.BOLD in styles) sb.append("")
+ if (RichStyle.ITALIC in styles) sb.append("")
+ if (RichStyle.UNDERLINE in styles) sb.append("")
+ sb.append(escape(content.text.substring(a, b)))
+ if (RichStyle.UNDERLINE in styles) sb.append("")
+ if (RichStyle.ITALIC in styles) sb.append("")
+ if (RichStyle.BOLD in styles) sb.append("")
+ if (link != null) sb.append("")
+}
+
+/**
+ * Parses the narrow HTML subset [toHtml][RichTextHtml.toHtml] emits (plus `strong`/`em` and
+ * pretty-printer whitespace) back into a [RichTextContent]. A small state machine keeps the nesting
+ * shallow: [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text.
+ */
+private class HtmlToRichParser(private val html: String) {
+ private val text = StringBuilder()
+ private val spans = ArrayList()
+ private val links = ArrayList()
+ private var boldStart = -1
+ private var italicStart = -1
+ private var underlineStart = -1
+ private var linkStart = -1
+ private var linkUrl = ""
+ private var listType: Char? = null
+ private var olCount = 0
+ private var inQuote = false
+
+ fun parse(): RichTextContent {
+ var i = 0
+ while (i < html.length) {
+ if (html[i] == '<') {
+ val gt = html.indexOf('>', i)
+ if (gt == -1) break
+ handleTag(html.substring(i + 1, gt).trim())
+ i = gt + 1
+ } else {
+ val lt = html.indexOf('<', i)
+ val end = if (lt == -1) html.length else lt
+ handleText(html.substring(i, end))
+ i = end
+ }
+ }
+ return finish()
+ }
+
+ private fun atLineStart() = text.isEmpty() || text.last() == '\n'
+
+ private fun newlineIfNeeded() {
+ if (!atLineStart()) text.append('\n')
+ }
+
+ private fun handleTag(raw: String) {
+ val closing = raw.startsWith("/")
+ val body = raw.removePrefix("/").trim()
+ when (body.substringBefore(' ').substringBefore('/').lowercase()) {
+ "br" -> {
+ text.append('\n')
+ if (inQuote) text.append(QUOTE_PREFIX)
+ }
+ "b", "strong" -> boldStart = toggle(closing, boldStart, RichStyle.BOLD)
+ "i", "em" -> italicStart = toggle(closing, italicStart, RichStyle.ITALIC)
+ "u" -> underlineStart = toggle(closing, underlineStart, RichStyle.UNDERLINE)
+ "a" -> handleAnchor(closing, body)
+ "ul" -> handleList(closing, 'u')
+ "ol" -> handleList(closing, 'o')
+ "li" -> if (!closing) startListItem()
+ "blockquote" -> handleQuote(closing)
+ "p", "div" -> newlineIfNeeded()
+ else -> Unit
+ }
+ }
+
+ /** Opens a style (returns the current offset) or closes it (records the span, returns -1). */
+ private fun toggle(closing: Boolean, openOffset: Int, style: RichStyle): Int {
+ if (!closing) return text.length
+ if (openOffset >= 0) spans.add(RichSpan(openOffset, text.length, style))
+ return -1
+ }
+
+ private fun handleAnchor(closing: Boolean, body: String) {
+ if (closing) {
+ if (linkStart >= 0) {
+ links.add(RichLink(linkStart, text.length, linkUrl))
+ linkStart = -1
+ linkUrl = ""
+ }
+ } else {
+ linkStart = text.length
+ linkUrl = extractHref(body)
+ }
+ }
+
+ private fun handleList(closing: Boolean, type: Char) {
+ if (closing) {
+ listType = null
+ } else {
+ listType = type
+ if (type == 'o') olCount = 0
+ }
+ newlineIfNeeded()
+ }
+
+ private fun startListItem() {
+ newlineIfNeeded()
+ if (listType == 'o') {
+ olCount++
+ text.append("$olCount. ")
+ } else {
+ text.append(BULLET_PREFIX)
+ }
+ }
+
+ private fun handleQuote(closing: Boolean) {
+ newlineIfNeeded()
+ inQuote = !closing
+ if (!closing) text.append(QUOTE_PREFIX)
+ }
+
+ private fun handleText(chunk: String) {
+ // Drop the insignificant whitespace a pretty-printer leaves between block tags (blank runs at
+ // a line start, or any blank run with a newline), but keep a real space between inline runs.
+ if (!(chunk.isBlank() && (atLineStart() || chunk.contains('\n')))) text.append(unescape(chunk))
+ }
+
+ private fun finish(): RichTextContent {
+ val out = text.toString().trimEnd('\n')
+ val len = out.length
+ if (boldStart in 0 until len) spans.add(RichSpan(boldStart, len, RichStyle.BOLD))
+ if (italicStart in 0 until len) spans.add(RichSpan(italicStart, len, RichStyle.ITALIC))
+ if (underlineStart in 0 until len) spans.add(RichSpan(underlineStart, len, RichStyle.UNDERLINE))
+ if (linkStart in 0 until len) links.add(RichLink(linkStart, len, linkUrl))
+ return RichTextContent(
+ text = out,
+ spans = spans.filter { it.end <= len && it.start < it.end },
+ links = links.filter { it.end <= len && it.start < it.end },
+ )
+ }
+}
+
+private fun extractHref(tagBody: String): String {
+ val match = Regex("href\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE).find(tagBody) ?: return ""
+ val (doubleQuoted, singleQuoted) = match.destructured
+ return unescape(doubleQuoted.ifEmpty { singleQuoted })
+}
+
+private fun escape(s: String): String = s.replace("&", "&").replace("<", "<").replace(">", ">")
+
+private fun escapeAttr(s: String): String = escape(s).replace("\"", """)
+
+private fun unescape(s: String): String = s
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace(""", "\"")
+ .replace("'", "'")
+ .replace("'", "'")
+ .replace(" ", " ")
+ .replace("&", "&")
diff --git a/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt b/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt
new file mode 100644
index 0000000..0e7a149
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt
@@ -0,0 +1,200 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.richtext
+
+/** A block-level marker the toolbar can toggle over the selected lines. */
+enum class BlockMarker { BULLET, ORDERED, QUOTE }
+
+/** The result of an editing op: the new [content] and where the selection should land. */
+data class EditResult(val content: RichTextContent, val selectionStart: Int, val selectionEnd: Int)
+
+/**
+ * Pure editing operations over [RichTextContent] — toggling inline styles/links and block markers —
+ * shared by the Compose editor's toolbar. Keeping them free of Compose types makes the tricky index
+ * bookkeeping (splitting spans, shifting offsets when a line marker is inserted/removed) unit-testable
+ * on the JVM.
+ */
+object RichTextEditing {
+
+ /** Adds [style] over [[start], [end]) if it is not already fully styled, otherwise removes it. */
+ fun toggleStyle(content: RichTextContent, start: Int, end: Int, style: RichStyle): RichTextContent {
+ if (start >= end) return content
+ val others = content.spans.filter { it.style != style }
+ val same = content.spans.filter { it.style == style }
+ val updated = if (isFullyStyled(same, start, end)) {
+ subtractRange(same, start, end)
+ } else {
+ mergeSameStyle(same + RichSpan(start, end, style))
+ }
+ return content.copy(spans = (others + updated).sortedBy { it.start })
+ }
+
+ /** Links [[start], [end]) to [url], replacing any links that overlap the range. */
+ fun applyLink(content: RichTextContent, start: Int, end: Int, url: String): RichTextContent {
+ if (start >= end || url.isBlank()) return content
+ val kept = content.links.filter { it.end <= start || it.start >= end }
+ return content.copy(links = (kept + RichLink(start, end, url)).sortedBy { it.start })
+ }
+
+ /** Removes any links overlapping [[start], [end]). */
+ fun removeLink(content: RichTextContent, start: Int, end: Int): RichTextContent {
+ if (start >= end) return content
+ return content.copy(links = content.links.filter { it.end <= start || it.start >= end })
+ }
+
+ /** Whether [[start], [end]) is entirely covered by [style] (drives the toolbar's toggle state). */
+ fun isStyled(content: RichTextContent, start: Int, end: Int, style: RichStyle): Boolean =
+ start < end && isFullyStyled(content.spans.filter { it.style == style }, start, end)
+
+ /** Whether every line the selection touches carries [marker]. */
+ fun hasBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): Boolean {
+ val lineStarts = lineStartsTouching(content.text, start, end)
+ return lineStarts.isNotEmpty() && lineStarts.all { markerAt(content.text, it) == marker }
+ }
+
+ /**
+ * Toggles [marker] across every line the selection touches: if all those lines already carry it,
+ * it is removed; otherwise it is applied (replacing any other block marker already there). Spans,
+ * links and the selection are shifted to track the inserted/removed prefixes.
+ */
+ fun toggleBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): EditResult {
+ val text = content.text
+ val lineStarts = lineStartsTouching(text, start, end)
+ val allHaveMarker = lineStarts.all { markerAt(text, it) == marker }
+ val edits = ArrayList()
+ var ordinal = 1
+ for (lineStart in lineStarts) {
+ val existing = markerLengthAt(text, lineStart)
+ when {
+ allHaveMarker && existing > 0 -> edits.add(LineEdit(lineStart, existing, ""))
+ allHaveMarker -> Unit
+ else -> edits.add(LineEdit(lineStart, existing, insertFor(marker, ordinal++)))
+ }
+ }
+ val (newText, remap) = applyEdits(text, edits)
+ val newSpans = content.spans.mapNotNull { remapSpan(it, remap) }
+ val newLinks = content.links.mapNotNull { remapLink(it, remap) }
+ return EditResult(RichTextContent(newText, newSpans, newLinks), remap(start), remap(end))
+ }
+}
+
+private fun insertFor(marker: BlockMarker, ordinal: Int): String = when (marker) {
+ BlockMarker.BULLET -> BULLET_PREFIX
+ BlockMarker.QUOTE -> QUOTE_PREFIX
+ BlockMarker.ORDERED -> "$ordinal. "
+}
+
+private fun remapSpan(span: RichSpan, remap: (Int) -> Int): RichSpan? {
+ val s = remap(span.start)
+ val e = remap(span.end)
+ return if (s < e) RichSpan(s, e, span.style) else null
+}
+
+private fun remapLink(link: RichLink, remap: (Int) -> Int): RichLink? {
+ val s = remap(link.start)
+ val e = remap(link.end)
+ return if (s < e) RichLink(s, e, link.url) else null
+}
+
+// --- inline style helpers ---
+
+private fun isFullyStyled(spans: List, start: Int, end: Int): Boolean {
+ var pos = start
+ for (span in spans.filter { it.end > start && it.start < end }.sortedBy { it.start }) {
+ if (span.start > pos) return false
+ pos = maxOf(pos, span.end)
+ if (pos >= end) return true
+ }
+ return pos >= end
+}
+
+private fun subtractRange(spans: List, start: Int, end: Int): List = spans.flatMap { span ->
+ when {
+ span.end <= start || span.start >= end -> listOf(span)
+ else -> buildList {
+ if (span.start < start) add(span.copy(end = start))
+ if (span.end > end) add(span.copy(start = end))
+ }
+ }
+}
+
+private fun mergeSameStyle(spans: List): List {
+ val merged = ArrayList()
+ for (span in spans.sortedBy { it.start }) {
+ val last = merged.lastOrNull()
+ if (last != null && span.start <= last.end) {
+ merged[merged.size - 1] = last.copy(end = maxOf(last.end, span.end))
+ } else {
+ merged.add(span)
+ }
+ }
+ return merged
+}
+
+// --- block marker helpers ---
+
+private val ORDERED = Regex("^\\d+\\. ")
+
+private fun markerAt(text: String, lineStart: Int): BlockMarker? {
+ val rest = text.substring(lineStart)
+ return when {
+ rest.startsWith(BULLET_PREFIX) -> BlockMarker.BULLET
+ rest.startsWith(QUOTE_PREFIX) -> BlockMarker.QUOTE
+ // ORDERED is anchored at ^, so find() matches only when this line starts with "N. ".
+ ORDERED.find(rest) != null -> BlockMarker.ORDERED
+ else -> null
+ }
+}
+
+private fun markerLengthAt(text: String, lineStart: Int): Int {
+ val rest = text.substring(lineStart)
+ return when {
+ rest.startsWith(BULLET_PREFIX) -> BULLET_PREFIX.length
+ rest.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX.length
+ else -> ORDERED.find(rest)?.value?.length ?: 0
+ }
+}
+
+/** Start offsets of every line the range [[start], [end]] intersects (a caret counts as its line). */
+private fun lineStartsTouching(text: String, start: Int, end: Int): List {
+ val from = start.coerceIn(0, text.length)
+ val to = end.coerceIn(from, text.length)
+ val result = ArrayList()
+ var lineStart = if (from == 0) 0 else text.lastIndexOf('\n', from - 1).let { if (it == -1) 0 else it + 1 }
+ while (lineStart <= text.length) {
+ result.add(lineStart)
+ val nl = text.indexOf('\n', lineStart)
+ if (nl == -1 || nl >= to) break
+ lineStart = nl + 1
+ }
+ return result
+}
+
+private data class LineEdit(val offset: Int, val deleteLen: Int, val insert: String)
+
+/** Applies line-start [edits] (ascending, non-overlapping) and returns the new text + an index remap. */
+private fun applyEdits(text: String, edits: List): Pair Int> {
+ if (edits.isEmpty()) return text to { it }
+ val sorted = edits.sortedBy { it.offset }
+ val sb = StringBuilder()
+ var cursor = 0
+ for (edit in sorted) {
+ sb.append(text, cursor, edit.offset)
+ sb.append(edit.insert)
+ cursor = edit.offset + edit.deleteLen
+ }
+ sb.append(text, cursor, text.length)
+ val remap: (Int) -> Int = { index -> remapIndex(index, sorted).coerceIn(0, sb.length) }
+ return sb.toString() to remap
+}
+
+private fun remapIndex(index: Int, edits: List): Int {
+ var delta = 0
+ for (edit in edits) {
+ val delEnd = edit.offset + edit.deleteLen
+ when {
+ delEnd <= index -> delta += edit.insert.length - edit.deleteLen
+ edit.offset < index -> delta += edit.insert.length - (index - edit.offset)
+ }
+ }
+ return index + delta
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
index 0301b0f..5ac1e12 100644
--- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
+++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
@@ -24,6 +24,8 @@ import org.libremail.ui.outbox.OutboxScreen
import org.libremail.ui.reader.ReaderScreen
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() {
@@ -86,8 +88,35 @@ fun LibreMailApp() {
composable(
route = Routes.ACCOUNT_SETTINGS_PATTERN,
arguments = listOf(navArgument(Routes.ACCOUNT_SETTINGS_ARG_ID) { type = NavType.StringType }),
+ ) { backStackEntry ->
+ val accountId = backStackEntry.arguments?.getString(Routes.ACCOUNT_SETTINGS_ARG_ID).orEmpty()
+ AccountSettingsScreen(
+ onBack = navController::popBackStack,
+ 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 = ""
+ },
+ ),
) {
- AccountSettingsScreen(onBack = navController::popBackStack)
+ SignatureEditScreen(onBack = navController::popBackStack)
}
composable(Routes.ACCOUNT_SETUP) {
AccountSetupScreen(
diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt
index 50e748b..9c229d5 100644
--- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt
@@ -145,6 +145,7 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
label = { Text(stringResource(R.string.compose_to)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+ shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
)
if (state.suggestions.isNotEmpty()) {
@@ -158,6 +159,7 @@ 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))
@@ -166,6 +168,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 +177,11 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
onRemove = viewModel::removeAttachment,
)
Spacer(Modifier.height(8.dp))
- OutlinedTextField(
- value = state.body,
- onValueChange = viewModel::onBodyChange,
- label = { Text(stringResource(R.string.compose_body)) },
+ RichTextBodyField(
+ body = state.body,
+ bodyHtml = state.bodyHtml,
+ onBodyChange = viewModel::onBodyChange,
+ label = stringResource(R.string.compose_body),
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt
index d37ca90..cc6a796 100644
--- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt
@@ -18,13 +18,17 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.contacts.ContactSuggestion
import org.libremail.contacts.ContactsRepository
+import org.libremail.data.SignatureBlock
import org.libremail.data.settings.AccountSettingsRepository
+import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.Draft
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
+import org.libremail.richtext.RichTextContent
+import org.libremail.richtext.RichTextHtml
import org.libremail.ui.navigation.Routes
import java.util.UUID
import javax.inject.Inject
@@ -33,7 +37,10 @@ data class ComposeUiState(
val to: String = "",
val cc: String = "",
val subject: String = "",
+ /** The plaintext form of the body (also the `text/plain` fallback when sending). */
val body: String = "",
+ /** The HTML form of the body, or null when the message carries no formatting (plaintext-only). */
+ val bodyHtml: String? = null,
val fromAccountId: String? = null,
val attachments: List = emptyList(),
val suggestions: List = emptyList(),
@@ -49,6 +56,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? =
@@ -76,7 +84,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 +96,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,
)
@@ -112,27 +121,63 @@ class ComposeViewModel @Inject constructor(
fun onCcChange(value: String) = _state.update { it.copy(cc = value) }
fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) }
- fun onBodyChange(value: String) = _state.update { it.copy(body = value) }
+
+ /**
+ * The rich editor reports the current body in both forms: [plain] (also the plaintext fallback)
+ * and [html], which is null when the content carries no formatting so the message stays
+ * plaintext-only. Both are held for sending and for saving the draft.
+ */
+ fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) }
+
fun selectFrom(accountId: String) {
viewModelScope.launch { applySignature(accountId) }
}
/**
- * Sets the sending account and swaps its signature into the body: strips the previously-appended
- * signature block (when the body still ends with it) and appends the newly-selected account's.
+ * Sets the sending account and swaps its default signature into the body: strips the
+ * previously-appended block (when the body still ends with it) and appends the newly-selected
+ * account's, in both the plaintext and HTML representations. Honors the account's
+ * "append signature" preference.
*/
private suspend fun applySignature(accountId: String) {
- val block = accountSettingsRepository.get(accountId).signatureBlock()
+ val settings = accountSettingsRepository.get(accountId)
+ val block = if (settings.signatureEnabled) {
+ SignatureBlock.of(signatureRepository.getDefault(accountId))
+ } else {
+ SignatureBlock.EMPTY
+ }
_state.update { s ->
- val base = if (appliedSignatureBlock.isNotEmpty() && s.body.endsWith(appliedSignatureBlock)) {
- s.body.removeSuffix(appliedSignatureBlock)
- } else {
- s.body
- }
- s.copy(fromAccountId = accountId, body = base + block)
+ val basePlain = s.body.stripSuffixIfPresent(appliedSignatureBlock.plain)
+ val newBody = basePlain + block.plain
+ s.copy(fromAccountId = accountId, body = newBody, bodyHtml = swapHtmlSignature(s.bodyHtml, newBody, block))
}
appliedSignatureBlock = block
}
+
+ /**
+ * Swaps the signature in the HTML body. When the old block is still a clean suffix (the common
+ * case — the user changed accounts before editing), it is stripped and the new one appended,
+ * preserving any formatting the user applied. Otherwise the HTML was re-serialized after editing
+ * and no longer ends with the old block, so it is rebuilt from the plaintext to avoid ever
+ * duplicating the signature (inline styling from before the switch is not preserved in that case).
+ */
+ private fun swapHtmlSignature(currentHtml: String?, newBody: String, block: SignatureBlock): String? {
+ val old = appliedSignatureBlock.html
+ val cleanlyStrippable = old.isEmpty() || currentHtml == null || currentHtml.endsWith(old)
+ val combined = if (cleanlyStrippable) {
+ (currentHtml?.removeSuffix(old) ?: "") + block.html
+ } else {
+ RichTextHtml.toHtml(RichTextContent(newBody))
+ }
+ return normalizedHtml(combined)
+ }
+
+ private fun String.stripSuffixIfPresent(suffix: String): String =
+ if (suffix.isNotEmpty() && endsWith(suffix)) removeSuffix(suffix) else this
+
+ /** Keeps an HTML body only when it actually carries formatting, so plaintext stays plaintext. */
+ private fun normalizedHtml(html: String): String? =
+ if (html.isBlank() || !RichTextHtml.fromHtml(html).hasFormatting()) null else html
fun addAttachments(items: List) =
_state.update { it.copy(attachments = it.attachments + items) }
fun removeAttachment(uri: String) = _state.update {
@@ -200,6 +245,7 @@ class ComposeViewModel @Inject constructor(
subject = s.subject,
body = s.body,
updatedAt = System.currentTimeMillis(),
+ bodyHtml = s.bodyHtml,
attachments = s.attachments,
),
)
@@ -220,7 +266,15 @@ 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,
+ subject = s.subject,
+ body = s.body,
+ bodyHtml = s.bodyHtml,
+ attachments = s.attachments,
+ ),
).fold(
onSuccess = {
draftId?.let { mailRepository.deleteDraft(it) }
diff --git a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
new file mode 100644
index 0000000..95a51b2
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
@@ -0,0 +1,322 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.compose
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import org.libremail.R
+import org.libremail.richtext.BlockMarker
+import org.libremail.richtext.RichLink
+import org.libremail.richtext.RichSpan
+import org.libremail.richtext.RichStyle
+import org.libremail.richtext.RichTextContent
+import org.libremail.richtext.RichTextEditing
+import org.libremail.richtext.RichTextHtml
+
+/** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */
+private const val URL_TAG = "libremail:url"
+
+/**
+ * A rich-text body editor: a formatting toolbar (bold / italic / underline, bulleted + numbered
+ * lists, block quote, and link) above a rounded [OutlinedTextField]. It converts its
+ * [AnnotatedString] to the app's [RichTextContent] model and reports both the plaintext form and its
+ * HTML — or null HTML when nothing is formatted, so an unformatted message stays plaintext-only and
+ * feels exactly like the old editor.
+ *
+ * The field is a normal Compose text field, so TalkBack, text selection, and large system fonts all
+ * work as usual; the toolbar buttons carry content descriptions and toggle state for accessibility.
+ */
+@Composable
+fun RichTextBodyField(
+ body: String,
+ bodyHtml: String?,
+ onBodyChange: (plain: String, html: String?) -> Unit,
+ label: String,
+ modifier: Modifier = Modifier,
+) {
+ val linkColor = MaterialTheme.colorScheme.primary
+ var value by remember { mutableStateOf(seedValue(body, bodyHtml, linkColor)) }
+ // Tracks the (plain, html) we last pushed up, so an external change (draft load / signature swap)
+ // re-seeds the field but our own emissions do not fight the user's cursor.
+ var lastEmitted by remember { mutableStateOf(body to bodyHtml) }
+
+ if (body to bodyHtml != lastEmitted) {
+ value = seedValue(body, bodyHtml, linkColor)
+ lastEmitted = body to bodyHtml
+ }
+
+ fun emit(newValue: TextFieldValue) {
+ value = newValue
+ val content = newValue.annotatedString.toRichContent()
+ val html = if (content.hasFormatting()) RichTextHtml.toHtml(content) else null
+ lastEmitted = content.text to html
+ onBodyChange(content.text, html)
+ }
+
+ var showLinkDialog by remember { mutableStateOf(false) }
+
+ Column(modifier) {
+ FormattingToolbar(
+ value = value,
+ onToggleStyle = { style -> emit(applyStyle(value, style, linkColor)) },
+ onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor)) },
+ onLink = { showLinkDialog = true },
+ )
+ OutlinedTextField(
+ value = value,
+ onValueChange = ::emit,
+ label = { Text(label) },
+ shape = MaterialTheme.shapes.large,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ )
+ }
+
+ if (showLinkDialog) {
+ val hasSelection = value.selection.min < value.selection.max
+ LinkDialog(
+ enabled = hasSelection,
+ onDismiss = { showLinkDialog = false },
+ onConfirm = { url ->
+ emit(applyLink(value, url, linkColor))
+ showLinkDialog = false
+ },
+ )
+ }
+}
+
+@Composable
+private fun FormattingToolbar(
+ value: TextFieldValue,
+ onToggleStyle: (RichStyle) -> Unit,
+ onToggleBlock: (BlockMarker) -> Unit,
+ onLink: () -> Unit,
+) {
+ val content = value.annotatedString.toRichContent()
+ val start = value.selection.min
+ val end = value.selection.max
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ FormatButton(
+ label = "B",
+ description = stringResource(R.string.format_bold),
+ active = RichTextEditing.isStyled(content, start, end, RichStyle.BOLD),
+ fontWeight = FontWeight.Bold,
+ onClick = { onToggleStyle(RichStyle.BOLD) },
+ )
+ FormatButton(
+ label = "I",
+ description = stringResource(R.string.format_italic),
+ active = RichTextEditing.isStyled(content, start, end, RichStyle.ITALIC),
+ fontStyle = FontStyle.Italic,
+ onClick = { onToggleStyle(RichStyle.ITALIC) },
+ )
+ FormatButton(
+ label = "U",
+ description = stringResource(R.string.format_underline),
+ active = RichTextEditing.isStyled(content, start, end, RichStyle.UNDERLINE),
+ underline = true,
+ onClick = { onToggleStyle(RichStyle.UNDERLINE) },
+ )
+ FormatButton(
+ label = "•",
+ description = stringResource(R.string.format_bullet_list),
+ active = RichTextEditing.hasBlock(content, start, end, BlockMarker.BULLET),
+ onClick = { onToggleBlock(BlockMarker.BULLET) },
+ )
+ FormatButton(
+ label = "1.",
+ description = stringResource(R.string.format_numbered_list),
+ active = RichTextEditing.hasBlock(content, start, end, BlockMarker.ORDERED),
+ onClick = { onToggleBlock(BlockMarker.ORDERED) },
+ )
+ FormatButton(
+ label = "❝",
+ description = stringResource(R.string.format_quote),
+ active = RichTextEditing.hasBlock(content, start, end, BlockMarker.QUOTE),
+ onClick = { onToggleBlock(BlockMarker.QUOTE) },
+ )
+ FormatButton(
+ label = "🔗",
+ description = stringResource(R.string.format_link),
+ active = false,
+ onClick = onLink,
+ )
+ }
+}
+
+@Composable
+private fun FormatButton(
+ label: String,
+ description: String,
+ active: Boolean,
+ onClick: () -> Unit,
+ fontWeight: FontWeight? = null,
+ fontStyle: FontStyle? = null,
+ underline: Boolean = false,
+) {
+ val colors = MaterialTheme.colorScheme
+ val background = if (active) colors.secondaryContainer else Color.Transparent
+ val textColor = if (active) colors.onSecondaryContainer else colors.onSurfaceVariant
+ Box(
+ modifier = Modifier
+ .clip(MaterialTheme.shapes.small)
+ .background(background)
+ .clickable(onClick = onClick, role = Role.Button, onClickLabel = description)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = label,
+ color = textColor,
+ style = LocalTextStyle.current.copy(
+ fontWeight = fontWeight,
+ fontStyle = fontStyle,
+ textDecoration = if (underline) TextDecoration.Underline else null,
+ ),
+ )
+ }
+}
+
+@Composable
+private fun LinkDialog(enabled: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
+ var url by remember { mutableStateOf("") }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.format_link_title)) },
+ text = {
+ if (enabled) {
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ singleLine = true,
+ label = { Text(stringResource(R.string.format_link_url)) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ } else {
+ Text(stringResource(R.string.format_link_needs_selection))
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onConfirm(url.trim()) }, enabled = enabled && url.isNotBlank()) {
+ Text(stringResource(R.string.format_link_apply))
+ }
+ },
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
+ )
+}
+
+// --- editor-op plumbing (TextFieldValue <-> RichTextContent) ---
+
+private fun applyStyle(value: TextFieldValue, style: RichStyle, linkColor: Color): TextFieldValue {
+ val updated = RichTextEditing.toggleStyle(
+ value.annotatedString.toRichContent(),
+ value.selection.min,
+ value.selection.max,
+ style,
+ )
+ return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
+}
+
+private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Color): TextFieldValue {
+ val result = RichTextEditing.toggleBlock(
+ value.annotatedString.toRichContent(),
+ value.selection.min,
+ value.selection.max,
+ marker,
+ )
+ return TextFieldValue(
+ result.content.toAnnotatedString(linkColor),
+ TextRange(result.selectionStart, result.selectionEnd),
+ )
+}
+
+private fun applyLink(value: TextFieldValue, url: String, linkColor: Color): TextFieldValue {
+ val updated = RichTextEditing.applyLink(
+ value.annotatedString.toRichContent(),
+ value.selection.min,
+ value.selection.max,
+ url,
+ )
+ return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
+}
+
+private fun seedValue(body: String, bodyHtml: String?, linkColor: Color): TextFieldValue {
+ val content = if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body)
+ val annotated = content.toAnnotatedString(linkColor)
+ return TextFieldValue(annotated, TextRange(annotated.length))
+}
+
+/** Maps the app rich-text model onto a Compose [AnnotatedString] for display/editing. */
+internal fun RichTextContent.toAnnotatedString(linkColor: Color): AnnotatedString = buildAnnotatedString {
+ append(text)
+ spans.forEach { span -> addStyle(spanStyleFor(span.style), span.start, span.end) }
+ links.forEach { link ->
+ addStyle(SpanStyle(color = linkColor), link.start, link.end)
+ addStringAnnotation(URL_TAG, link.url, link.start, link.end)
+ }
+}
+
+/** Maps a Compose [AnnotatedString] back to the app model, reading single-attribute span styles. */
+internal fun AnnotatedString.toRichContent(): RichTextContent {
+ val richSpans = spanStyles.mapNotNull { range ->
+ styleOf(range.item)?.let { RichSpan(range.start, range.end, it) }
+ }
+ val links = getStringAnnotations(URL_TAG, 0, length).map { RichLink(it.start, it.end, it.item) }
+ return RichTextContent(text, richSpans, links)
+}
+
+private fun spanStyleFor(style: RichStyle): SpanStyle = when (style) {
+ RichStyle.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
+ RichStyle.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
+ RichStyle.UNDERLINE -> SpanStyle(textDecoration = TextDecoration.Underline)
+}
+
+private fun styleOf(span: SpanStyle): RichStyle? = when {
+ span.fontWeight == FontWeight.Bold -> RichStyle.BOLD
+ span.fontStyle == FontStyle.Italic -> RichStyle.ITALIC
+ span.textDecoration == TextDecoration.Underline -> RichStyle.UNDERLINE
+ else -> null // e.g. the link color span, which is carried by the URL annotation instead
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt
index 5ddb2d5..76ae954 100644
--- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt
+++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt
@@ -20,6 +20,17 @@ 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 COMPOSE_ARG_TO = "to"
const val COMPOSE_ARG_SUBJECT = "subject"
const val COMPOSE_ARG_FROM = "from"
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt
index 07e1c38..bdb68b5 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt
@@ -6,7 +6,6 @@ import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -17,7 +16,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -26,17 +24,21 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewModel = hiltViewModel()) {
+fun AccountSettingsScreen(
+ onBack: () -> Unit,
+ onManageSignatures: () -> Unit,
+ viewModel: AccountSettingsViewModel = hiltViewModel(),
+) {
val account by viewModel.account.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
- val signature by viewModel.signature.collectAsStateWithLifecycle()
+ val signatureCount by viewModel.signatureCount.collectAsStateWithLifecycle()
+ val defaultSignatureName by viewModel.defaultSignatureName.collectAsStateWithLifecycle()
val context = LocalContext.current
val fallbackTitle = stringResource(R.string.settings_account_title)
@@ -67,15 +69,14 @@ fun AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewMode
checked = settings.signatureEnabled,
onCheckedChange = viewModel::setSignatureEnabled,
)
- OutlinedTextField(
- value = signature ?: "",
- onValueChange = viewModel::onSignatureChange,
- enabled = settings.signatureEnabled,
- label = { Text(stringResource(R.string.settings_signature_hint)) },
- minLines = 3,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
+ ClickRow(
+ title = stringResource(R.string.settings_signatures_manage),
+ subtitle = if (signatureCount == 0) {
+ stringResource(R.string.settings_signatures_summary_none)
+ } else {
+ stringResource(R.string.settings_signatures_summary, signatureCount, defaultSignatureName)
+ },
+ onClick = onManageSignatures,
)
HorizontalDivider()
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt
index 2436360..733cd74 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt
@@ -5,15 +5,13 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.data.settings.AccountSettingsRepository
+import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AccountSettings
import org.libremail.domain.repository.AccountRepository
@@ -26,6 +24,7 @@ class AccountSettingsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val accountRepository: AccountRepository,
private val accountSettingsRepository: AccountSettingsRepository,
+ signatureRepository: SignatureRepository,
) : ViewModel() {
private val accountId: String =
@@ -38,26 +37,20 @@ class AccountSettingsViewModel @Inject constructor(
val settings: StateFlow = accountSettingsRepository.observe(accountId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AccountSettings(accountId))
- // The signature text is edited locally (seeded once from persistence) so the field stays
- // responsive — a fully DB-driven value would lag each keystroke and jump the cursor.
- private val _signature = MutableStateFlow(null)
- val signature: StateFlow = _signature.asStateFlow()
+ private val signaturesFlow = signatureRepository.observeForAccount(accountId)
+
+ val signatureCount: StateFlow = signaturesFlow
+ .map { it.size }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
+
+ /** The name of the account's default signature (for the settings summary line), or "". */
+ val defaultSignatureName: StateFlow = signaturesFlow
+ .map { list -> list.firstOrNull { it.isDefault }?.name ?: list.firstOrNull()?.name ?: "" }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "")
/** This account's notification channel id, for deep-linking into Android's system settings. */
val notificationChannelId: String = MailNotifier.channelId(accountId)
- init {
- viewModelScope.launch {
- val loaded = accountSettingsRepository.get(accountId).signature
- _signature.update { it ?: loaded } // don't clobber any text typed before the load returned
- }
- }
-
- fun onSignatureChange(value: String) {
- _signature.value = value
- viewModelScope.launch { accountSettingsRepository.setSignature(accountId, value) }
- }
-
fun setSignatureEnabled(value: Boolean) {
viewModelScope.launch { accountSettingsRepository.setSignatureEnabled(accountId, value) }
}
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt
new file mode 100644
index 0000000..b656ebd
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.settings
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.libremail.R
+import org.libremail.ui.compose.RichTextBodyField
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignatureEditScreen(onBack: () -> Unit, viewModel: SignatureEditViewModel = hiltViewModel()) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(
+ if (viewModel.isNew) R.string.signature_new_title else R.string.signature_edit_title,
+ ),
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.action_back),
+ )
+ }
+ },
+ actions = {
+ TextButton(onClick = { viewModel.save(onBack) }) {
+ Text(stringResource(R.string.signature_save))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ ) {
+ OutlinedTextField(
+ value = state.name,
+ onValueChange = viewModel::onNameChange,
+ label = { Text(stringResource(R.string.signature_name)) },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(8.dp))
+ // Reuse the compose editor so signatures are rich in exactly the same way message bodies are.
+ if (state.loaded) {
+ RichTextBodyField(
+ body = state.body,
+ bodyHtml = state.bodyHtml,
+ onBodyChange = viewModel::onBodyChange,
+ label = stringResource(R.string.signature_content),
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt
new file mode 100644
index 0000000..3bfe833
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.settings
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.libremail.data.settings.SignatureRepository
+import org.libremail.richtext.RichTextContent
+import org.libremail.richtext.RichTextHtml
+import org.libremail.ui.navigation.Routes
+import javax.inject.Inject
+
+data class SignatureEditUiState(
+ val name: String = "",
+ val body: String = "",
+ val bodyHtml: String? = null,
+ val loaded: Boolean = false,
+)
+
+@HiltViewModel
+class SignatureEditViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val signatureRepository: SignatureRepository,
+) : ViewModel() {
+
+ private val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURE_EDIT_ARG_ACCOUNT])
+ private val signatureId: String? =
+ savedStateHandle.get(Routes.SIGNATURE_EDIT_ARG_ID)?.takeIf { it.isNotBlank() }
+
+ val isNew: Boolean = signatureId == null
+
+ private val _state = MutableStateFlow(SignatureEditUiState(loaded = isNew))
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ if (signatureId != null) {
+ viewModelScope.launch {
+ signatureRepository.get(signatureId)?.let { signature ->
+ _state.update {
+ it.copy(
+ name = signature.name,
+ body = signature.plainText(),
+ bodyHtml = signature.html.ifBlank { null },
+ loaded = true,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun onNameChange(value: String) = _state.update { it.copy(name = value) }
+
+ fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) }
+
+ /** Persists the signature (create or update), then invokes [onSaved]. */
+ fun save(onSaved: () -> Unit) {
+ val s = _state.value
+ val name = s.name.trim().ifBlank { DEFAULT_NAME }
+ // Store real HTML so the signature round-trips; derive it from the plaintext when unformatted.
+ val html = s.bodyHtml ?: RichTextHtml.toHtml(RichTextContent(s.body))
+ viewModelScope.launch {
+ if (signatureId == null) {
+ signatureRepository.create(accountId, name, html)
+ } else {
+ signatureRepository.update(signatureId, name, html)
+ }
+ onSaved()
+ }
+ }
+
+ private companion object {
+ const val DEFAULT_NAME = "Signature"
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt
new file mode 100644
index 0000000..25ef189
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt
@@ -0,0 +1,135 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.settings
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.libremail.R
+import org.libremail.domain.model.Signature
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignaturesScreen(
+ onBack: () -> Unit,
+ onEdit: (signatureId: String) -> Unit,
+ onAdd: () -> Unit,
+ viewModel: SignaturesViewModel = hiltViewModel(),
+) {
+ val signatures by viewModel.signatures.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.signatures_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.action_back),
+ )
+ }
+ },
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = onAdd) {
+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.signatures_add))
+ }
+ },
+ ) { padding ->
+ if (signatures.isEmpty()) {
+ Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) {
+ Text(
+ stringResource(R.string.signatures_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ LazyColumn(Modifier.fillMaxSize().padding(padding)) {
+ items(signatures, key = { it.id }) { signature ->
+ SignatureRow(
+ signature = signature,
+ onClick = { onEdit(signature.id) },
+ onMakeDefault = { viewModel.setDefault(signature.id) },
+ onDelete = { viewModel.delete(signature.id) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SignatureRow(signature: Signature, onClick: () -> Unit, onMakeDefault: () -> Unit, onDelete: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ RadioButton(
+ selected = signature.isDefault,
+ onClick = onMakeDefault,
+ )
+ Column(Modifier.weight(1f)) {
+ Text(
+ signature.name.ifBlank { stringResource(R.string.signature_content) },
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ val preview = signature.plainText().replace('\n', ' ').trim()
+ if (preview.isNotEmpty()) {
+ Text(
+ preview,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (signature.isDefault) {
+ Text(
+ stringResource(R.string.signature_default_badge),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ IconButton(onClick = onDelete) {
+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.signature_delete))
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt
new file mode 100644
index 0000000..baa6b94
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.settings
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.libremail.data.settings.SignatureRepository
+import org.libremail.domain.model.Signature
+import org.libremail.ui.navigation.Routes
+import javax.inject.Inject
+
+@HiltViewModel
+class SignaturesViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val signatureRepository: SignatureRepository,
+) : ViewModel() {
+
+ val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURES_ARG_ACCOUNT])
+
+ val signatures: StateFlow> = signatureRepository.observeForAccount(accountId)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ fun setDefault(id: String) {
+ viewModelScope.launch { signatureRepository.setDefault(accountId, id) }
+ }
+
+ fun delete(id: String) {
+ viewModelScope.launch { signatureRepository.delete(id) }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a4df47f..5f5363b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -77,6 +77,19 @@
Attach file
Remove attachment
+
+ Bold
+ Italic
+ Underline
+ Bulleted list
+ Numbered list
+ Block quote
+ Insert link
+ Insert link
+ Link address
+ Link
+ Select some text first, then add a link.
+
Drafts
No drafts
@@ -146,8 +159,25 @@
Account
Signature
- Append signature
+ Append signature automatically
Your signature
+ Manage signatures
+ No signatures yet
+ %1$d signature(s), default: %2$s
+
+
+ Signatures
+ No signatures yet. Add one to append it to your messages.
+ Add signature
+ New signature
+ Edit signature
+ Name
+ Signature
+ Save
+ Delete signature
+ Default
+ Set as default
+ Default
Notifications
New mail notifications
Show notifications for this account
diff --git a/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt
index 5475d28..49fc6c2 100644
--- a/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt
+++ b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt
@@ -105,4 +105,26 @@ class ReplyBuilderTest {
assertTrue(result.body.contains("Hello"), "body=${result.body}")
assertTrue(result.body.contains("there"), "body=${result.body}")
}
+
+ @Test
+ fun `html original is quoted into a blockquote without leaking original tags`() {
+ val result = ReplyBuilder.build(
+ context(body = "Hello there
", isHtml = true),
+ ReplyMode.REPLY,
+ "me@example.org",
+ )
+
+ // The HTML alternative wraps the (tag-stripped) original in a blockquote — never raw tags.
+ assertTrue(result.bodyHtml.contains(""), "html=${result.bodyHtml}")
+ assertTrue(result.bodyHtml.contains("Hello there"), "html=${result.bodyHtml}")
+ assertFalse(result.bodyHtml.contains("Hello"), "html=${result.bodyHtml}")
+ }
+
+ @Test
+ fun `plaintext reply also carries an html blockquote alternative`() {
+ val result = ReplyBuilder.build(context(body = "First line\nSecond line"), ReplyMode.REPLY, "me@example.org")
+
+ assertTrue(result.bodyHtml.contains("
"), "html=${result.bodyHtml}")
+ assertTrue(result.bodyHtml.contains("First line"), "html=${result.bodyHtml}")
+ }
}
diff --git a/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt b/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt
new file mode 100644
index 0000000..b3c7119
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data
+
+import org.junit.Test
+import org.libremail.domain.model.Signature
+import org.libremail.richtext.RichTextHtml
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class SignatureBlockTest {
+
+ private fun signature(html: String) =
+ Signature(id = "s", accountId = "a", name = "Sig", html = html, isDefault = true)
+
+ @Test
+ fun `plain form opens with the RFC 3676 delimiter`() {
+ val block = SignatureBlock.of(signature("Cheers, Alice"))
+ assertEquals("\n\n-- \nCheers, Alice", block.plain)
+ }
+
+ @Test
+ fun `html form parses back to exactly the plain form`() {
+ val block = SignatureBlock.of(signature("Cheers, Alice"))
+ assertEquals(block.plain, RichTextHtml.fromHtml(block.html).text)
+ }
+
+ @Test
+ fun `a rich signature keeps its formatting in the html form`() {
+ val block = SignatureBlock.of(signature("Cheers, Alice"))
+ assertTrue(block.html.endsWith("Cheers, Alice"), block.html)
+ assertEquals("\n\n-- \nCheers, Alice", block.plain)
+ }
+
+ @Test
+ fun `null or blank signature yields the empty block`() {
+ assertTrue(SignatureBlock.of(null).isEmpty)
+ assertTrue(SignatureBlock.of(signature("")).isEmpty)
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt b/app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt
new file mode 100644
index 0000000..2e57478
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.local
+
+import org.junit.Test
+import org.libremail.data.local.entity.OutboxEntity
+import org.libremail.domain.model.Draft
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class MappersHtmlBodyTest {
+
+ @Test
+ fun `draft html body round-trips through the entity`() {
+ val draft = Draft(
+ id = "d1",
+ accountId = "acct",
+ to = "a@x.com",
+ cc = "",
+ subject = "Hi",
+ body = "Hello",
+ updatedAt = 1L,
+ bodyHtml = "Hello there
",
+ )
+
+ val restored = draft.toEntity().toDomain()
+
+ assertEquals("Hello there
", restored.bodyHtml)
+ assertEquals("Hello", restored.body)
+ }
+
+ @Test
+ fun `a plaintext draft keeps a null html body`() {
+ val draft = Draft("d2", "acct", "a@x.com", "", "Hi", "Hello", 1L)
+ assertNull(draft.toEntity().toDomain().bodyHtml)
+ }
+
+ @Test
+ fun `outbox entity maps its html body to the domain`() {
+ val entity = OutboxEntity(
+ id = "o1",
+ accountId = "acct",
+ toAddresses = "a@x.com",
+ ccAddresses = "",
+ subject = "Hi",
+ body = "Hello",
+ createdAt = 1L,
+ bodyHtml = "Hi
",
+ )
+
+ assertEquals("Hi
", entity.toDomain().bodyHtml)
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt
index 56edec2..a40ed20 100644
--- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt
+++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt
@@ -25,6 +25,7 @@ import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.settings.AccountSettingsRepository
+import org.libremail.data.settings.SignatureRepository
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.domain.model.AccountSettings
import org.libremail.domain.model.FolderRole
@@ -54,6 +55,7 @@ class MailRepositoryImplTest {
private val connectionFactory = mockk()
private val context = mockk(relaxed = true)
private val accountSettingsRepository = mockk()
+ private val signatureRepository = mockk()
private val repository = MailRepositoryImpl(
context = context,
messageDao = messageDao,
@@ -66,6 +68,7 @@ class MailRepositoryImplTest {
connectionFactory = connectionFactory,
sendScheduler = mockk(),
accountSettingsRepository = accountSettingsRepository,
+ signatureRepository = signatureRepository,
)
@Test
@@ -202,6 +205,7 @@ class MailRepositoryImplTest {
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
+ coEvery { signatureRepository.getDefault(any()) } returns null
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
coEvery { imapClient.fetchForReply(any(), "INBOX", "2") } returns ReplyContext(
fromEmail = "boss@example.org",
@@ -220,6 +224,42 @@ class MailRepositoryImplTest {
assertTrue(result.isSuccess)
assertEquals("boss@example.org", draft.captured.toAddresses)
assertEquals("Re: Plan", draft.captured.subject)
+ // The reply carries an HTML alternative with the quote rendered as a blockquote.
+ assertTrue(draft.captured.bodyHtml?.contains("") == true, "html=${draft.captured.bodyHtml}")
+ }
+
+ @Test
+ fun `buildReplyDraft bakes the account default signature above the quote`() = runTest {
+ val id = "acct:INBOX:3"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
+ coEvery { signatureRepository.getDefault("acct") } returns org.libremail.domain.model.Signature(
+ id = "acct:sig",
+ accountId = "acct",
+ name = "Signature",
+ html = "Regards, Ada",
+ isDefault = true,
+ )
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { imapClient.fetchForReply(any(), "INBOX", "3") } returns ReplyContext(
+ fromEmail = "boss@example.org",
+ toRecipients = listOf("ada@example.org"),
+ ccRecipients = emptyList(),
+ subject = "Plan",
+ sentDateMillis = 0L,
+ body = "Original",
+ isHtml = false,
+ )
+ val draft = slot()
+ coEvery { draftDao.upsert(capture(draft)) } just Runs
+
+ repository.buildReplyDraft(id, ReplyMode.REPLY)
+
+ val body = draft.captured.body
+ // Signature is placed before (above) the quoted original.
+ assertTrue(body.contains("Regards, Ada"), "body=$body")
+ assertTrue(body.indexOf("Regards, Ada") < body.indexOf("> Original"), "body=$body")
}
@Test
diff --git a/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt b/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt
new file mode 100644
index 0000000..b38c0df
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt
@@ -0,0 +1,74 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.settings
+
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.libremail.data.local.dao.SignatureDao
+import org.libremail.data.local.entity.SignatureEntity
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class SignatureRepositoryTest {
+
+ private val dao = mockk(relaxed = true)
+ private val repository = SignatureRepository(dao)
+
+ private fun entity(id: String, isDefault: Boolean) =
+ SignatureEntity(id, accountId = "acct", name = "N", contentHtml = "x
", isDefault = isDefault)
+
+ @Test
+ fun `the first signature for an account becomes its default`() = runTest {
+ coEvery { dao.countForAccount("acct") } returns 0
+ val saved = slot()
+ coEvery { dao.upsert(capture(saved)) } just Runs
+
+ repository.create("acct", "Work", "hi
")
+
+ assertTrue(saved.captured.isDefault)
+ }
+
+ @Test
+ fun `later signatures are not made default`() = runTest {
+ coEvery { dao.countForAccount("acct") } returns 2
+ val saved = slot()
+ coEvery { dao.upsert(capture(saved)) } just Runs
+
+ repository.create("acct", "Personal", "hey
")
+
+ assertFalse(saved.captured.isDefault)
+ }
+
+ @Test
+ fun `deleting the default promotes the first remaining signature`() = runTest {
+ coEvery { dao.getById("s1") } returns entity("s1", isDefault = true)
+ coEvery { dao.firstForAccount("acct") } returns entity("s2", isDefault = false)
+
+ repository.delete("s1")
+
+ coVerify { dao.delete("s1") }
+ coVerify { dao.markDefault("s2") }
+ }
+
+ @Test
+ fun `deleting a non-default signature promotes nothing`() = runTest {
+ coEvery { dao.getById("s2") } returns entity("s2", isDefault = false)
+
+ repository.delete("s2")
+
+ coVerify { dao.delete("s2") }
+ coVerify(exactly = 0) { dao.firstForAccount(any()) }
+ coVerify(exactly = 0) { dao.markDefault(any()) }
+ }
+
+ @Test
+ fun `setDefault delegates to the dao's atomic swap`() = runTest {
+ repository.setDefault("acct", "s1")
+ coVerify { dao.setDefault("acct", "s1") }
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt
index 758f3af..0e9a25f 100644
--- a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt
+++ b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt
@@ -34,6 +34,28 @@ class GraphSenderTest {
assertEquals("c@z.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address"))
}
+ @Test
+ fun `payload uses the HTML content type when a formatted body is present`() {
+ val message = OutgoingMessage(
+ accountId = "outlook:me@example.com",
+ to = "a@x.com",
+ subject = "Hi",
+ body = "Hello world",
+ bodyHtml = "Hello world
",
+ )
+ val body = JSONObject(buildSendMailPayload(message, emptyList()))
+ .getJSONObject("message").getJSONObject("body")
+ assertEquals("HTML", body.getString("contentType"))
+ assertEquals("Hello world
", body.getString("content"))
+ }
+
+ @Test
+ fun `payload falls back to plain text when there is no HTML body`() {
+ val body = JSONObject(buildSendMailPayload(message(to = "a@x.com"), emptyList()))
+ .getJSONObject("message").getJSONObject("body")
+ assertEquals("Text", body.getString("contentType"))
+ }
+
@Test
fun `recipients parse RFC822 display names into bare addresses`() {
val json = JSONObject(
diff --git a/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt
new file mode 100644
index 0000000..a5e67be
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.mail
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class HtmlToTextTest {
+
+ @Test
+ fun `strips inline tags and keeps the words`() {
+ assertEquals("Hello world", HtmlToText.convert("Hello world
"))
+ }
+
+ @Test
+ fun `br and paragraphs become line breaks`() {
+ assertEquals("Line one\nLine two", HtmlToText.convert("Line one
Line two"))
+ assertEquals("A\n\nB", HtmlToText.convert("A
B
"))
+ }
+
+ @Test
+ fun `list items gain bullet markers`() {
+ assertEquals("• One\n• Two", HtmlToText.convert(""))
+ }
+
+ @Test
+ fun `decodes entities and drops script and style content`() {
+ assertEquals("Tom & Jerry", HtmlToText.convert("Tom & Jerry"))
+ val converted = HtmlToText.convert("Safe")
+ assertEquals("Safe", converted)
+ assertFalse(converted.contains("alert"))
+ }
+
+ @Test
+ fun `collapses excess whitespace`() {
+ val converted = HtmlToText.convert("Hello there
\n\n\nbye
")
+ assertTrue(converted.contains("Hello there"), converted)
+ assertFalse(converted.contains("Hello there"), converted)
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt
index 32fa157..52fd553 100644
--- a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt
+++ b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt
@@ -61,6 +61,74 @@ class SmtpSenderTest {
assertTrue(GreenMailUtil.getBody(received[0]).contains("Hello there"))
}
+ @Test
+ fun `a formatted message is sent as multipart alternative with both parts`() = runTest {
+ val params = SmtpParams(
+ host = "127.0.0.1",
+ port = greenMail.smtp.port,
+ security = MailSecurity.NONE,
+ username = "sender@example.org",
+ secret = "secret",
+ useXoauth2 = false,
+ )
+
+ sender.send(
+ params = params,
+ from = "sender@example.org",
+ message = OutgoingMessage(
+ accountId = "x",
+ to = "bob@example.org",
+ subject = "Rich",
+ body = "Hello world",
+ bodyHtml = "Hello world
",
+ ),
+ )
+
+ greenMail.waitForIncomingEmail(1)
+ val received = greenMail.receivedMessages.single()
+ assertTrue(received.contentType.contains("multipart/alternative", ignoreCase = true), received.contentType)
+ val raw = GreenMailUtil.getWholeMessage(received)
+ assertTrue(raw.contains("text/plain", ignoreCase = true), "missing text/plain")
+ assertTrue(raw.contains("text/html", ignoreCase = true), "missing text/html")
+ assertTrue(raw.contains("world"), "missing html body")
+ assertTrue(raw.contains("Hello world"), "missing plaintext fallback")
+ }
+
+ @Test
+ fun `a formatted message with an attachment nests the alternative inside mixed`() = runTest {
+ val file = File.createTempFile("libremail-note", ".txt").apply { writeText("attached note") }
+ val params = SmtpParams(
+ host = "127.0.0.1",
+ port = greenMail.smtp.port,
+ security = MailSecurity.NONE,
+ username = "sender@example.org",
+ secret = "secret",
+ useXoauth2 = false,
+ )
+
+ sender.send(
+ params = params,
+ from = "sender@example.org",
+ message = OutgoingMessage(
+ accountId = "x",
+ to = "bob@example.org",
+ subject = "Rich + file",
+ body = "Body",
+ bodyHtml = "Body
",
+ ),
+ attachments = listOf(file),
+ )
+
+ greenMail.waitForIncomingEmail(1)
+ val received = greenMail.receivedMessages.single()
+ assertTrue(received.contentType.contains("multipart/mixed", ignoreCase = true), received.contentType)
+ val raw = GreenMailUtil.getWholeMessage(received)
+ assertTrue(raw.contains("multipart/alternative", ignoreCase = true), "missing alternative part")
+ assertTrue(raw.contains(file.name), "missing attachment")
+ assertTrue(raw.contains("Body"), "missing html body")
+ file.delete()
+ }
+
@Test
fun `send delivers a message with an attachment`() = runTest {
val file = File.createTempFile("libremail-report", ".txt").apply { writeText("quarterly numbers") }
diff --git a/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt b/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt
new file mode 100644
index 0000000..f0cb15b
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt
@@ -0,0 +1,70 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.richtext
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class RichTextEditingTest {
+
+ @Test
+ fun `toggleStyle adds then removes a style over the selection`() {
+ val base = RichTextContent("hello")
+ val bold = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.BOLD)
+ assertEquals(listOf(RichSpan(0, 5, RichStyle.BOLD)), bold.spans)
+
+ val plain = RichTextEditing.toggleStyle(bold, 0, 5, RichStyle.BOLD)
+ assertTrue(plain.spans.isEmpty())
+ }
+
+ @Test
+ fun `toggleStyle over a fully styled sub-range removes just that part`() {
+ val bold = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.BOLD)))
+ val result = RichTextEditing.toggleStyle(bold, 1, 3, RichStyle.BOLD)
+ assertEquals(
+ listOf(RichSpan(0, 1, RichStyle.BOLD), RichSpan(3, 5, RichStyle.BOLD)),
+ result.spans.sortedBy { it.start },
+ )
+ }
+
+ @Test
+ fun `applyLink links the selection and removes overlapping links`() {
+ val base = RichTextContent("see here")
+ val linked = RichTextEditing.applyLink(base, 4, 8, "http://x")
+ assertEquals(listOf(RichLink(4, 8, "http://x")), linked.links)
+
+ val relinked = RichTextEditing.applyLink(linked, 0, 8, "http://y")
+ assertEquals(listOf(RichLink(0, 8, "http://y")), relinked.links)
+ }
+
+ @Test
+ fun `toggleBlock adds a bullet marker to the caret's line and shifts spans`() {
+ val base = RichTextContent("ab", spans = listOf(RichSpan(0, 2, RichStyle.BOLD)))
+ val result = RichTextEditing.toggleBlock(base, 0, 0, BlockMarker.BULLET)
+ assertEquals("• ab", result.content.text)
+ assertEquals(listOf(RichSpan(2, 4, RichStyle.BOLD)), result.content.spans)
+ }
+
+ @Test
+ fun `toggleBlock numbers each line of a multi-line selection`() {
+ val base = RichTextContent("a\nb\nc")
+ val result = RichTextEditing.toggleBlock(base, 0, 5, BlockMarker.ORDERED)
+ assertEquals("1. a\n2. b\n3. c", result.content.text)
+ }
+
+ @Test
+ fun `toggleBlock removes the marker when every selected line already has it`() {
+ val base = RichTextContent("• a\n• b")
+ val result = RichTextEditing.toggleBlock(base, 0, base.text.length, BlockMarker.BULLET)
+ assertEquals("a\nb", result.content.text)
+ assertFalse(RichTextEditing.hasBlock(result.content, 0, result.content.text.length, BlockMarker.BULLET))
+ }
+
+ @Test
+ fun `toggleBlock replaces a different marker in place`() {
+ val base = RichTextContent("• a")
+ val result = RichTextEditing.toggleBlock(base, 0, 3, BlockMarker.QUOTE)
+ assertEquals("> a", result.content.text)
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt
new file mode 100644
index 0000000..7bf7657
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.richtext
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class RichTextHtmlTest {
+
+ @Test
+ fun `plain paragraphs become p with br line breaks`() {
+ val html = RichTextHtml.toHtml(RichTextContent("Hello\nWorld"))
+ assertEquals("Hello
World
", html)
+ }
+
+ @Test
+ fun `inline styles nest as b i u`() {
+ val content = RichTextContent(
+ text = "bold italic under",
+ spans = listOf(
+ RichSpan(0, 4, RichStyle.BOLD),
+ RichSpan(5, 11, RichStyle.ITALIC),
+ RichSpan(12, 17, RichStyle.UNDERLINE),
+ ),
+ )
+ assertEquals("bold italic under
", RichTextHtml.toHtml(content))
+ }
+
+ @Test
+ fun `overlapping styles stay valid html`() {
+ val content = RichTextContent(
+ text = "abcd",
+ spans = listOf(RichSpan(0, 3, RichStyle.BOLD), RichSpan(1, 4, RichStyle.ITALIC)),
+ )
+ // b over [0,3), i over [1,4): every run fully closes its tags, so nesting is always valid.
+ assertEquals("abcd
", RichTextHtml.toHtml(content))
+ }
+
+ @Test
+ fun `bulleted and numbered lists and quotes map to block tags`() {
+ assertEquals("", RichTextHtml.toHtml(RichTextContent("• Milk\n• Eggs")))
+ assertEquals("- One
- Two
", RichTextHtml.toHtml(RichTextContent("1. One\n2. Two")))
+ assertEquals("a
b
", RichTextHtml.toHtml(RichTextContent("> a\n> b")))
+ }
+
+ @Test
+ fun `links render as anchors and text is html escaped`() {
+ val content = RichTextContent("a&c", links = listOf(RichLink(0, 1, "http://x?y=1&z")))
+ val html = RichTextHtml.toHtml(content)
+ assertTrue(html.contains("a"), html)
+ assertTrue(html.contains("<b>&c"), html)
+ }
+
+ @Test
+ fun `plaintext keeps the readable markers`() {
+ assertEquals("• Milk\n> quote", RichTextHtml.toPlainText(RichTextContent("• Milk\n> quote")))
+ }
+
+ @Test
+ fun `hasFormatting is false for unstyled markerless text`() {
+ assertFalse(RichTextContent("just words\nmore words").hasFormatting())
+ assertTrue(RichTextContent("• bullet").hasFormatting())
+ assertTrue(RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.BOLD))).hasFormatting())
+ }
+
+ @Test
+ fun `fromHtml round-trips paragraphs styles lists quotes and links`() {
+ listOf(
+ RichTextContent("Hello\nWorld"),
+ RichTextContent("bold", spans = listOf(RichSpan(0, 4, RichStyle.BOLD))),
+ RichTextContent("• Milk\n• Eggs"),
+ RichTextContent("1. One\n2. Two"),
+ RichTextContent("> a\n> b"),
+ RichTextContent("see here", links = listOf(RichLink(4, 8, "http://example.com"))),
+ RichTextContent("a&c"),
+ ).forEach { original ->
+ val restored = RichTextHtml.fromHtml(RichTextHtml.toHtml(original))
+ assertEquals(original.text, restored.text, "text: $original")
+ assertEquals(
+ original.spans.sortedBy(RichSpan::start),
+ restored.spans.sortedBy(RichSpan::start),
+ "spans: $original",
+ )
+ assertEquals(
+ original.links.sortedBy(RichLink::start),
+ restored.links.sortedBy(RichLink::start),
+ "links: $original",
+ )
+ }
+ }
+
+ @Test
+ fun `fromHtml tolerates strong em and pretty-printed whitespace`() {
+ val restored = RichTextHtml.fromHtml("")
+ assertEquals("• One\n• Two", restored.text)
+ // "Two" occupies [8,11) of "• One\n• Two".
+ assertEquals(listOf(RichSpan(8, 11, RichStyle.BOLD)), restored.spans)
+ }
+
+ @Test
+ fun `empty content produces empty html`() {
+ assertEquals("", RichTextHtml.toHtml(RichTextContent("")))
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt
index a2bda85..c48c499 100644
--- a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt
+++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt
@@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
+import io.mockk.slot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -16,16 +17,21 @@ import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.settings.AccountSettingsRepository
+import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AccountSettings
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.Draft
import org.libremail.domain.model.MailSecurity
+import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.ServerConfig
+import org.libremail.domain.model.Signature
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class ComposeViewModelTest {
@@ -48,10 +54,14 @@ class ComposeViewModelTest {
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
)
+ private fun signature(accountId: String, html: String) =
+ Signature(id = "$accountId:sig", accountId = accountId, name = "Signature", html = html, isDefault = true)
+
private fun viewModel(
accounts: List = listOf(alice),
savedState: SavedStateHandle = SavedStateHandle(),
- signatures: Map = emptyMap(),
+ signatures: Map = emptyMap(),
+ settings: Map = emptyMap(),
mailRepository: MailRepository = mockk(relaxed = true),
): ComposeViewModel {
val accountRepository = mockk()
@@ -59,22 +69,36 @@ class ComposeViewModelTest {
val accountSettingsRepository = mockk()
coEvery { accountSettingsRepository.get(any()) } answers {
val id = firstArg()
- signatures[id] ?: AccountSettings(id)
+ settings[id] ?: AccountSettings(id)
}
+ val signatureRepository = mockk()
+ coEvery { signatureRepository.getDefault(any()) } answers { signatures[firstArg()] }
return ComposeViewModel(
savedStateHandle = savedState,
mailRepository = mailRepository,
accountRepository = accountRepository,
contactsRepository = mockk(relaxed = true),
accountSettingsRepository = accountSettingsRepository,
+ signatureRepository = signatureRepository,
)
}
@Test
- fun `appends the sending account signature to a new message`() = runTest(testDispatcher) {
- val vm = viewModel(signatures = mapOf("imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice")))
+ fun `appends the sending account default signature to a new message`() = runTest(testDispatcher) {
+ val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")))
assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body)
+ // A plain signature carries no formatting, so the message stays plaintext-only.
+ assertNull(vm.state.value.bodyHtml)
+ }
+
+ @Test
+ fun `a rich signature makes the new message carry an HTML body`() = runTest(testDispatcher) {
+ val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")))
+
+ assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body)
+ val html = vm.state.value.bodyHtml
+ assertTrue(html != null && html.contains("Alice"), "html=$html")
}
@Test
@@ -82,8 +106,8 @@ class ComposeViewModelTest {
val vm = viewModel(
accounts = listOf(alice, bob),
signatures = mapOf(
- "imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice"),
- "imap:b" to AccountSettings("imap:b", signature = "Best, Bob"),
+ "imap:a" to signature("imap:a", "Cheers, Alice"),
+ "imap:b" to signature("imap:b", "Best, Bob"),
),
)
assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body)
@@ -93,6 +117,16 @@ class ComposeViewModelTest {
assertEquals("\n\n-- \nBest, Bob", vm.state.value.body)
}
+ @Test
+ fun `does not append a signature when the account disabled it`() = runTest(testDispatcher) {
+ val vm = viewModel(
+ signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")),
+ settings = mapOf("imap:a" to AccountSettings("imap:a", signatureEnabled = false)),
+ )
+
+ assertEquals("", vm.state.value.body)
+ }
+
@Test
fun `does not append a signature when resuming a draft`() = runTest(testDispatcher) {
val mailRepository = mockk(relaxed = true)
@@ -104,14 +138,32 @@ class ComposeViewModelTest {
subject = "Hi",
body = "Draft body",
updatedAt = 0L,
+ bodyHtml = "Draft body
",
attachments = emptyList(),
)
val vm = viewModel(
savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")),
- signatures = mapOf("imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice")),
+ signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")),
mailRepository = mailRepository,
)
assertEquals("Draft body", vm.state.value.body)
+ // The draft's HTML body is restored so it round-trips back out on send.
+ assertEquals("Draft body
", vm.state.value.bodyHtml)
+ }
+
+ @Test
+ fun `send carries the HTML body through to the outgoing message`() = runTest(testDispatcher) {
+ val mailRepository = mockk(relaxed = true)
+ val sent = slot()
+ coEvery { mailRepository.sendMessage(capture(sent)) } returns Result.success(Unit)
+ val vm = viewModel(mailRepository = mailRepository)
+
+ vm.onToChange("bob@example.org")
+ vm.onBodyChange("Hello", "Hello world
")
+ vm.send()
+
+ assertEquals("Hello world
", sent.captured.bodyHtml)
+ assertEquals("Hello", sent.captured.body)
}
}
From b643c3bb617335d3f7b6225c5b7cb6aabf8d552f Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Wed, 1 Jul 2026 00:38:27 -0500
Subject: [PATCH 07/11] docs: refresh README for
onboarding/app-password/rich-compose/opt-in features
Bring README in line with the post-batch shipped state (issue #20, folding in
#31's README reconciliation):
- Rewrite the status blurb and feature list to cover the onboarding flow, rich
compose (HTML + multipart/alternative + signatures), full-history backfill
with a device-only retention cap, opt-in app lock, mailto/default-app, and
opt-in local debug reporting.
- Remove the Gmail OAuth setup section and the "no stored passwords for Gmail"
claim; Gmail/Yahoo/iCloud are now app-password IMAP/SMTP vendors.
- Add an "Accounts and onboarding" section (Outlook OAuth; Gmail/Yahoo/iCloud
app password with vendor app-password pages + Gmail 2SV note; Other IMAP/SMTP)
and keep the Outlook OAuth setup section.
- Add a "Privacy and data flow" note: opt-in cache encryption, local
user-initiated debug reporting (no hosted pipeline), and opt-in Android Backup.
Co-Authored-By: Claude Opus 4.8
---
README.md | 108 ++++++++++++++++++++++++++++++++++--------------------
1 file changed, 68 insertions(+), 40 deletions(-)
diff --git a/README.md b/README.md
index 6cc4ab4..125c37d 100644
--- a/README.md
+++ b/README.md
@@ -5,28 +5,37 @@ A free and open-source email client for Android, built with Kotlin, Jetpack
Compose and Material 3 (Material You). LibreMail aims for a friendly default
experience with power-user features tucked under an **Advanced Settings** group.
-> Status: **in development.** Material You shell; **account setup** (Gmail OAuth via
-> AppAuth/PKCE and generic IMAP/SMTP, with a live connection test and Keystore-
-> encrypted credentials); **IMAP receive** — background sync (WorkManager) into a local
-> Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and
-> rendered in a hardened WebView (JavaScript off, remote images blocked by default),
-> with mark-read, star, and delete; and **composing** — a compose screen with device-
-> contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued
-> and retried, with a viewable outbox folder), plus reply and **drafts** saved for
-> later; **on-device new-mail
-> notifications** (no push service) with persisted settings; **instant push** via a
-> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a
-> system viewer, and attach files when composing; **multiple accounts** — a unified inbox
-> with per-account filtering; and
-> **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft**
-> accounts (OAuth 2.0 sign-in, IMAP receive + Microsoft Graph send, SMTP/XOAUTH2 fallback).
+> Status: **in development.** Material You shell; **onboarding** — a first-run flow from a
+> welcome screen through a vendor picker (Outlook/Hotmail, Gmail, Yahoo, iCloud, or Other) and
+> per-vendor setup to your first account's inbox; **account setup** — Outlook/Microsoft via
+> OAuth 2.0 (AppAuth/PKCE), Gmail/Yahoo/iCloud via app password, and generic IMAP/SMTP, all
+> with a live connection test and Keystore-encrypted credentials; **IMAP receive** — background
+> sync (WorkManager) into a local Room cache with pull-to-refresh, backfilling your **entire**
+> mail history (resumable) with an optional device-only retention cap; **reading** — message
+> bodies fetched on open and rendered in a hardened WebView (JavaScript off, remote images
+> blocked by default), with mark-read, star, and delete; **composing** — a rich-text HTML
+> editor with a formatting toolbar and per-account signatures that sends
+> `multipart/alternative` (HTML with a plaintext fallback) through a reliable background
+> **outbox** (WorkManager-queued and retried, with a viewable outbox folder), plus
+> device-contacts autocomplete, reply, and **drafts**; **on-device new-mail notifications** (no
+> push service) with persisted settings; **instant push** via a foreground **IMAP IDLE**
+> service; **attachments** — downloaded on demand and opened in a system viewer, and attach
+> files when composing; **multiple accounts** — a unified inbox with per-account filtering;
+> **search** across cached mail and the server (IMAP SEARCH); Outlook/Microsoft send via
+> Microsoft **Graph** with an SMTP/XOAUTH2 fallback; an opt-in **app lock**
+> (biometric/device-credential) that binds the encrypted cache key to your unlock; **mailto:**
+> link handling with optional default-mail-app registration; and opt-in, F-Droid-safe **debug
+> reporting** — local crash/error capture that you review (with a PII disclaimer) and submit
+> only on an explicit action.
## Features (target MVP)
-- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider.
+- Send and receive email with **Outlook/Microsoft** (OAuth 2.0), **Gmail, Yahoo and iCloud** (app password), and **any other IMAP/SMTP** provider.
+- Guided first-run onboarding: welcome → vendor picker → per-vendor setup → your inbox.
- Material You dynamic theming, light/dark, edge-to-edge.
-- Clean compose screen with phone/account contacts integration.
-- Modern security: OAuth 2.0 Authorization Code + PKCE, no stored passwords for Gmail.
+- Rich-text compose with a formatting toolbar, per-account signatures, and phone/account contacts integration.
+- Offline-first: a local Room cache with full-history backfill and an optional device-only retention limit.
+- Modern, opt-in security: OAuth 2.0 (Authorization Code + PKCE) for Outlook, Keystore-encrypted credentials, optional SQLCipher cache encryption, and a biometric/device-credential app lock.
## Tech stack
@@ -67,27 +76,27 @@ sdkmanager "platforms;android-37.0" "build-tools;37.0.0"
`local.properties` (git-ignored) must point `sdk.dir` at your Android SDK; Android
Studio creates it automatically.
-## Gmail account setup (OAuth client)
+## Accounts and onboarding
-Gmail IMAP/SMTP requires the restricted `https://mail.google.com/` scope. While the
-app is unpublished you can use it in **Testing** mode with up to 100 test users and
-no security assessment; a public Play Store release later requires a Google CASA
-assessment for the restricted scope.
+On first launch LibreMail runs a short onboarding flow: a welcome screen, a **vendor picker**
+(Outlook/Hotmail, Gmail, Yahoo, iCloud, or **Other**), per-vendor setup, and an "add another
+account?" prompt before it drops you on your first account's inbox. You can add more accounts
+later from settings; a unified inbox merges them with per-account filtering.
-1. In the [Google Cloud Console](https://console.cloud.google.com/), create a
- project (e.g. *LibreMail*).
-2. **APIs & Services → Library →** enable the **Gmail API**.
-3. **OAuth consent screen:** user type *External*; add the scope
- `https://mail.google.com/`; under **Test users**, add your Google address.
- Leave the app in **Testing**.
-4. **Credentials → Create credentials → OAuth client ID → Android.** Use package
- name `org.libremail.app` and your debug keystore SHA-1:
- ```bash
- keytool -list -v -keystore "$HOME/.android/debug.keystore" \
- -alias androiddebugkey -storepass android -keypass android
- ```
-5. Copy `secrets.properties.example` to `secrets.properties` (git-ignored) and set
- `GMAIL_OAUTH_CLIENT_ID` to your client ID. The build injects it via `BuildConfig`.
+LibreMail supports three kinds of account:
+
+- **Outlook / Hotmail** — signs in with **OAuth 2.0** through Microsoft (AppAuth); no password
+ is stored. See [Outlook / Microsoft account setup](#outlook--microsoft-account-setup-oauth-client)
+ below.
+- **Gmail, Yahoo and iCloud** — preconfigured IMAP/SMTP that authenticate with a provider
+ **app password** (not your normal account password), preferring STARTTLS where the provider
+ supports it. Onboarding links you to each vendor's app-password page. **Gmail requires
+ 2-Step Verification to be enabled** before Google will issue an app password.
+- **Other** — a manual IMAP/SMTP form (host, port, security, and credentials) for any other
+ provider.
+
+App passwords and OAuth tokens are held in a credential store encrypted with the Android
+Keystore, and every account runs a live connection test before it is saved.
## Outlook / Microsoft account setup (OAuth client)
@@ -108,6 +117,25 @@ token. A working client ID ships with the build; to use your own Azure app regis
4. Copy the **Application (client) ID** into `secrets.properties` as
`OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default).
+## Privacy and data flow
+
+LibreMail is offline-first: your mail lives in a local cache, and by default network traffic
+goes only to your mail providers (IMAP/SMTP, plus Microsoft's OAuth and Graph endpoints for
+Outlook). There is no analytics SDK and no always-on telemetry. The privacy-sensitive extras
+are all **opt-in**:
+
+- **Cache encryption** — the Room cache can be encrypted at rest with **SQLCipher**. With the
+ optional **app lock** (biometric or device credential) enabled, the cache key is bound to
+ your authentication, so the database is only decrypted after you unlock the app.
+- **Debug reporting** — **off by default.** When enabled, crashes and errors are captured
+ **locally**; you review the full report — shown with a plain-language **PII disclaimer** —
+ and it is sent only when you explicitly submit it, to a configurable (optional) endpoint.
+ There is no hosted crash pipeline collecting reports in the background.
+- **Android Backup** — **off by default.** When you turn it on, only safe app settings are
+ backed up; the encrypted-database key, account credentials, and the mail cache are
+ **excluded**. Because Android's backup transport can route data through Google, it stays
+ disabled unless you opt in — the kind of optional behavior F-Droid lists as an anti-feature.
+
## Architecture
Offline-first, unidirectional, layered:
@@ -119,9 +147,9 @@ data/ Room (entities, DAOs, database) + repository implementation (source o
di/ Hilt modules
```
-The UI observes Room via `Flow`; later increments add a sync engine (Angus Mail
-over IMAP/SMTP) that writes into Room, and an auth layer (AppAuth + an Android
-Keystore-backed credential store).
+The UI observes Room via `Flow`; a sync engine (Angus Mail over IMAP/SMTP, plus Microsoft
+Graph for Outlook send) writes into Room, and an auth layer (AppAuth for OAuth and an Android
+Keystore-backed credential store for app passwords) handles sign-in.
## License
From 3ea166607c0f9b902ca1021e4bcd9c45d4b4758d Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Wed, 1 Jul 2026 08:41:01 -0500
Subject: [PATCH 08/11] test(reporting): compile instrumented SettingsScreen
test with onReportProblem
The #32/#33 change added a required onReportProblem parameter to SettingsScreen
but left the existing instrumented SettingsScreenTest calling the old signature,
so :app:compileDebugAndroidTestKotlin failed in every E2E job (the local fast
gate never compiles the androidTest variant). Pass onReportProblem = {} in the
test. Verified with :app:compileDebugAndroidTestKotlin on JDK 21.
Co-Authored-By: Claude Opus 4.8
---
.../org/libremail/ui/settings/SettingsScreenTest.kt | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt
index c3b6061..e3cdef4 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt
@@ -39,7 +39,13 @@ class SettingsScreenTest {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
- SettingsScreen(onAddAccount = {}, onOpenAccount = {}, onSelectTab = {}, viewModel = viewModel)
+ SettingsScreen(
+ onAddAccount = {},
+ onOpenAccount = {},
+ onSelectTab = {},
+ onReportProblem = {},
+ viewModel = viewModel,
+ )
}
}
From 876539b514977dd52e0d7cb057211ab745de8438 Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Wed, 1 Jul 2026 08:41:05 -0500
Subject: [PATCH 09/11] test(onboarding): widen onboarding E2E waitForText
timeout to 15s
OnboardingFlowTest passed on the API-37 job but timed out (ComposeTimeoutException
after 5000ms) across the animation-disabled API 29-36 matrix, at the single
async-gated transition: click -> viewModelScope coroutine -> addImapAccount ->
DONE -> LaunchedEffect -> navigate -> AddAnother render. The flow is correct
(green on API 37; ManualSetupScreenTest proves the add-callback path); the 5s cap
was just too tight for that compound step on slower matrix emulators. waitUntil
returns as soon as the text appears, so the happy path is unaffected.
Co-Authored-By: Claude Opus 4.8
---
.../org/libremail/ui/onboarding/OnboardingFlowTest.kt | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
index 30299ad..56dc92b 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
@@ -52,7 +52,11 @@ class OnboardingFlowTest {
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
- private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
+ // 15s (not the 5s used elsewhere): the add-another step is reached only after an async chain
+ // (button → viewModelScope coroutine → addImapAccount → DONE → LaunchedEffect → navigate), which
+ // on the slower, animation-disabled E2E matrix emulators can exceed a 5s budget for that one
+ // transition. waitUntil returns as soon as the text appears, so this only raises the cap.
+ private fun waitForText(text: String) = composeTestRule.waitUntil(15_000) {
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
From 25e1a8b83ca24c7cbcbe5c5d1955e60a50b85c1b Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Wed, 1 Jul 2026 09:27:29 -0500
Subject: [PATCH 10/11] test(onboarding): scroll app-password fields/button
into view before tapping
Real cause of the API 29-36 E2E timeout (the earlier 5s->15s bump didn't help,
proving it wasn't slowness): AppPasswordSetupScreen is a scrolling Column and the
"Test and add" button sits below the fold on the short default matrix emulator, so
the positional performClick was a silent no-op -> no add -> no navigation -> the
add-another wait never resolved. It passed on API 37 only because that job uses a
taller pixel_2 AVD. performScrollTo() each field + the button before interacting,
matching the existing pattern in SettingsScreenTest. Verified compileDebugAndroid
TestKotlin + ktlintCheck on JDK 21.
Co-Authored-By: Claude Opus 4.8
---
.../ui/onboarding/OnboardingFlowTest.kt | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
index 56dc92b..625a328 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
@@ -9,6 +9,7 @@ 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
@@ -52,10 +53,8 @@ class OnboardingFlowTest {
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
- // 15s (not the 5s used elsewhere): the add-another step is reached only after an async chain
- // (button → viewModelScope coroutine → addImapAccount → DONE → LaunchedEffect → navigate), which
- // on the slower, animation-disabled E2E matrix emulators can exceed a 5s budget for that one
- // transition. waitUntil returns as soon as the text appears, so this only raises the cap.
+ // 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()
}
@@ -194,10 +193,16 @@ class OnboardingFlowTest {
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)).performTextInput("e2e@gmail.com")
- composeTestRule.onNodeWithText(string(R.string.app_password_field)).performTextInput("app-pass")
- composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).performClick()
+ 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))
From 59c9f9d27e8fba74c86bf95aa506b2acfa391946 Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Wed, 1 Jul 2026 12:31:54 -0500
Subject: [PATCH 11/11] feat(onboarding): opt-in to unrestricted
battery/background usage
Add a guided, F-Droid-safe onboarding step and an Advanced Settings recovery
row that let users move LibreMail to "Unrestricted" battery usage, so IMAP
IDLE push (IdleService) and periodic WorkManager sync aren't throttled or
killed by Doze. Deep-links to the system app-details screen rather than the
restricted REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog, so it needs no new
permission and is safe on Play (#17) and F-Droid (#16).
- BatteryPromptDecision: pure, unit-tested gate (supported && !unrestricted && !handled)
- BatteryOptimizationManager: reads isIgnoringBatteryOptimizations, builds the deep-link intent
- Onboarding step shown after the first account is added; skipped when already
unrestricted or already handled; re-checks status on resume
- Advanced Settings row shows current status and re-opens the system screen
- battery_prompt_handled flag persisted in the settings DataStore (kept out of AppSettings)
- Unit tests for the decision + view model; Espresso E2E for the step
Closes #49
Co-Authored-By: Claude Opus 4.8
---
app/build.gradle.kts | 1 +
.../onboarding/BatteryOptimizationStepTest.kt | 186 ++++++++++++++++++
.../ui/onboarding/OnboardingFlowTest.kt | 11 +-
.../ui/settings/SettingsScreenTest.kt | 7 +-
.../data/settings/SettingsRepository.kt | 13 ++
.../push/BatteryOptimizationManager.kt | 53 +++++
.../libremail/push/BatteryPromptDecision.kt | 22 +++
.../kotlin/org/libremail/ui/LibreMailApp.kt | 68 +++++--
.../org/libremail/ui/navigation/Routes.kt | 6 +
.../onboarding/BatteryOptimizationScreen.kt | 123 ++++++++++++
.../ui/onboarding/OnboardingViewModel.kt | 59 +++++-
.../libremail/ui/settings/SettingsScreen.kt | 19 ++
.../ui/settings/SettingsViewModel.kt | 16 ++
app/src/main/res/values/strings.xml | 13 ++
.../push/BatteryPromptDecisionTest.kt | 50 +++++
.../ui/onboarding/OnboardingViewModelTest.kt | 106 ++++++++++
gradle/libs.versions.toml | 1 +
17 files changed, 730 insertions(+), 24 deletions(-)
create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt
create mode 100644 app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt
create mode 100644 app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt
create mode 100644 app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt
create mode 100644 app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6f3b4bd..1440adb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -197,6 +197,7 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.androidx.espresso.intents)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt
new file mode 100644
index 0000000..a32112a
--- /dev/null
+++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt
@@ -0,0 +1,186 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.onboarding
+
+import android.app.Activity
+import android.app.Instrumentation
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.ComponentActivity
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.runBlocking
+import org.hamcrest.CoreMatchers.allOf
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.libremail.R
+import org.libremail.data.settings.SettingsRepository
+import org.libremail.push.BatteryOptimizationManager
+import org.libremail.ui.navigation.Routes
+import org.libremail.ui.theme.LibreMailTheme
+
+/**
+ * End-to-end test for the onboarding battery opt-in step (#49). It drives the real
+ * [BatteryOptimizationScreen] + graph-scoped [OnboardingViewModel] through a NavHost that mirrors the
+ * production "add another? → (optional) battery → inbox" tail (see
+ * `LibreMailApp.onboardingFinishDestinations`).
+ *
+ * Battery status comes from the real [BatteryOptimizationManager]: a fresh emulator is never on the
+ * battery allowlist, so the step is offered. The "already unrestricted" skip can't be forced from a
+ * test (there's no API to set it) and is covered by the view-model unit tests; the "already handled"
+ * skip is exercised here through the real settings DataStore.
+ */
+@RunWith(AndroidJUnit4::class)
+class BatteryOptimizationStepTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private lateinit var settingsRepository: SettingsRepository
+ private lateinit var onboarding: OnboardingViewModel
+
+ private fun string(resId: Int) = composeTestRule.activity.getString(resId)
+
+ private fun waitForText(text: String) = composeTestRule.waitUntil(10_000) {
+ composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
+ }
+
+ /**
+ * Renders the "add another? → battery → inbox" tail with one account already added this session,
+ * starting on the add-another prompt. [handled] seeds the persisted "prompt handled" flag so the
+ * skip path can be exercised through the real repository.
+ */
+ private fun setContent(handled: Boolean) {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
+ settingsRepository = SettingsRepository(context)
+ runBlocking { settingsRepository.setBatteryPromptHandled(handled) }
+ onboarding = OnboardingViewModel(BatteryOptimizationManager(context), settingsRepository)
+ onboarding.onAccountAdded(FIRST_ACCOUNT_ID)
+
+ composeTestRule.setContent {
+ LibreMailTheme(darkTheme = false, dynamicColor = false) {
+ val navController = rememberNavController()
+ NavHost(navController = navController, startDestination = Routes.ONBOARDING_ADD_ANOTHER) {
+ composable(Routes.ONBOARDING_ADD_ANOTHER) {
+ val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
+ AddAnotherAccountScreen(
+ onAddAnother = {},
+ onFinish = {
+ if (batteryPromptNeeded == true) {
+ navController.navigate(Routes.ONBOARDING_BATTERY)
+ } else {
+ navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
+ popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
+ }
+ }
+ },
+ )
+ }
+ composable(Routes.ONBOARDING_BATTERY) {
+ BatteryOptimizationScreen(
+ viewModel = onboarding,
+ onFinish = {
+ onboarding.markBatteryPromptHandled()
+ navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
+ popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
+ }
+ },
+ )
+ }
+ composable(
+ route = Routes.MAILBOX_PATTERN,
+ arguments = listOf(
+ navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
+ type = NavType.StringType
+ defaultValue = ""
+ },
+ ),
+ ) {
+ Text(INBOX_MARKER)
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun batteryStep_isOffered_thenNotNow_landsOnInbox() {
+ setContent(handled = false)
+ // The decision resolves asynchronously (a DataStore read); wait before driving the finish tap.
+ composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
+
+ composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
+
+ // The battery opt-in step is shown...
+ waitForText(string(R.string.onboarding_battery_title))
+ composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed()
+
+ // ...and "Not now" continues to the inbox and records the prompt as handled (so it won't nag).
+ composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick()
+ waitForText(INBOX_MARKER)
+ composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
+ composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } }
+ }
+
+ @Test
+ fun batteryStep_takeMeThere_opensThisAppsSystemSettings() {
+ setContent(handled = false)
+ composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
+ composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
+ waitForText(string(R.string.onboarding_battery_title))
+
+ val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName
+ Intents.init()
+ try {
+ // Stub the match so the real system settings screen never actually launches mid-test.
+ Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS))
+ .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
+
+ composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick()
+
+ // Deep-links to *this app's* details screen (where Battery → Unrestricted lives).
+ Intents.intended(
+ allOf(
+ hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS),
+ hasData(Uri.fromParts("package", packageName, null)),
+ ),
+ )
+ } finally {
+ Intents.release()
+ }
+ }
+
+ @Test
+ fun finish_skipsBatteryStep_whenAlreadyHandled() {
+ setContent(handled = true)
+ composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == false }
+
+ composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
+
+ // Straight to the inbox — the opt-in step is skipped entirely.
+ waitForText(INBOX_MARKER)
+ composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
+ composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertDoesNotExist()
+ }
+
+ private companion object {
+ const val INBOX_MARKER = "INBOX-REACHED"
+ const val FIRST_ACCOUNT_ID = "imap:e2e@example.com"
+ }
+}
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
index 625a328..b35a7bf 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
@@ -23,7 +23,9 @@ 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
@@ -77,7 +79,14 @@ class OnboardingFlowTest {
)
private fun setOnboardingContent(accountRepo: FakeAccountRepository, mailRepo: FakeMailRepository) {
- val onboarding = OnboardingViewModel()
+ // 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()
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt
index e3cdef4..4bdf2ce 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt
@@ -15,6 +15,7 @@ import org.junit.runner.RunWith
import org.libremail.R
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
@@ -35,7 +36,11 @@ class SettingsScreenTest {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val settingsRepository = SettingsRepository(context)
runBlocking { settingsRepository.setFetchPolicy(FetchPolicy.ALWAYS) } // known starting state
- val viewModel = SettingsViewModel(FakeAccountRepository(), settingsRepository)
+ val viewModel = SettingsViewModel(
+ FakeAccountRepository(),
+ settingsRepository,
+ BatteryOptimizationManager(context),
+ )
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
index 553ae00..2d5f0d6 100644
--- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
+++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
@@ -55,6 +55,7 @@ private object Keys {
val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache")
val INCLUDE_IN_BACKUP = booleanPreferencesKey("include_in_backup")
val FETCH_POLICY = stringPreferencesKey("fetch_policy")
+ val BATTERY_PROMPT_HANDLED = booleanPreferencesKey("battery_prompt_handled")
}
/**
@@ -83,6 +84,18 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
suspend fun fetchPolicy(): FetchPolicy = settings.first().fetchPolicy
+ /**
+ * 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)
diff --git a/app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt b/app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt
new file mode 100644
index 0000000..a694b7c
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.push
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.PowerManager
+import android.provider.Settings
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Reads and deep-links this app's system battery-optimization state, so the user can move LibreMail
+ * to "Unrestricted" and keep [IdleService]'s push connection (and periodic sync) from being throttled
+ * or torn down by Doze.
+ *
+ * We deliberately do NOT use the restricted `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission or its
+ * one-tap dialog: Google Play limits that permission to an approved set of use cases (rejection risk,
+ * see #17). Sending the user to the system screen instead needs no extra permission and is safe on
+ * both Play and F-Droid (#16).
+ *
+ * Note [isIgnoringBatteryOptimizations] reflects the Doze allowlist: it is `true` only for the
+ * "Unrestricted" setting and `false` for *both* "Optimized" and "Restricted", so it cannot single out
+ * the (most harmful) "Restricted" state on its own.
+ */
+@Singleton
+class BatteryOptimizationManager @Inject constructor(@ApplicationContext private val context: Context) {
+ /**
+ * Whether the platform exposes battery-optimization control. The Doze allowlist has existed since
+ * API 23, so at our minSdk (29) this is always true; the version check keeps [BatteryPromptDecision]
+ * honest if the floor ever drops below API 23.
+ */
+ val isSupported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+
+ /** True when this app is currently exempt from battery optimization ("Unrestricted"). */
+ fun isIgnoringBatteryOptimizations(): Boolean {
+ val powerManager = context.getSystemService(PowerManager::class.java) ?: return false
+ return powerManager.isIgnoringBatteryOptimizations(context.packageName)
+ }
+
+ /**
+ * Intent to this app's system details screen, where the user can open **Battery** and choose
+ * **Unrestricted**. App-details is targeted (rather than the flat battery-optimization list)
+ * because it is the only route to the Unrestricted/Optimized/Restricted setting and lands
+ * directly on LibreMail. Always resolvable since API 9.
+ */
+ fun settingsIntent(): Intent = Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", context.packageName, null),
+ )
+}
diff --git a/app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt b/app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt
new file mode 100644
index 0000000..7d3ab3d
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.push
+
+/**
+ * Pure decision for whether onboarding should surface the battery opt-in step. Deliberately free of
+ * Android types so it is exhaustively unit-testable; the live inputs are read by
+ * [BatteryOptimizationManager] and [org.libremail.data.settings.SettingsRepository].
+ */
+object BatteryPromptDecision {
+
+ /**
+ * Prompt only when all three hold:
+ * - [supported]: the platform exposes battery-optimization control (the Doze allowlist is API 23+,
+ * so this is always true at our minSdk — kept explicit so the rule stays correct if the floor
+ * ever drops).
+ * - not [alreadyUnrestricted]: an app already exempt from battery optimization gains nothing.
+ * - not [alreadyHandled]: the user has already seen and acted on (or dismissed) the prompt, so we
+ * don't nag on a later onboarding run.
+ */
+ fun shouldPrompt(supported: Boolean, alreadyUnrestricted: Boolean, alreadyHandled: Boolean): Boolean =
+ supported && !alreadyUnrestricted && !alreadyHandled
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
index b3ae46a..6d44535 100644
--- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
+++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
@@ -36,6 +36,7 @@ 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
@@ -310,25 +311,54 @@ private fun NavGraphBuilder.onboardingGraph(navController: NavHostController) {
onAccountAdded = { id -> onboarding.completeAdd(navController, id) },
)
}
- composable(Routes.ONBOARDING_ADD_ANOTHER) { entry ->
- val onboarding = onboardingViewModel(navController, entry)
- 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 = {
- val firstId = onboarding.firstAddedAccountId
- val dest = if (firstId != null) Routes.mailboxForAccount(firstId) else Routes.MAILBOX
- navController.navigate(dest) {
- // Leave onboarding entirely; the mailbox becomes the new back-stack root.
- popUpTo(Routes.ONBOARDING) { inclusive = true }
- }
- },
- )
- }
+ 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 }
}
}
diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt
index 1a855c1..169d8a2 100644
--- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt
+++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt
@@ -35,6 +35,12 @@ object Routes {
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)}"
diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt
new file mode 100644
index 0000000..0314c5a
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt
@@ -0,0 +1,123 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.onboarding
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.libremail.R
+
+/**
+ * Final onboarding step (shown only when needed, see [OnboardingViewModel.batteryPromptNeeded]):
+ * invites the user to allow unrestricted background/battery usage so push and periodic sync aren't
+ * throttled by Doze. **Take me there** deep-links to the system screen (no restricted permission);
+ * **Not now** skips. Either way [onFinish] proceeds to the inbox. On returning from Settings the
+ * status is re-read and, if the app is now unrestricted, the screen reflects that with a "done" state.
+ *
+ * @param viewModel the graph-scoped onboarding view model (holds live battery status + the flag).
+ * @param onFinish leaves onboarding for the inbox; the caller also marks the prompt handled.
+ */
+@Composable
+fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Unit) {
+ val unrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ // Re-check on every resume so returning from the system settings screen reflects the new state.
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() }
+
+ Scaffold { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = if (unrestricted) Icons.Filled.CheckCircle else Icons.Filled.Notifications,
+ contentDescription = null,
+ modifier = Modifier.size(72.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.height(24.dp))
+ Text(
+ text = stringResource(
+ if (unrestricted) R.string.onboarding_battery_done_title else R.string.onboarding_battery_title,
+ ),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(
+ if (unrestricted) R.string.onboarding_battery_done_body else R.string.onboarding_battery_body,
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(32.dp))
+
+ if (unrestricted) {
+ Button(
+ onClick = onFinish,
+ modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
+ ) {
+ Text(stringResource(R.string.onboarding_battery_continue))
+ }
+ } else {
+ Text(
+ text = stringResource(R.string.onboarding_battery_guidance),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(24.dp))
+ Button(
+ onClick = {
+ // Mark handled up front: the user is leaving for Settings and might not return
+ // to this screen. Launching app-details always resolves; guard defensively.
+ viewModel.markBatteryPromptHandled()
+ runCatching { context.startActivity(viewModel.batterySettingsIntent()) }
+ },
+ modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
+ ) {
+ Text(stringResource(R.string.onboarding_battery_take_me))
+ }
+ Spacer(Modifier.height(12.dp))
+ OutlinedButton(
+ onClick = onFinish,
+ modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
+ ) {
+ Text(stringResource(R.string.onboarding_battery_not_now))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt
index f9d5659..bc8706b 100644
--- a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt
@@ -1,28 +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.
*
- * Its only job is to remember the **first** account added during this session: when the user
- * finishes ("No, don't add another"), onboarding opens that account's inbox (see #30).
+ * 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() : ViewModel() {
+class OnboardingViewModel @Inject constructor(
+ private val batteryOptimizationManager: BatteryOptimizationManager,
+ private val settingsRepository: SettingsRepository,
+) : ViewModel() {
/** The id of the first account added this session, or null if none has been added yet. */
var firstAddedAccountId: String? = null
private set
+ private val _batteryPromptNeeded = MutableStateFlow(null)
+
+ /**
+ * Whether onboarding should show the battery opt-in step before finishing. `null` until decided;
+ * the finish path treats `null` as "skip", so a slow read can never block the end of onboarding.
+ * Decided once at graph start — neither input can change until the user reaches the step itself.
+ */
+ val batteryPromptNeeded: StateFlow = _batteryPromptNeeded.asStateFlow()
+
+ private val _batteryUnrestricted = MutableStateFlow(false)
+
+ /** Live "Unrestricted" status, re-read when the opt-in step resumes (e.g. back from Settings). */
+ val batteryUnrestricted: StateFlow = _batteryUnrestricted.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ val unrestricted = batteryOptimizationManager.isIgnoringBatteryOptimizations()
+ _batteryUnrestricted.value = unrestricted
+ _batteryPromptNeeded.value = BatteryPromptDecision.shouldPrompt(
+ supported = batteryOptimizationManager.isSupported,
+ alreadyUnrestricted = unrestricted,
+ alreadyHandled = settingsRepository.isBatteryPromptHandled(),
+ )
+ }
+ }
+
/** Records a freshly added account. Only the first one sticks — later adds don't overwrite it. */
fun onAccountAdded(accountId: String) {
if (firstAddedAccountId == null) {
firstAddedAccountId = accountId
}
}
+
+ /** Intent to the system screen where the user flips this app to "Unrestricted". */
+ fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent()
+
+ /** Re-read the live battery status; call when the opt-in step resumes. */
+ fun refreshBatteryStatus() {
+ viewModelScope.launch {
+ _batteryUnrestricted.value = batteryOptimizationManager.isIgnoringBatteryOptimizations()
+ }
+ }
+
+ /** Record that the user has seen/acted on the battery opt-in so onboarding won't ask again. */
+ fun markBatteryPromptHandled() {
+ viewModelScope.launch { settingsRepository.setBatteryPromptHandled(true) }
+ }
}
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
index c015376..4ea2d77 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
@@ -27,9 +27,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
+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.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.data.settings.FetchPolicy
@@ -48,6 +51,11 @@ fun SettingsScreen(
val settings by viewModel.settings.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val advancedExpanded by viewModel.advancedExpanded.collectAsStateWithLifecycle()
+ val batteryUnrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ // 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)) }) },
@@ -139,6 +147,17 @@ fun SettingsScreen(
checked = settings.pushIdle,
onCheckedChange = viewModel::setPushIdle,
)
+ ClickRow(
+ title = stringResource(R.string.settings_adv_battery),
+ subtitle = stringResource(
+ if (batteryUnrestricted) {
+ R.string.settings_adv_battery_unrestricted
+ } else {
+ R.string.settings_adv_battery_optimized
+ },
+ ),
+ onClick = { runCatching { context.startActivity(viewModel.batterySettingsIntent()) } },
+ )
SwitchRow(
title = stringResource(R.string.settings_adv_starttls),
checked = settings.allowStartTls,
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
index 52da6d4..fcd9883 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
+import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -16,12 +17,14 @@ 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
class SettingsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val settingsRepository: SettingsRepository,
+ private val batteryOptimizationManager: BatteryOptimizationManager,
) : ViewModel() {
val accounts: StateFlow> = accountRepository.observeAccounts()
@@ -33,8 +36,21 @@ class SettingsViewModel @Inject constructor(
private val _advancedExpanded = MutableStateFlow(false)
val advancedExpanded: StateFlow = _advancedExpanded.asStateFlow()
+ private val _batteryUnrestricted = MutableStateFlow(batteryOptimizationManager.isIgnoringBatteryOptimizations())
+
+ /** Whether this app is exempt from battery optimization ("Unrestricted"). */
+ val batteryUnrestricted: StateFlow = _batteryUnrestricted.asStateFlow()
+
fun toggleAdvanced() = _advancedExpanded.update { !it }
+ /** Re-read the battery-optimization status; call when the screen resumes (e.g. back from Settings). */
+ fun refreshBatteryStatus() {
+ _batteryUnrestricted.value = batteryOptimizationManager.isIgnoringBatteryOptimizations()
+ }
+
+ /** Intent to the system screen where the user flips this app to "Unrestricted". */
+ fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent()
+
fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) }
fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) }
fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) }
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1b239fb..da43daa 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -123,6 +123,16 @@
Add another account
No, take me to my inbox
+
+ Get mail the instant it arrives
+ LibreMail keeps a lightweight connection open to deliver mail the moment it lands. Android\'s battery optimization can pause or close that connection, so messages may show up late — or only when you open the app.
+ On the next screen, open Battery and choose “Unrestricted”.
+ Take me there
+ Not now
+ You\'re all set
+ Background usage is unrestricted — new mail will arrive instantly.
+ Continue to inbox
+
Outlook or Hotmail
Other (IMAP/SMTP)
@@ -225,6 +235,9 @@
Load remote images by default
Encrypt local cache
Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail.
+ Background battery usage
+ Unrestricted — instant background mail is allowed.
+ Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage.
Diagnostics
diff --git a/app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt b/app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt
new file mode 100644
index 0000000..d6dde8d
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.push
+
+import org.junit.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class BatteryPromptDecisionTest {
+
+ @Test
+ fun `prompts when supported, not already unrestricted, and not yet handled`() {
+ assertTrue(
+ BatteryPromptDecision.shouldPrompt(
+ supported = true,
+ alreadyUnrestricted = false,
+ alreadyHandled = false,
+ ),
+ )
+ }
+
+ @Test
+ fun `never prompts when the app is already unrestricted`() {
+ assertFalse(
+ BatteryPromptDecision.shouldPrompt(supported = true, alreadyUnrestricted = true, alreadyHandled = false),
+ )
+ }
+
+ @Test
+ fun `never prompts once the user has handled it`() {
+ assertFalse(
+ BatteryPromptDecision.shouldPrompt(supported = true, alreadyUnrestricted = false, alreadyHandled = true),
+ )
+ }
+
+ @Test
+ fun `never prompts on an unsupported platform, regardless of the other inputs`() {
+ for (unrestricted in listOf(false, true)) {
+ for (handled in listOf(false, true)) {
+ assertFalse(
+ BatteryPromptDecision.shouldPrompt(
+ supported = false,
+ alreadyUnrestricted = unrestricted,
+ alreadyHandled = handled,
+ ),
+ "supported=false must never prompt (unrestricted=$unrestricted, handled=$handled)",
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt
new file mode 100644
index 0000000..4000aa5
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.onboarding
+
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.libremail.data.settings.SettingsRepository
+import org.libremail.push.BatteryOptimizationManager
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class OnboardingViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Before
+ fun setUp() = Dispatchers.setMain(testDispatcher)
+
+ @After
+ fun tearDown() = Dispatchers.resetMain()
+
+ private fun batteryManager(supported: Boolean = true, unrestricted: Boolean = false) =
+ mockk {
+ every { isSupported } returns supported
+ every { isIgnoringBatteryOptimizations() } returns unrestricted
+ }
+
+ private fun settingsRepository(handled: Boolean = false) = mockk {
+ coEvery { isBatteryPromptHandled() } returns handled
+ }
+
+ @Test
+ fun `battery prompt is needed when not unrestricted and not handled`() = runTest(testDispatcher) {
+ val vm = OnboardingViewModel(batteryManager(unrestricted = false), settingsRepository(handled = false))
+
+ assertEquals(true, vm.batteryPromptNeeded.value)
+ assertFalse(vm.batteryUnrestricted.value)
+ }
+
+ @Test
+ fun `battery prompt is skipped when the app is already unrestricted`() = runTest(testDispatcher) {
+ val vm = OnboardingViewModel(batteryManager(unrestricted = true), settingsRepository(handled = false))
+
+ assertEquals(false, vm.batteryPromptNeeded.value)
+ assertTrue(vm.batteryUnrestricted.value)
+ }
+
+ @Test
+ fun `battery prompt is skipped once it has been handled`() = runTest(testDispatcher) {
+ val vm = OnboardingViewModel(batteryManager(unrestricted = false), settingsRepository(handled = true))
+
+ assertEquals(false, vm.batteryPromptNeeded.value)
+ }
+
+ @Test
+ fun `only the first added account id is remembered`() = runTest(testDispatcher) {
+ val vm = OnboardingViewModel(batteryManager(), settingsRepository())
+
+ assertNull(vm.firstAddedAccountId)
+ vm.onAccountAdded("imap:first@example.com")
+ vm.onAccountAdded("imap:second@example.com")
+
+ assertEquals("imap:first@example.com", vm.firstAddedAccountId)
+ }
+
+ @Test
+ fun `marking the prompt handled persists the flag`() = runTest(testDispatcher) {
+ val repo = settingsRepository()
+ coEvery { repo.setBatteryPromptHandled(any()) } just Runs
+ val vm = OnboardingViewModel(batteryManager(), repo)
+
+ vm.markBatteryPromptHandled()
+
+ coVerify { repo.setBatteryPromptHandled(true) }
+ }
+
+ @Test
+ fun `refresh re-reads the live battery status`() = runTest(testDispatcher) {
+ val manager = mockk {
+ every { isSupported } returns true
+ // First read (init) is not-unrestricted; the second (refresh) reflects the user's change.
+ every { isIgnoringBatteryOptimizations() } returnsMany listOf(false, true)
+ }
+ val vm = OnboardingViewModel(manager, settingsRepository())
+ assertFalse(vm.batteryUnrestricted.value)
+
+ vm.refreshBatteryStatus()
+
+ assertTrue(vm.batteryUnrestricted.value)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5caf166..f3b1b1e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -86,6 +86,7 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
+androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }