feat(compose): rich HTML editor, multipart send, and signatures
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
@@ -19,6 +18,7 @@ import org.libremail.R
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
@@ -48,6 +48,8 @@ class AccountSettingsScreenTest {
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private var manageSignaturesClicked = false
|
||||
|
||||
private fun setContent(): AccountSettingsRepository {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
// Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by
|
||||
@@ -64,24 +66,27 @@ class AccountSettingsScreenTest {
|
||||
savedStateHandle = SavedStateHandle(mapOf(Routes.ACCOUNT_SETTINGS_ARG_ID to account.id)),
|
||||
accountRepository = FakeAccountRepository(accounts = listOf(account)),
|
||||
accountSettingsRepository = repository,
|
||||
signatureRepository = SignatureRepository(db.signatureDao()),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AccountSettingsScreen(onBack = {}, viewModel = viewModel)
|
||||
AccountSettingsScreen(
|
||||
onBack = {},
|
||||
onManageSignatures = { manageSignaturesClicked = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editingSignature_persistsThroughTheRepository() {
|
||||
val repository = setContent()
|
||||
fun manageSignatures_opensTheSignaturesScreen() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_signature_hint)).performTextInput("Cheers")
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
runBlocking { repository.get(account.id).signature } == "Cheers"
|
||||
}
|
||||
composeTestRule.waitUntil(5_000) { manageSignaturesClicked }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,40 +2,55 @@
|
||||
package org.libremail.data
|
||||
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.mail.HtmlToText
|
||||
import org.libremail.mail.ReplyContext
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/** The pre-filled compose fields for a reply/forward (everything else the user fills in). */
|
||||
data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String)
|
||||
/**
|
||||
* The pre-filled compose fields for a reply/forward. [body] is the plaintext form; [bodyHtml] is the
|
||||
* matching HTML (the quote rendered as a `<blockquote>`), so the reply can go out as
|
||||
* `multipart/alternative` without the user having to re-format the quote.
|
||||
*/
|
||||
data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String, val bodyHtml: String)
|
||||
|
||||
/**
|
||||
* Pure builder that turns an original message ([ReplyContext]) into the pre-filled compose fields for a
|
||||
* reply, reply-all, or forward. Kept free of Android/IMAP dependencies so it can be unit-tested directly.
|
||||
*
|
||||
* HTML originals are quoted by first reducing them to readable text (via [HtmlToText]) and then
|
||||
* quoting that — never by prefixing "> " onto raw tags — so the quote can never corrupt the markup.
|
||||
* The plaintext quote's "> " / attribution structure is then rendered to a clean `<blockquote>` for
|
||||
* the HTML alternative.
|
||||
*/
|
||||
object ReplyBuilder {
|
||||
|
||||
fun build(context: ReplyContext, mode: ReplyMode, selfEmail: String): ReplyContent = when (mode) {
|
||||
ReplyMode.REPLY -> ReplyContent(
|
||||
to = context.fromEmail,
|
||||
cc = "",
|
||||
subject = prefixedSubject(context.subject, "Re:"),
|
||||
body = quotedReply(context),
|
||||
)
|
||||
ReplyMode.REPLY -> reply(context, cc = "")
|
||||
ReplyMode.REPLY_ALL -> reply(context, cc = replyAllCc(context, selfEmail).joinToString(", "))
|
||||
ReplyMode.FORWARD -> {
|
||||
val body = forwardedBody(context)
|
||||
ReplyContent(
|
||||
to = "",
|
||||
cc = "",
|
||||
subject = prefixedSubject(context.subject, "Fwd:"),
|
||||
body = body,
|
||||
bodyHtml = htmlOf(body),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ReplyMode.REPLY_ALL -> ReplyContent(
|
||||
private fun reply(context: ReplyContext, cc: String): ReplyContent {
|
||||
val body = quotedReply(context)
|
||||
return ReplyContent(
|
||||
to = context.fromEmail,
|
||||
cc = replyAllCc(context, selfEmail).joinToString(", "),
|
||||
cc = cc,
|
||||
subject = prefixedSubject(context.subject, "Re:"),
|
||||
body = quotedReply(context),
|
||||
)
|
||||
|
||||
ReplyMode.FORWARD -> ReplyContent(
|
||||
to = "",
|
||||
cc = "",
|
||||
subject = prefixedSubject(context.subject, "Fwd:"),
|
||||
body = forwardedBody(context),
|
||||
body = body,
|
||||
bodyHtml = htmlOf(body),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,15 +83,12 @@ object ReplyBuilder {
|
||||
append(bodyText(context))
|
||||
}
|
||||
|
||||
/** The original body as plain text (HTML stripped), suitable for quoting in a plain-text compose. */
|
||||
private fun bodyText(context: ReplyContext): String = if (context.isHtml) htmlToText(context.body) else context.body
|
||||
/** The original body as plain text (HTML stripped), suitable for quoting in a compose field. */
|
||||
private fun bodyText(context: ReplyContext): String =
|
||||
if (context.isHtml) HtmlToText.convert(context.body) else context.body
|
||||
|
||||
private fun htmlToText(html: String): String = html
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
||||
.replace(Regex("<[^>]*>"), "")
|
||||
.replace(Regex("[ \\t]+"), " ")
|
||||
.trim()
|
||||
/** Renders the plaintext quote (with its "> " markers) to the equivalent clean HTML. */
|
||||
private fun htmlOf(body: String): String = RichTextHtml.toHtml(RichTextContent(body))
|
||||
|
||||
private fun formatDate(millis: Long): String =
|
||||
SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis))
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data
|
||||
|
||||
import org.libremail.domain.model.Signature
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
|
||||
/**
|
||||
* A signature rendered into both forms the composer needs: [plain] for the plaintext body/fallback
|
||||
* and [html] for the HTML body. The two are kept in sync — parsing [html] back through the rich-text
|
||||
* model yields exactly [plain] — so the editor shows the same content whichever it seeds from, and a
|
||||
* From-account swap can strip the previously applied block from either representation.
|
||||
*
|
||||
* The block opens with the RFC 3676 "-- " delimiter so receiving clients recognize it as a signature.
|
||||
*/
|
||||
data class SignatureBlock(val plain: String, val html: String) {
|
||||
val isEmpty: Boolean get() = plain.isEmpty() && html.isEmpty()
|
||||
|
||||
companion object {
|
||||
val EMPTY = SignatureBlock("", "")
|
||||
|
||||
/** The block for [signature], or [EMPTY] when there is none / it is blank. */
|
||||
fun of(signature: Signature?): SignatureBlock {
|
||||
if (signature == null) return EMPTY
|
||||
val plainSig = signature.plainText().trimEnd()
|
||||
if (plainSig.isBlank() && signature.html.isBlank()) return EMPTY
|
||||
return SignatureBlock(
|
||||
plain = "$DELIMITER_PLAIN$plainSig",
|
||||
html = RichTextHtml.toHtml(RichTextContent(DELIMITER_PLAIN)) + signature.html,
|
||||
)
|
||||
}
|
||||
|
||||
private const val DELIMITER_PLAIN = "\n\n-- \n"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.dao.OutboxDao
|
||||
import org.libremail.data.local.dao.SignatureDao
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
@@ -19,6 +20,7 @@ import org.libremail.data.local.entity.DraftEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -30,8 +32,9 @@ import org.libremail.data.local.entity.OutboxEntity
|
||||
OutboxEntity::class,
|
||||
DraftEntity::class,
|
||||
FolderEntity::class,
|
||||
SignatureEntity::class,
|
||||
],
|
||||
version = 9,
|
||||
version = 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <br> so the HTML keeps the original line breaks; other characters are rare
|
||||
// in signatures and pass through unescaped.
|
||||
db.execSQL(
|
||||
"INSERT INTO `signatures` (`id`, `accountId`, `name`, `contentHtml`, `isDefault`) " +
|
||||
"SELECT `accountId` || ':default-signature', `accountId`, 'Signature', " +
|
||||
"replace(`signature`, char(10), '<br>'), 1 " +
|
||||
"FROM `account_settings` WHERE `signature` <> ''",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
@Dao
|
||||
interface SignatureDao {
|
||||
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY isDefault DESC, name COLLATE NOCASE")
|
||||
fun observeForAccount(accountId: String): Flow<List<SignatureEntity>>
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE id = :id LIMIT 1")
|
||||
suspend fun getById(id: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE accountId = :accountId AND isDefault = 1 LIMIT 1")
|
||||
suspend fun getDefault(accountId: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY name COLLATE NOCASE LIMIT 1")
|
||||
suspend fun firstForAccount(accountId: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM signatures WHERE accountId = :accountId")
|
||||
suspend fun countForAccount(accountId: String): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(signature: SignatureEntity)
|
||||
|
||||
@Query("DELETE FROM signatures WHERE id = :id")
|
||||
suspend fun delete(id: String)
|
||||
|
||||
@Query("UPDATE signatures SET isDefault = 0 WHERE accountId = :accountId")
|
||||
suspend fun clearDefault(accountId: String)
|
||||
|
||||
@Query("UPDATE signatures SET isDefault = 1 WHERE id = :id")
|
||||
suspend fun markDefault(id: String)
|
||||
|
||||
/** Makes [id] the account's sole default in one transaction (clears the others first). */
|
||||
@Transaction
|
||||
suspend fun setDefault(accountId: String, id: String) {
|
||||
clearDefault(accountId)
|
||||
markDefault(id)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One saved signature. The [accountId] foreign key cascades on delete, so removing an account drops
|
||||
* its signatures. [isDefault] marks the one auto-inserted when composing from the account; the
|
||||
* repository keeps at most one default per account.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "signatures",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = AccountEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["accountId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("accountId")],
|
||||
)
|
||||
data class SignatureEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val accountId: String,
|
||||
val name: String,
|
||||
val contentHtml: String,
|
||||
val isDefault: Boolean = false,
|
||||
)
|
||||
@@ -8,6 +8,7 @@ import jakarta.mail.Flags
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.libremail.data.ReplyBuilder
|
||||
import org.libremail.data.SignatureBlock
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.AttachmentDao
|
||||
import org.libremail.data.local.dao.DraftDao
|
||||
@@ -20,6 +21,7 @@ import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.toDomain
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.data.sync.MailConnectionFactory
|
||||
import org.libremail.data.sync.SendScheduler
|
||||
import org.libremail.domain.model.Attachment
|
||||
@@ -52,6 +54,7 @@ class MailRepositoryImpl @Inject constructor(
|
||||
private val connectionFactory: MailConnectionFactory,
|
||||
private val sendScheduler: SendScheduler,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val signatureRepository: SignatureRepository,
|
||||
) : MailRepository {
|
||||
|
||||
override fun observeMessages(): Flow<List<Message>> = messageDao.observeAll().map { rows ->
|
||||
@@ -191,9 +194,15 @@ class MailRepositoryImpl @Inject constructor(
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val context = imapClient.fetchForReply(params, entity.folder, uidOf(messageId))
|
||||
val content = ReplyBuilder.build(context, mode, account.email)
|
||||
// Bake the sending account's signature into the reply/forward body so it round-trips as part
|
||||
// of the draft (compose won't re-append for drafts).
|
||||
val signature = accountSettingsRepository.get(entity.accountId).signatureBlock()
|
||||
// Bake the sending account's default signature into the reply/forward body — above the quoted
|
||||
// original — so it round-trips as part of the draft (compose won't re-append for drafts). Both
|
||||
// the plaintext and HTML forms are stored so the reply can go out as multipart/alternative.
|
||||
val settings = accountSettingsRepository.get(entity.accountId)
|
||||
val sig = if (settings.signatureEnabled) {
|
||||
SignatureBlock.of(signatureRepository.getDefault(entity.accountId))
|
||||
} else {
|
||||
SignatureBlock.EMPTY
|
||||
}
|
||||
val draftId = UUID.randomUUID().toString()
|
||||
saveDraft(
|
||||
Draft(
|
||||
@@ -202,8 +211,9 @@ class MailRepositoryImpl @Inject constructor(
|
||||
to = content.to,
|
||||
cc = content.cc,
|
||||
subject = content.subject,
|
||||
body = content.body + signature,
|
||||
body = sig.plain + content.body,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
bodyHtml = sig.html + content.bodyHtml,
|
||||
attachments = emptyList(),
|
||||
),
|
||||
)
|
||||
@@ -269,6 +279,7 @@ class MailRepositoryImpl @Inject constructor(
|
||||
subject = outgoing.subject,
|
||||
body = outgoing.body,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
bodyHtml = outgoing.bodyHtml,
|
||||
),
|
||||
)
|
||||
sendScheduler.sendNow()
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.settings
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.libremail.data.local.dao.SignatureDao
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
import org.libremail.domain.model.Signature
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* CRUD for per-account signatures, keeping the "at most one default per account" invariant. The
|
||||
* first signature created for an account becomes its default automatically; deleting the default
|
||||
* promotes another (if any) so an account with signatures always has one to auto-insert.
|
||||
*/
|
||||
@Singleton
|
||||
class SignatureRepository @Inject constructor(private val dao: SignatureDao) {
|
||||
|
||||
fun observeForAccount(accountId: String): Flow<List<Signature>> =
|
||||
dao.observeForAccount(accountId).map { rows -> rows.map { it.toDomain() } }
|
||||
|
||||
suspend fun get(id: String): Signature? = dao.getById(id)?.toDomain()
|
||||
|
||||
suspend fun getDefault(accountId: String): Signature? = dao.getDefault(accountId)?.toDomain()
|
||||
|
||||
/** Creates a signature; makes it the default when it is the account's first. Returns its id. */
|
||||
suspend fun create(accountId: String, name: String, html: String): String {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val isFirst = dao.countForAccount(accountId) == 0
|
||||
dao.upsert(SignatureEntity(id, accountId, name, html, isDefault = isFirst))
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun update(id: String, name: String, html: String) {
|
||||
val existing = dao.getById(id) ?: return
|
||||
dao.upsert(existing.copy(name = name, contentHtml = html))
|
||||
}
|
||||
|
||||
suspend fun delete(id: String) {
|
||||
val existing = dao.getById(id) ?: return
|
||||
dao.delete(id)
|
||||
// If we removed the default, promote the account's first remaining signature.
|
||||
if (existing.isDefault) {
|
||||
dao.firstForAccount(existing.accountId)?.let { dao.markDefault(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDefault(accountId: String, id: String) = dao.setDefault(accountId, id)
|
||||
|
||||
private fun SignatureEntity.toDomain() = Signature(id, accountId, name, contentHtml, isDefault)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<OutgoingAttachment> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -9,4 +9,6 @@ data class OutboxMessage(
|
||||
val body: String,
|
||||
val createdAt: Long,
|
||||
val lastError: String?,
|
||||
/** HTML form of [body] when composed with formatting; null for plaintext messages. */
|
||||
val bodyHtml: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,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<OutgoingAttachment> = emptyList(),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.domain.model
|
||||
|
||||
import org.libremail.mail.HtmlToText
|
||||
|
||||
/**
|
||||
* A named, rich (HTML) email signature belonging to an account. Each account may have several; at
|
||||
* most one is its [isDefault], which is the one auto-inserted when composing from that account.
|
||||
*/
|
||||
data class Signature(
|
||||
val id: String,
|
||||
val accountId: String,
|
||||
val name: String,
|
||||
/** The signature body as HTML (authored in the same rich editor as the message body). */
|
||||
val html: String,
|
||||
val isDefault: Boolean = false,
|
||||
) {
|
||||
/** The signature as plain text, for the `text/plain` alternative and plaintext-only composing. */
|
||||
fun plainText(): String = HtmlToText.convert(html)
|
||||
}
|
||||
@@ -84,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<File>): String {
|
||||
// Graph carries a single body object: send HTML when the message was formatted (Outlook renders
|
||||
// it and derives its own plaintext), otherwise plain text so unformatted mail is unchanged.
|
||||
val body = if (message.bodyHtml != null) {
|
||||
JSONObject().put("contentType", "HTML").put("content", message.bodyHtml)
|
||||
} else {
|
||||
JSONObject().put("contentType", "Text").put("content", message.body)
|
||||
}
|
||||
val mail = JSONObject()
|
||||
.put("subject", message.subject)
|
||||
.put("body", JSONObject().put("contentType", "Text").put("content", message.body))
|
||||
.put("body", body)
|
||||
.put("toRecipients", recipientsJson(message.to))
|
||||
if (message.cc.isNotBlank()) {
|
||||
mail.put("ccRecipients", recipientsJson(message.cc))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
/**
|
||||
* Converts an HTML body into readable plain text for the `text/plain` alternative part and for
|
||||
* quoting HTML originals in replies/forwards. Pure (no Android/Compose), so it is unit-testable on
|
||||
* the JVM.
|
||||
*
|
||||
* It is intentionally lightweight — block elements become line breaks, list items gain a "• "
|
||||
* marker, tags are dropped and entities decoded — rather than a full HTML renderer. The goal is a
|
||||
* legible fallback, not a faithful reproduction.
|
||||
*/
|
||||
object HtmlToText {
|
||||
|
||||
private val BLOCK_BREAK = Regex(
|
||||
"(?i)</?(p|div|tr|table|ul|ol|h[1-6]|blockquote)\\b[^>]*>|<br\\s*/?>",
|
||||
)
|
||||
private val LIST_ITEM = Regex("(?i)<li\\b[^>]*>")
|
||||
|
||||
fun convert(html: String): String {
|
||||
var s = html
|
||||
// Drop script/style contents outright so their text never leaks into the output.
|
||||
s = s.replace(Regex("(?is)<(script|style)\\b[^>]*>.*?</\\1>"), "")
|
||||
s = LIST_ITEM.replace(s, "\n• ")
|
||||
s = BLOCK_BREAK.replace(s, "\n")
|
||||
s = s.replace(Regex("<[^>]*>"), "")
|
||||
s = decodeEntities(s)
|
||||
// Collapse runs of spaces/tabs, then trim trailing spaces and cap consecutive blank lines.
|
||||
s = s.replace(Regex("[ \\t]+"), " ")
|
||||
s = s.lineSequence().joinToString("\n") { it.trim() }
|
||||
s = s.replace(Regex("\n{3,}"), "\n\n")
|
||||
return s.trim()
|
||||
}
|
||||
|
||||
private fun decodeEntities(s: String): String = s
|
||||
.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("—", "—")
|
||||
.replace("–", "–")
|
||||
.replace("&", "&")
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import jakarta.mail.internet.InternetAddress
|
||||
import jakarta.mail.internet.MimeBodyPart
|
||||
import jakarta.mail.internet.MimeMessage
|
||||
import jakarta.mail.internet.MimeMultipart
|
||||
import jakarta.mail.internet.MimePart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
@@ -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<File>) {
|
||||
if (attachments.isEmpty()) {
|
||||
setBody(mime, message)
|
||||
return
|
||||
}
|
||||
val mixed = MimeMultipart("mixed")
|
||||
mixed.addBodyPart(MimeBodyPart().also { setBody(it, message) })
|
||||
attachments.forEach { file -> mixed.addBodyPart(MimeBodyPart().apply { attachFile(file) }) }
|
||||
mime.setContent(mixed)
|
||||
}
|
||||
|
||||
/** Writes the body onto [part]: plain text, or a text/plain + text/html alternative when formatted. */
|
||||
private fun setBody(part: MimePart, message: OutgoingMessage) {
|
||||
val html = message.bodyHtml
|
||||
if (html == null) {
|
||||
part.setText(message.body, "UTF-8")
|
||||
} else {
|
||||
part.setContent(alternative(message.body, html))
|
||||
}
|
||||
}
|
||||
|
||||
private fun alternative(plain: String, html: String): MimeMultipart = MimeMultipart("alternative").apply {
|
||||
addBodyPart(MimeBodyPart().apply { setText(plain, "UTF-8") })
|
||||
addBodyPart(MimeBodyPart().apply { setContent(html, "text/html; charset=UTF-8") })
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = "15000"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.richtext
|
||||
|
||||
/** Inline character styles the compose editor supports. */
|
||||
enum class RichStyle { BOLD, ITALIC, UNDERLINE }
|
||||
|
||||
/** A run of [style] over the half-open range [[start], [end]) of the plain text. */
|
||||
data class RichSpan(val start: Int, val end: Int, val style: RichStyle)
|
||||
|
||||
/** A hyperlink over the half-open range [[start], [end]) pointing at [url]. */
|
||||
data class RichLink(val start: Int, val end: Int, val url: String)
|
||||
|
||||
/**
|
||||
* The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links].
|
||||
*
|
||||
* Block structure (unordered/ordered lists and block quotes) is encoded as recognizable line
|
||||
* prefixes inside [text] — "• " for bullets, "N. " for numbered items, and "> " for quotes — so
|
||||
* the editor can stay a single plain-text field (keeping the plaintext-only experience smooth)
|
||||
* while the model still round-trips cleanly to real HTML and to a readable plaintext fallback.
|
||||
*/
|
||||
data class RichTextContent(
|
||||
val text: String = "",
|
||||
val spans: List<RichSpan> = emptyList(),
|
||||
val links: List<RichLink> = emptyList(),
|
||||
) {
|
||||
val isBlank: Boolean get() = text.isBlank()
|
||||
|
||||
/**
|
||||
* True when the content carries anything a plaintext field could not represent: inline styling,
|
||||
* a link, or a block marker. When false, callers should send/persist plaintext only so an
|
||||
* unformatted message stays byte-for-byte identical to the old plaintext-only path.
|
||||
*/
|
||||
fun hasFormatting(): Boolean =
|
||||
spans.isNotEmpty() || links.isNotEmpty() || text.lineSequence().any { lineMarker(it) != null }
|
||||
}
|
||||
|
||||
/** Recognized block markers and the tags they map to. */
|
||||
internal const val BULLET_PREFIX = "• "
|
||||
internal const val QUOTE_PREFIX = "> "
|
||||
private val ORDERED_PREFIX = Regex("^\\d+\\. ")
|
||||
|
||||
private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE }
|
||||
|
||||
private data class Line(val kind: Kind, val contentStart: Int, val contentEnd: Int)
|
||||
|
||||
/** The block marker prefixing [line], or null for an ordinary paragraph line. */
|
||||
internal fun lineMarker(line: String): String? = when {
|
||||
line.startsWith(BULLET_PREFIX) -> BULLET_PREFIX
|
||||
line.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX
|
||||
else -> ORDERED_PREFIX.find(line)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or
|
||||
* Compose types), so the whole conversion is unit-testable on the JVM.
|
||||
*
|
||||
* The emitted subset — `<b> <i> <u> <a> <ul>/<ol>/<li> <blockquote> <br>` — is deliberately narrow
|
||||
* so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts round-trip losslessly).
|
||||
*/
|
||||
object RichTextHtml {
|
||||
|
||||
fun toHtml(content: RichTextContent): String {
|
||||
if (content.text.isEmpty()) return ""
|
||||
val lines = classify(content.text)
|
||||
val sb = StringBuilder()
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
i = when (lines[i].kind) {
|
||||
Kind.BULLET -> emitList(sb, content, lines, i, Kind.BULLET, "ul")
|
||||
Kind.ORDERED -> emitList(sb, content, lines, i, Kind.ORDERED, "ol")
|
||||
Kind.QUOTE -> emitQuote(sb, content, lines, i)
|
||||
Kind.PARAGRAPH -> emitParagraph(sb, content, lines, i)
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */
|
||||
fun toPlainText(content: RichTextContent): String = content.text
|
||||
|
||||
fun fromHtml(html: String): RichTextContent = HtmlToRichParser(html).parse()
|
||||
}
|
||||
|
||||
private fun classify(text: String): List<Line> {
|
||||
val lines = ArrayList<Line>()
|
||||
var start = 0
|
||||
while (true) {
|
||||
val nl = text.indexOf('\n', start)
|
||||
val end = if (nl == -1) text.length else nl
|
||||
val marker = lineMarker(text.substring(start, end))
|
||||
val kind = when (marker) {
|
||||
BULLET_PREFIX -> Kind.BULLET
|
||||
QUOTE_PREFIX -> Kind.QUOTE
|
||||
null -> Kind.PARAGRAPH
|
||||
else -> Kind.ORDERED
|
||||
}
|
||||
lines.add(Line(kind, start + (marker?.length ?: 0), end))
|
||||
if (nl == -1) break
|
||||
start = nl + 1
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun emitList(
|
||||
sb: StringBuilder,
|
||||
content: RichTextContent,
|
||||
lines: List<Line>,
|
||||
from: Int,
|
||||
kind: Kind,
|
||||
tag: String,
|
||||
): Int {
|
||||
sb.append("<").append(tag).append(">")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == kind) {
|
||||
sb.append("<li>").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("</li>")
|
||||
i++
|
||||
}
|
||||
sb.append("</").append(tag).append(">")
|
||||
return i
|
||||
}
|
||||
|
||||
private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
sb.append("<blockquote>")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.QUOTE) {
|
||||
if (i > from) sb.append("<br>")
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
sb.append("</blockquote>")
|
||||
return i
|
||||
}
|
||||
|
||||
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
sb.append("<p>")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) {
|
||||
if (i > from) sb.append("<br>")
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
sb.append("</p>")
|
||||
return i
|
||||
}
|
||||
|
||||
/** Renders the inline styling/links over [[from], [to]) as nested `<a>/<b>/<i>/<u>` tags. */
|
||||
private fun renderInline(content: RichTextContent, from: Int, to: Int): String {
|
||||
if (from >= to) return ""
|
||||
val points = cutPoints(content, from, to)
|
||||
val sb = StringBuilder()
|
||||
for (idx in 0 until points.size - 1) {
|
||||
val a = points[idx]
|
||||
val b = points[idx + 1]
|
||||
if (a < b) appendRun(sb, content, a, b)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** The sorted set of offsets where a span or link starts/ends within [[from], [to]]. */
|
||||
private fun cutPoints(content: RichTextContent, from: Int, to: Int): List<Int> {
|
||||
val cuts = sortedSetOf(from, to)
|
||||
fun add(start: Int, end: Int) {
|
||||
if (end > from && start < to) {
|
||||
cuts.add(start.coerceIn(from, to))
|
||||
cuts.add(end.coerceIn(from, to))
|
||||
}
|
||||
}
|
||||
content.spans.forEach { add(it.start, it.end) }
|
||||
content.links.forEach { add(it.start, it.end) }
|
||||
return cuts.toList()
|
||||
}
|
||||
|
||||
/** Emits one constant-styling run [[a], [b]) with fully-closed tags, so nesting is always valid. */
|
||||
private fun appendRun(sb: StringBuilder, content: RichTextContent, a: Int, b: Int) {
|
||||
val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }.toSet()
|
||||
val link = content.links.firstOrNull { it.start <= a && b <= it.end }
|
||||
if (link != null) sb.append("<a href=\"").append(escapeAttr(link.url)).append("\">")
|
||||
if (RichStyle.BOLD in styles) sb.append("<b>")
|
||||
if (RichStyle.ITALIC in styles) sb.append("<i>")
|
||||
if (RichStyle.UNDERLINE in styles) sb.append("<u>")
|
||||
sb.append(escape(content.text.substring(a, b)))
|
||||
if (RichStyle.UNDERLINE in styles) sb.append("</u>")
|
||||
if (RichStyle.ITALIC in styles) sb.append("</i>")
|
||||
if (RichStyle.BOLD in styles) sb.append("</b>")
|
||||
if (link != null) sb.append("</a>")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the narrow HTML subset [toHtml][RichTextHtml.toHtml] emits (plus `strong`/`em` and
|
||||
* pretty-printer whitespace) back into a [RichTextContent]. A small state machine keeps the nesting
|
||||
* shallow: [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text.
|
||||
*/
|
||||
private class HtmlToRichParser(private val html: String) {
|
||||
private val text = StringBuilder()
|
||||
private val spans = ArrayList<RichSpan>()
|
||||
private val links = ArrayList<RichLink>()
|
||||
private var boldStart = -1
|
||||
private var italicStart = -1
|
||||
private var underlineStart = -1
|
||||
private var linkStart = -1
|
||||
private var linkUrl = ""
|
||||
private var listType: Char? = null
|
||||
private var olCount = 0
|
||||
private var inQuote = false
|
||||
|
||||
fun parse(): RichTextContent {
|
||||
var i = 0
|
||||
while (i < html.length) {
|
||||
if (html[i] == '<') {
|
||||
val gt = html.indexOf('>', i)
|
||||
if (gt == -1) break
|
||||
handleTag(html.substring(i + 1, gt).trim())
|
||||
i = gt + 1
|
||||
} else {
|
||||
val lt = html.indexOf('<', i)
|
||||
val end = if (lt == -1) html.length else lt
|
||||
handleText(html.substring(i, end))
|
||||
i = end
|
||||
}
|
||||
}
|
||||
return finish()
|
||||
}
|
||||
|
||||
private fun atLineStart() = text.isEmpty() || text.last() == '\n'
|
||||
|
||||
private fun newlineIfNeeded() {
|
||||
if (!atLineStart()) text.append('\n')
|
||||
}
|
||||
|
||||
private fun handleTag(raw: String) {
|
||||
val closing = raw.startsWith("/")
|
||||
val body = raw.removePrefix("/").trim()
|
||||
when (body.substringBefore(' ').substringBefore('/').lowercase()) {
|
||||
"br" -> {
|
||||
text.append('\n')
|
||||
if (inQuote) text.append(QUOTE_PREFIX)
|
||||
}
|
||||
"b", "strong" -> boldStart = toggle(closing, boldStart, RichStyle.BOLD)
|
||||
"i", "em" -> italicStart = toggle(closing, italicStart, RichStyle.ITALIC)
|
||||
"u" -> underlineStart = toggle(closing, underlineStart, RichStyle.UNDERLINE)
|
||||
"a" -> handleAnchor(closing, body)
|
||||
"ul" -> handleList(closing, 'u')
|
||||
"ol" -> handleList(closing, 'o')
|
||||
"li" -> if (!closing) startListItem()
|
||||
"blockquote" -> handleQuote(closing)
|
||||
"p", "div" -> newlineIfNeeded()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a style (returns the current offset) or closes it (records the span, returns -1). */
|
||||
private fun toggle(closing: Boolean, openOffset: Int, style: RichStyle): Int {
|
||||
if (!closing) return text.length
|
||||
if (openOffset >= 0) spans.add(RichSpan(openOffset, text.length, style))
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun handleAnchor(closing: Boolean, body: String) {
|
||||
if (closing) {
|
||||
if (linkStart >= 0) {
|
||||
links.add(RichLink(linkStart, text.length, linkUrl))
|
||||
linkStart = -1
|
||||
linkUrl = ""
|
||||
}
|
||||
} else {
|
||||
linkStart = text.length
|
||||
linkUrl = extractHref(body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleList(closing: Boolean, type: Char) {
|
||||
if (closing) {
|
||||
listType = null
|
||||
} else {
|
||||
listType = type
|
||||
if (type == 'o') olCount = 0
|
||||
}
|
||||
newlineIfNeeded()
|
||||
}
|
||||
|
||||
private fun startListItem() {
|
||||
newlineIfNeeded()
|
||||
if (listType == 'o') {
|
||||
olCount++
|
||||
text.append("$olCount. ")
|
||||
} else {
|
||||
text.append(BULLET_PREFIX)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleQuote(closing: Boolean) {
|
||||
newlineIfNeeded()
|
||||
inQuote = !closing
|
||||
if (!closing) text.append(QUOTE_PREFIX)
|
||||
}
|
||||
|
||||
private fun handleText(chunk: String) {
|
||||
// Drop the insignificant whitespace a pretty-printer leaves between block tags (blank runs at
|
||||
// a line start, or any blank run with a newline), but keep a real space between inline runs.
|
||||
if (!(chunk.isBlank() && (atLineStart() || chunk.contains('\n')))) text.append(unescape(chunk))
|
||||
}
|
||||
|
||||
private fun finish(): RichTextContent {
|
||||
val out = text.toString().trimEnd('\n')
|
||||
val len = out.length
|
||||
if (boldStart in 0 until len) spans.add(RichSpan(boldStart, len, RichStyle.BOLD))
|
||||
if (italicStart in 0 until len) spans.add(RichSpan(italicStart, len, RichStyle.ITALIC))
|
||||
if (underlineStart in 0 until len) spans.add(RichSpan(underlineStart, len, RichStyle.UNDERLINE))
|
||||
if (linkStart in 0 until len) links.add(RichLink(linkStart, len, linkUrl))
|
||||
return RichTextContent(
|
||||
text = out,
|
||||
spans = spans.filter { it.end <= len && it.start < it.end },
|
||||
links = links.filter { it.end <= len && it.start < it.end },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractHref(tagBody: String): String {
|
||||
val match = Regex("href\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE).find(tagBody) ?: return ""
|
||||
val (doubleQuoted, singleQuoted) = match.destructured
|
||||
return unescape(doubleQuoted.ifEmpty { singleQuoted })
|
||||
}
|
||||
|
||||
private fun escape(s: String): String = s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
private fun escapeAttr(s: String): String = escape(s).replace("\"", """)
|
||||
|
||||
private fun unescape(s: String): String = s
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
@@ -0,0 +1,200 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.richtext
|
||||
|
||||
/** A block-level marker the toolbar can toggle over the selected lines. */
|
||||
enum class BlockMarker { BULLET, ORDERED, QUOTE }
|
||||
|
||||
/** The result of an editing op: the new [content] and where the selection should land. */
|
||||
data class EditResult(val content: RichTextContent, val selectionStart: Int, val selectionEnd: Int)
|
||||
|
||||
/**
|
||||
* Pure editing operations over [RichTextContent] — toggling inline styles/links and block markers —
|
||||
* shared by the Compose editor's toolbar. Keeping them free of Compose types makes the tricky index
|
||||
* bookkeeping (splitting spans, shifting offsets when a line marker is inserted/removed) unit-testable
|
||||
* on the JVM.
|
||||
*/
|
||||
object RichTextEditing {
|
||||
|
||||
/** Adds [style] over [[start], [end]) if it is not already fully styled, otherwise removes it. */
|
||||
fun toggleStyle(content: RichTextContent, start: Int, end: Int, style: RichStyle): RichTextContent {
|
||||
if (start >= end) return content
|
||||
val others = content.spans.filter { it.style != style }
|
||||
val same = content.spans.filter { it.style == style }
|
||||
val updated = if (isFullyStyled(same, start, end)) {
|
||||
subtractRange(same, start, end)
|
||||
} else {
|
||||
mergeSameStyle(same + RichSpan(start, end, style))
|
||||
}
|
||||
return content.copy(spans = (others + updated).sortedBy { it.start })
|
||||
}
|
||||
|
||||
/** Links [[start], [end]) to [url], replacing any links that overlap the range. */
|
||||
fun applyLink(content: RichTextContent, start: Int, end: Int, url: String): RichTextContent {
|
||||
if (start >= end || url.isBlank()) return content
|
||||
val kept = content.links.filter { it.end <= start || it.start >= end }
|
||||
return content.copy(links = (kept + RichLink(start, end, url)).sortedBy { it.start })
|
||||
}
|
||||
|
||||
/** Removes any links overlapping [[start], [end]). */
|
||||
fun removeLink(content: RichTextContent, start: Int, end: Int): RichTextContent {
|
||||
if (start >= end) return content
|
||||
return content.copy(links = content.links.filter { it.end <= start || it.start >= end })
|
||||
}
|
||||
|
||||
/** Whether [[start], [end]) is entirely covered by [style] (drives the toolbar's toggle state). */
|
||||
fun isStyled(content: RichTextContent, start: Int, end: Int, style: RichStyle): Boolean =
|
||||
start < end && isFullyStyled(content.spans.filter { it.style == style }, start, end)
|
||||
|
||||
/** Whether every line the selection touches carries [marker]. */
|
||||
fun hasBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): Boolean {
|
||||
val lineStarts = lineStartsTouching(content.text, start, end)
|
||||
return lineStarts.isNotEmpty() && lineStarts.all { markerAt(content.text, it) == marker }
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles [marker] across every line the selection touches: if all those lines already carry it,
|
||||
* it is removed; otherwise it is applied (replacing any other block marker already there). Spans,
|
||||
* links and the selection are shifted to track the inserted/removed prefixes.
|
||||
*/
|
||||
fun toggleBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): EditResult {
|
||||
val text = content.text
|
||||
val lineStarts = lineStartsTouching(text, start, end)
|
||||
val allHaveMarker = lineStarts.all { markerAt(text, it) == marker }
|
||||
val edits = ArrayList<LineEdit>()
|
||||
var ordinal = 1
|
||||
for (lineStart in lineStarts) {
|
||||
val existing = markerLengthAt(text, lineStart)
|
||||
when {
|
||||
allHaveMarker && existing > 0 -> edits.add(LineEdit(lineStart, existing, ""))
|
||||
allHaveMarker -> Unit
|
||||
else -> edits.add(LineEdit(lineStart, existing, insertFor(marker, ordinal++)))
|
||||
}
|
||||
}
|
||||
val (newText, remap) = applyEdits(text, edits)
|
||||
val newSpans = content.spans.mapNotNull { remapSpan(it, remap) }
|
||||
val newLinks = content.links.mapNotNull { remapLink(it, remap) }
|
||||
return EditResult(RichTextContent(newText, newSpans, newLinks), remap(start), remap(end))
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertFor(marker: BlockMarker, ordinal: Int): String = when (marker) {
|
||||
BlockMarker.BULLET -> BULLET_PREFIX
|
||||
BlockMarker.QUOTE -> QUOTE_PREFIX
|
||||
BlockMarker.ORDERED -> "$ordinal. "
|
||||
}
|
||||
|
||||
private fun remapSpan(span: RichSpan, remap: (Int) -> Int): RichSpan? {
|
||||
val s = remap(span.start)
|
||||
val e = remap(span.end)
|
||||
return if (s < e) RichSpan(s, e, span.style) else null
|
||||
}
|
||||
|
||||
private fun remapLink(link: RichLink, remap: (Int) -> Int): RichLink? {
|
||||
val s = remap(link.start)
|
||||
val e = remap(link.end)
|
||||
return if (s < e) RichLink(s, e, link.url) else null
|
||||
}
|
||||
|
||||
// --- inline style helpers ---
|
||||
|
||||
private fun isFullyStyled(spans: List<RichSpan>, start: Int, end: Int): Boolean {
|
||||
var pos = start
|
||||
for (span in spans.filter { it.end > start && it.start < end }.sortedBy { it.start }) {
|
||||
if (span.start > pos) return false
|
||||
pos = maxOf(pos, span.end)
|
||||
if (pos >= end) return true
|
||||
}
|
||||
return pos >= end
|
||||
}
|
||||
|
||||
private fun subtractRange(spans: List<RichSpan>, start: Int, end: Int): List<RichSpan> = spans.flatMap { span ->
|
||||
when {
|
||||
span.end <= start || span.start >= end -> listOf(span)
|
||||
else -> buildList {
|
||||
if (span.start < start) add(span.copy(end = start))
|
||||
if (span.end > end) add(span.copy(start = end))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeSameStyle(spans: List<RichSpan>): List<RichSpan> {
|
||||
val merged = ArrayList<RichSpan>()
|
||||
for (span in spans.sortedBy { it.start }) {
|
||||
val last = merged.lastOrNull()
|
||||
if (last != null && span.start <= last.end) {
|
||||
merged[merged.size - 1] = last.copy(end = maxOf(last.end, span.end))
|
||||
} else {
|
||||
merged.add(span)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// --- block marker helpers ---
|
||||
|
||||
private val ORDERED = Regex("^\\d+\\. ")
|
||||
|
||||
private fun markerAt(text: String, lineStart: Int): BlockMarker? {
|
||||
val rest = text.substring(lineStart)
|
||||
return when {
|
||||
rest.startsWith(BULLET_PREFIX) -> BlockMarker.BULLET
|
||||
rest.startsWith(QUOTE_PREFIX) -> BlockMarker.QUOTE
|
||||
// ORDERED is anchored at ^, so find() matches only when this line starts with "N. ".
|
||||
ORDERED.find(rest) != null -> BlockMarker.ORDERED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun markerLengthAt(text: String, lineStart: Int): Int {
|
||||
val rest = text.substring(lineStart)
|
||||
return when {
|
||||
rest.startsWith(BULLET_PREFIX) -> BULLET_PREFIX.length
|
||||
rest.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX.length
|
||||
else -> ORDERED.find(rest)?.value?.length ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Start offsets of every line the range [[start], [end]] intersects (a caret counts as its line). */
|
||||
private fun lineStartsTouching(text: String, start: Int, end: Int): List<Int> {
|
||||
val from = start.coerceIn(0, text.length)
|
||||
val to = end.coerceIn(from, text.length)
|
||||
val result = ArrayList<Int>()
|
||||
var lineStart = if (from == 0) 0 else text.lastIndexOf('\n', from - 1).let { if (it == -1) 0 else it + 1 }
|
||||
while (lineStart <= text.length) {
|
||||
result.add(lineStart)
|
||||
val nl = text.indexOf('\n', lineStart)
|
||||
if (nl == -1 || nl >= to) break
|
||||
lineStart = nl + 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private data class LineEdit(val offset: Int, val deleteLen: Int, val insert: String)
|
||||
|
||||
/** Applies line-start [edits] (ascending, non-overlapping) and returns the new text + an index remap. */
|
||||
private fun applyEdits(text: String, edits: List<LineEdit>): Pair<String, (Int) -> Int> {
|
||||
if (edits.isEmpty()) return text to { it }
|
||||
val sorted = edits.sortedBy { it.offset }
|
||||
val sb = StringBuilder()
|
||||
var cursor = 0
|
||||
for (edit in sorted) {
|
||||
sb.append(text, cursor, edit.offset)
|
||||
sb.append(edit.insert)
|
||||
cursor = edit.offset + edit.deleteLen
|
||||
}
|
||||
sb.append(text, cursor, text.length)
|
||||
val remap: (Int) -> Int = { index -> remapIndex(index, sorted).coerceIn(0, sb.length) }
|
||||
return sb.toString() to remap
|
||||
}
|
||||
|
||||
private fun remapIndex(index: Int, edits: List<LineEdit>): Int {
|
||||
var delta = 0
|
||||
for (edit in edits) {
|
||||
val delEnd = edit.offset + edit.deleteLen
|
||||
when {
|
||||
delEnd <= index -> delta += edit.insert.length - edit.deleteLen
|
||||
edit.offset < index -> delta += edit.insert.length - (index - edit.offset)
|
||||
}
|
||||
}
|
||||
return index + delta
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<OutgoingAttachment> = emptyList(),
|
||||
val suggestions: List<ContactSuggestion> = 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<OutgoingAttachment>) =
|
||||
_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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -17,7 +16,6 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -26,17 +24,21 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewModel = hiltViewModel()) {
|
||||
fun AccountSettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onManageSignatures: () -> Unit,
|
||||
viewModel: AccountSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val account by viewModel.account.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val signature by viewModel.signature.collectAsStateWithLifecycle()
|
||||
val signatureCount by viewModel.signatureCount.collectAsStateWithLifecycle()
|
||||
val defaultSignatureName by viewModel.defaultSignatureName.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val fallbackTitle = stringResource(R.string.settings_account_title)
|
||||
|
||||
@@ -67,15 +69,14 @@ fun AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewMode
|
||||
checked = settings.signatureEnabled,
|
||||
onCheckedChange = viewModel::setSignatureEnabled,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = signature ?: "",
|
||||
onValueChange = viewModel::onSignatureChange,
|
||||
enabled = settings.signatureEnabled,
|
||||
label = { Text(stringResource(R.string.settings_signature_hint)) },
|
||||
minLines = 3,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
ClickRow(
|
||||
title = stringResource(R.string.settings_signatures_manage),
|
||||
subtitle = if (signatureCount == 0) {
|
||||
stringResource(R.string.settings_signatures_summary_none)
|
||||
} else {
|
||||
stringResource(R.string.settings_signatures_summary, signatureCount, defaultSignatureName)
|
||||
},
|
||||
onClick = onManageSignatures,
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
|
||||
@@ -5,15 +5,13 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
@@ -26,6 +24,7 @@ class AccountSettingsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
signatureRepository: SignatureRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val accountId: String =
|
||||
@@ -38,26 +37,20 @@ class AccountSettingsViewModel @Inject constructor(
|
||||
val settings: StateFlow<AccountSettings> = accountSettingsRepository.observe(accountId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AccountSettings(accountId))
|
||||
|
||||
// The signature text is edited locally (seeded once from persistence) so the field stays
|
||||
// responsive — a fully DB-driven value would lag each keystroke and jump the cursor.
|
||||
private val _signature = MutableStateFlow<String?>(null)
|
||||
val signature: StateFlow<String?> = _signature.asStateFlow()
|
||||
private val signaturesFlow = signatureRepository.observeForAccount(accountId)
|
||||
|
||||
val signatureCount: StateFlow<Int> = signaturesFlow
|
||||
.map { it.size }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
|
||||
|
||||
/** The name of the account's default signature (for the settings summary line), or "". */
|
||||
val defaultSignatureName: StateFlow<String> = signaturesFlow
|
||||
.map { list -> list.firstOrNull { it.isDefault }?.name ?: list.firstOrNull()?.name ?: "" }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "")
|
||||
|
||||
/** This account's notification channel id, for deep-linking into Android's system settings. */
|
||||
val notificationChannelId: String = MailNotifier.channelId(accountId)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val loaded = accountSettingsRepository.get(accountId).signature
|
||||
_signature.update { it ?: loaded } // don't clobber any text typed before the load returned
|
||||
}
|
||||
}
|
||||
|
||||
fun onSignatureChange(value: String) {
|
||||
_signature.value = value
|
||||
viewModelScope.launch { accountSettingsRepository.setSignature(accountId, value) }
|
||||
}
|
||||
|
||||
fun setSignatureEnabled(value: Boolean) {
|
||||
viewModelScope.launch { accountSettingsRepository.setSignatureEnabled(accountId, value) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
import org.libremail.ui.compose.RichTextBodyField
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SignatureEditScreen(onBack: () -> Unit, viewModel: SignatureEditViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (viewModel.isNew) R.string.signature_new_title else R.string.signature_edit_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = { viewModel.save(onBack) }) {
|
||||
Text(stringResource(R.string.signature_save))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = viewModel::onNameChange,
|
||||
label = { Text(stringResource(R.string.signature_name)) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Reuse the compose editor so signatures are rich in exactly the same way message bodies are.
|
||||
if (state.loaded) {
|
||||
RichTextBodyField(
|
||||
body = state.body,
|
||||
bodyHtml = state.bodyHtml,
|
||||
onBodyChange = viewModel::onBodyChange,
|
||||
label = stringResource(R.string.signature_content),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SignatureEditUiState(
|
||||
val name: String = "",
|
||||
val body: String = "",
|
||||
val bodyHtml: String? = null,
|
||||
val loaded: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SignatureEditViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val signatureRepository: SignatureRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURE_EDIT_ARG_ACCOUNT])
|
||||
private val signatureId: String? =
|
||||
savedStateHandle.get<String>(Routes.SIGNATURE_EDIT_ARG_ID)?.takeIf { it.isNotBlank() }
|
||||
|
||||
val isNew: Boolean = signatureId == null
|
||||
|
||||
private val _state = MutableStateFlow(SignatureEditUiState(loaded = isNew))
|
||||
val state: StateFlow<SignatureEditUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
if (signatureId != null) {
|
||||
viewModelScope.launch {
|
||||
signatureRepository.get(signatureId)?.let { signature ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
name = signature.name,
|
||||
body = signature.plainText(),
|
||||
bodyHtml = signature.html.ifBlank { null },
|
||||
loaded = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNameChange(value: String) = _state.update { it.copy(name = value) }
|
||||
|
||||
fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) }
|
||||
|
||||
/** Persists the signature (create or update), then invokes [onSaved]. */
|
||||
fun save(onSaved: () -> Unit) {
|
||||
val s = _state.value
|
||||
val name = s.name.trim().ifBlank { DEFAULT_NAME }
|
||||
// Store real HTML so the signature round-trips; derive it from the plaintext when unformatted.
|
||||
val html = s.bodyHtml ?: RichTextHtml.toHtml(RichTextContent(s.body))
|
||||
viewModelScope.launch {
|
||||
if (signatureId == null) {
|
||||
signatureRepository.create(accountId, name, html)
|
||||
} else {
|
||||
signatureRepository.update(signatureId, name, html)
|
||||
}
|
||||
onSaved()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_NAME = "Signature"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.libremail.R
|
||||
import org.libremail.domain.model.Signature
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SignaturesScreen(
|
||||
onBack: () -> Unit,
|
||||
onEdit: (signatureId: String) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
viewModel: SignaturesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val signatures by viewModel.signatures.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.signatures_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onAdd) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.signatures_add))
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
if (signatures.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringResource(R.string.signatures_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
|
||||
items(signatures, key = { it.id }) { signature ->
|
||||
SignatureRow(
|
||||
signature = signature,
|
||||
onClick = { onEdit(signature.id) },
|
||||
onMakeDefault = { viewModel.setDefault(signature.id) },
|
||||
onDelete = { viewModel.delete(signature.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignatureRow(signature: Signature, onClick: () -> Unit, onMakeDefault: () -> Unit, onDelete: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = signature.isDefault,
|
||||
onClick = onMakeDefault,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
signature.name.ifBlank { stringResource(R.string.signature_content) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val preview = signature.plainText().replace('\n', ' ').trim()
|
||||
if (preview.isNotEmpty()) {
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (signature.isDefault) {
|
||||
Text(
|
||||
stringResource(R.string.signature_default_badge),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.signature_delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Signature
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SignaturesViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val signatureRepository: SignatureRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURES_ARG_ACCOUNT])
|
||||
|
||||
val signatures: StateFlow<List<Signature>> = signatureRepository.observeForAccount(accountId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun setDefault(id: String) {
|
||||
viewModelScope.launch { signatureRepository.setDefault(accountId, id) }
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
viewModelScope.launch { signatureRepository.delete(id) }
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,19 @@
|
||||
<string name="compose_attach">Attach file</string>
|
||||
<string name="compose_attachment_remove">Remove attachment</string>
|
||||
|
||||
<!-- Compose formatting toolbar -->
|
||||
<string name="format_bold">Bold</string>
|
||||
<string name="format_italic">Italic</string>
|
||||
<string name="format_underline">Underline</string>
|
||||
<string name="format_bullet_list">Bulleted list</string>
|
||||
<string name="format_numbered_list">Numbered list</string>
|
||||
<string name="format_quote">Block quote</string>
|
||||
<string name="format_link">Insert link</string>
|
||||
<string name="format_link_title">Insert link</string>
|
||||
<string name="format_link_url">Link address</string>
|
||||
<string name="format_link_apply">Link</string>
|
||||
<string name="format_link_needs_selection">Select some text first, then add a link.</string>
|
||||
|
||||
<!-- Drafts -->
|
||||
<string name="drafts_title">Drafts</string>
|
||||
<string name="drafts_empty">No drafts</string>
|
||||
@@ -146,8 +159,25 @@
|
||||
<!-- Per-account settings -->
|
||||
<string name="settings_account_title">Account</string>
|
||||
<string name="settings_signature">Signature</string>
|
||||
<string name="settings_signature_enable">Append signature</string>
|
||||
<string name="settings_signature_enable">Append signature automatically</string>
|
||||
<string name="settings_signature_hint">Your signature</string>
|
||||
<string name="settings_signatures_manage">Manage signatures</string>
|
||||
<string name="settings_signatures_summary_none">No signatures yet</string>
|
||||
<string name="settings_signatures_summary">%1$d signature(s), default: %2$s</string>
|
||||
|
||||
<!-- Signatures management -->
|
||||
<string name="signatures_title">Signatures</string>
|
||||
<string name="signatures_empty">No signatures yet. Add one to append it to your messages.</string>
|
||||
<string name="signatures_add">Add signature</string>
|
||||
<string name="signature_new_title">New signature</string>
|
||||
<string name="signature_edit_title">Edit signature</string>
|
||||
<string name="signature_name">Name</string>
|
||||
<string name="signature_content">Signature</string>
|
||||
<string name="signature_save">Save</string>
|
||||
<string name="signature_delete">Delete signature</string>
|
||||
<string name="signature_default">Default</string>
|
||||
<string name="signature_make_default">Set as default</string>
|
||||
<string name="signature_default_badge">Default</string>
|
||||
<string name="settings_account_notifications">Notifications</string>
|
||||
<string name="settings_account_new_mail">New mail notifications</string>
|
||||
<string name="settings_account_new_mail_summary">Show notifications for this account</string>
|
||||
|
||||
@@ -105,4 +105,26 @@ class ReplyBuilderTest {
|
||||
assertTrue(result.body.contains("Hello"), "body=${result.body}")
|
||||
assertTrue(result.body.contains("there"), "body=${result.body}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `html original is quoted into a blockquote without leaking original tags`() {
|
||||
val result = ReplyBuilder.build(
|
||||
context(body = "<p>Hello <b>there</b></p>", isHtml = true),
|
||||
ReplyMode.REPLY,
|
||||
"me@example.org",
|
||||
)
|
||||
|
||||
// The HTML alternative wraps the (tag-stripped) original in a blockquote — never raw tags.
|
||||
assertTrue(result.bodyHtml.contains("<blockquote>"), "html=${result.bodyHtml}")
|
||||
assertTrue(result.bodyHtml.contains("Hello there"), "html=${result.bodyHtml}")
|
||||
assertFalse(result.bodyHtml.contains("<p>Hello"), "html=${result.bodyHtml}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plaintext reply also carries an html blockquote alternative`() {
|
||||
val result = ReplyBuilder.build(context(body = "First line\nSecond line"), ReplyMode.REPLY, "me@example.org")
|
||||
|
||||
assertTrue(result.bodyHtml.contains("<blockquote>"), "html=${result.bodyHtml}")
|
||||
assertTrue(result.bodyHtml.contains("First line"), "html=${result.bodyHtml}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data
|
||||
|
||||
import org.junit.Test
|
||||
import org.libremail.domain.model.Signature
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SignatureBlockTest {
|
||||
|
||||
private fun signature(html: String) =
|
||||
Signature(id = "s", accountId = "a", name = "Sig", html = html, isDefault = true)
|
||||
|
||||
@Test
|
||||
fun `plain form opens with the RFC 3676 delimiter`() {
|
||||
val block = SignatureBlock.of(signature("Cheers, Alice"))
|
||||
assertEquals("\n\n-- \nCheers, Alice", block.plain)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `html form parses back to exactly the plain form`() {
|
||||
val block = SignatureBlock.of(signature("Cheers, Alice"))
|
||||
assertEquals(block.plain, RichTextHtml.fromHtml(block.html).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rich signature keeps its formatting in the html form`() {
|
||||
val block = SignatureBlock.of(signature("Cheers, <b>Alice</b>"))
|
||||
assertTrue(block.html.endsWith("Cheers, <b>Alice</b>"), block.html)
|
||||
assertEquals("\n\n-- \nCheers, Alice", block.plain)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null or blank signature yields the empty block`() {
|
||||
assertTrue(SignatureBlock.of(null).isEmpty)
|
||||
assertTrue(SignatureBlock.of(signature("")).isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 = "<p>Hello <b>there</b></p>",
|
||||
)
|
||||
|
||||
val restored = draft.toEntity().toDomain()
|
||||
|
||||
assertEquals("<p>Hello <b>there</b></p>", restored.bodyHtml)
|
||||
assertEquals("Hello", restored.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plaintext draft keeps a null html body`() {
|
||||
val draft = Draft("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 = "<p>Hi</p>",
|
||||
)
|
||||
|
||||
assertEquals("<p>Hi</p>", entity.toDomain().bodyHtml)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.data.sync.MailConnectionFactory
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.FolderRole
|
||||
@@ -54,6 +55,7 @@ class MailRepositoryImplTest {
|
||||
private val connectionFactory = mockk<MailConnectionFactory>()
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
private val accountSettingsRepository = mockk<AccountSettingsRepository>()
|
||||
private val signatureRepository = mockk<SignatureRepository>()
|
||||
private val repository = MailRepositoryImpl(
|
||||
context = context,
|
||||
messageDao = messageDao,
|
||||
@@ -66,6 +68,7 @@ class MailRepositoryImplTest {
|
||||
connectionFactory = connectionFactory,
|
||||
sendScheduler = mockk(),
|
||||
accountSettingsRepository = accountSettingsRepository,
|
||||
signatureRepository = signatureRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -202,6 +205,7 @@ class MailRepositoryImplTest {
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
|
||||
coEvery { signatureRepository.getDefault(any()) } returns null
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { imapClient.fetchForReply(any(), "INBOX", "2") } returns ReplyContext(
|
||||
fromEmail = "boss@example.org",
|
||||
@@ -220,6 +224,42 @@ class MailRepositoryImplTest {
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals("boss@example.org", draft.captured.toAddresses)
|
||||
assertEquals("Re: Plan", draft.captured.subject)
|
||||
// The reply carries an HTML alternative with the quote rendered as a blockquote.
|
||||
assertTrue(draft.captured.bodyHtml?.contains("<blockquote>") == true, "html=${draft.captured.bodyHtml}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildReplyDraft bakes the account default signature above the quote`() = runTest {
|
||||
val id = "acct:INBOX:3"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
|
||||
coEvery { signatureRepository.getDefault("acct") } returns org.libremail.domain.model.Signature(
|
||||
id = "acct:sig",
|
||||
accountId = "acct",
|
||||
name = "Signature",
|
||||
html = "Regards, Ada",
|
||||
isDefault = true,
|
||||
)
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { imapClient.fetchForReply(any(), "INBOX", "3") } returns ReplyContext(
|
||||
fromEmail = "boss@example.org",
|
||||
toRecipients = listOf("ada@example.org"),
|
||||
ccRecipients = emptyList(),
|
||||
subject = "Plan",
|
||||
sentDateMillis = 0L,
|
||||
body = "Original",
|
||||
isHtml = false,
|
||||
)
|
||||
val draft = slot<DraftEntity>()
|
||||
coEvery { draftDao.upsert(capture(draft)) } just Runs
|
||||
|
||||
repository.buildReplyDraft(id, ReplyMode.REPLY)
|
||||
|
||||
val body = draft.captured.body
|
||||
// Signature is placed before (above) the quoted original.
|
||||
assertTrue(body.contains("Regards, Ada"), "body=$body")
|
||||
assertTrue(body.indexOf("Regards, Ada") < body.indexOf("> Original"), "body=$body")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.settings
|
||||
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.libremail.data.local.dao.SignatureDao
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SignatureRepositoryTest {
|
||||
|
||||
private val dao = mockk<SignatureDao>(relaxed = true)
|
||||
private val repository = SignatureRepository(dao)
|
||||
|
||||
private fun entity(id: String, isDefault: Boolean) =
|
||||
SignatureEntity(id, accountId = "acct", name = "N", contentHtml = "<p>x</p>", isDefault = isDefault)
|
||||
|
||||
@Test
|
||||
fun `the first signature for an account becomes its default`() = runTest {
|
||||
coEvery { dao.countForAccount("acct") } returns 0
|
||||
val saved = slot<SignatureEntity>()
|
||||
coEvery { dao.upsert(capture(saved)) } just Runs
|
||||
|
||||
repository.create("acct", "Work", "<p>hi</p>")
|
||||
|
||||
assertTrue(saved.captured.isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `later signatures are not made default`() = runTest {
|
||||
coEvery { dao.countForAccount("acct") } returns 2
|
||||
val saved = slot<SignatureEntity>()
|
||||
coEvery { dao.upsert(capture(saved)) } just Runs
|
||||
|
||||
repository.create("acct", "Personal", "<p>hey</p>")
|
||||
|
||||
assertFalse(saved.captured.isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the default promotes the first remaining signature`() = runTest {
|
||||
coEvery { dao.getById("s1") } returns entity("s1", isDefault = true)
|
||||
coEvery { dao.firstForAccount("acct") } returns entity("s2", isDefault = false)
|
||||
|
||||
repository.delete("s1")
|
||||
|
||||
coVerify { dao.delete("s1") }
|
||||
coVerify { dao.markDefault("s2") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting a non-default signature promotes nothing`() = runTest {
|
||||
coEvery { dao.getById("s2") } returns entity("s2", isDefault = false)
|
||||
|
||||
repository.delete("s2")
|
||||
|
||||
coVerify { dao.delete("s2") }
|
||||
coVerify(exactly = 0) { dao.firstForAccount(any()) }
|
||||
coVerify(exactly = 0) { dao.markDefault(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setDefault delegates to the dao's atomic swap`() = runTest {
|
||||
repository.setDefault("acct", "s1")
|
||||
coVerify { dao.setDefault("acct", "s1") }
|
||||
}
|
||||
}
|
||||
@@ -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 = "<p>Hello <b>world</b></p>",
|
||||
)
|
||||
val body = JSONObject(buildSendMailPayload(message, emptyList()))
|
||||
.getJSONObject("message").getJSONObject("body")
|
||||
assertEquals("HTML", body.getString("contentType"))
|
||||
assertEquals("<p>Hello <b>world</b></p>", 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(
|
||||
|
||||
@@ -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("<p>Hello <b>world</b></p>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `br and paragraphs become line breaks`() {
|
||||
assertEquals("Line one\nLine two", HtmlToText.convert("Line one<br>Line two"))
|
||||
assertEquals("A\n\nB", HtmlToText.convert("<p>A</p><p>B</p>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list items gain bullet markers`() {
|
||||
assertEquals("• One\n• Two", HtmlToText.convert("<ul><li>One</li><li>Two</li></ul>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes entities and drops script and style content`() {
|
||||
assertEquals("Tom & Jerry", HtmlToText.convert("Tom & Jerry<style>.x{color:red}</style>"))
|
||||
val converted = HtmlToText.convert("<script>alert('x')</script>Safe")
|
||||
assertEquals("Safe", converted)
|
||||
assertFalse(converted.contains("alert"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapses excess whitespace`() {
|
||||
val converted = HtmlToText.convert("<p>Hello there</p>\n\n\n<p>bye</p>")
|
||||
assertTrue(converted.contains("Hello there"), converted)
|
||||
assertFalse(converted.contains("Hello there"), converted)
|
||||
}
|
||||
}
|
||||
@@ -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 = "<p>Hello <b>world</b></p>",
|
||||
),
|
||||
)
|
||||
|
||||
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("<b>world</b>"), "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 = "<p><b>Body</b></p>",
|
||||
),
|
||||
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("<b>Body</b>"), "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") }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("<p>Hello<br>World</p>", 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("<p><b>bold</b> <i>italic</i> <u>under</u></p>", 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("<p><b>a</b><b><i>bc</i></b><i>d</i></p>", RichTextHtml.toHtml(content))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bulleted and numbered lists and quotes map to block tags`() {
|
||||
assertEquals("<ul><li>Milk</li><li>Eggs</li></ul>", RichTextHtml.toHtml(RichTextContent("• Milk\n• Eggs")))
|
||||
assertEquals("<ol><li>One</li><li>Two</li></ol>", RichTextHtml.toHtml(RichTextContent("1. One\n2. Two")))
|
||||
assertEquals("<blockquote>a<br>b</blockquote>", RichTextHtml.toHtml(RichTextContent("> a\n> b")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `links render as anchors and text is html escaped`() {
|
||||
val content = RichTextContent("a<b>&c", links = listOf(RichLink(0, 1, "http://x?y=1&z")))
|
||||
val html = RichTextHtml.toHtml(content)
|
||||
assertTrue(html.contains("<a href=\"http://x?y=1&z\">a</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<b>&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("<ul>\n <li>One</li>\n <li><strong>Two</strong></li>\n</ul>")
|
||||
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("")))
|
||||
}
|
||||
}
|
||||
@@ -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<Account> = listOf(alice),
|
||||
savedState: SavedStateHandle = SavedStateHandle(),
|
||||
signatures: Map<String, AccountSettings> = emptyMap(),
|
||||
signatures: Map<String, Signature> = emptyMap(),
|
||||
settings: Map<String, AccountSettings> = emptyMap(),
|
||||
mailRepository: MailRepository = mockk(relaxed = true),
|
||||
): ComposeViewModel {
|
||||
val accountRepository = mockk<AccountRepository>()
|
||||
@@ -59,22 +69,36 @@ class ComposeViewModelTest {
|
||||
val accountSettingsRepository = mockk<AccountSettingsRepository>()
|
||||
coEvery { accountSettingsRepository.get(any()) } answers {
|
||||
val id = firstArg<String>()
|
||||
signatures[id] ?: AccountSettings(id)
|
||||
settings[id] ?: AccountSettings(id)
|
||||
}
|
||||
val signatureRepository = mockk<SignatureRepository>()
|
||||
coEvery { signatureRepository.getDefault(any()) } answers { signatures[firstArg<String>()] }
|
||||
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, <b>Alice</b>")))
|
||||
|
||||
assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body)
|
||||
val html = vm.state.value.bodyHtml
|
||||
assertTrue(html != null && html.contains("<b>Alice</b>"), "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<MailRepository>(relaxed = true)
|
||||
@@ -104,14 +138,32 @@ class ComposeViewModelTest {
|
||||
subject = "Hi",
|
||||
body = "Draft body",
|
||||
updatedAt = 0L,
|
||||
bodyHtml = "<p>Draft <b>body</b></p>",
|
||||
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("<p>Draft <b>body</b></p>", vm.state.value.bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send carries the HTML body through to the outgoing message`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val sent = slot<OutgoingMessage>()
|
||||
coEvery { mailRepository.sendMessage(capture(sent)) } returns Result.success(Unit)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("Hello", "<p>Hello <i>world</i></p>")
|
||||
vm.send()
|
||||
|
||||
assertEquals("<p>Hello <i>world</i></p>", sent.captured.bodyHtml)
|
||||
assertEquals("Hello", sent.captured.body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user