From a6ec00d2030a2c0b3664a7b0aecba936fb956869 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 15:51:16 -0500 Subject: [PATCH 1/8] fix(notifications): open the tapped message from a new-mail notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a new-mail notification only brought the app to the foreground: the content PendingIntent was a bare launch intent shared by every notification, and nothing on the activity side handled a message target. Per-message notifications now carry an explicit open-message intent — action + id extra + a per-message data URI, so each message keeps its own PendingIntent under filterEquals instead of all collapsing onto one FLAG_UPDATE_CURRENT entry. MainActivity parses the id on fresh launch and in onNewIntent and hands it to the NavHost as pending state (the pendingCompose handoff pattern) to navigate to the reader. The group summary keeps the plain open-the-app intent. Fixes #56 Co-Authored-By: Claude Fable 5 --- .../notifications/NotificationIntentsTest.kt | 51 +++++++++++++++++++ .../main/kotlin/org/libremail/MainActivity.kt | 15 +++++- .../libremail/notifications/MailNotifier.kt | 16 ++++-- .../notifications/NotificationIntents.kt | 33 ++++++++++++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 11 ++++ 5 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt create mode 100644 app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt diff --git a/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt b/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt new file mode 100644 index 0000000..8a546ff --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.notifications + +import android.content.Intent +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Locks in the notification deep-link contract: a message id round-trips build → parse, and intents + * for different messages are distinct under [Intent.filterEquals] — the identity PendingIntent keys + * on — so per-message notifications never collapse onto one shared PendingIntent. + */ +@RunWith(AndroidJUnit4::class) +class NotificationIntentsTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun message_id_round_trips_through_the_intent() { + val id = "imap:user@example.com:INBOX:42" + assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id))) + } + + @Test + fun uri_hostile_ids_round_trip() { + val id = "imap:user@example.com:[Gmail]/All Mail:7?&%#" + assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id))) + } + + @Test + fun other_intents_carry_no_message_id() { + assertNull(NotificationIntents.messageId(null)) + assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_MAIN))) + assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:a@b.c")))) + } + + @Test + fun intents_for_different_messages_are_distinct_pending_intent_keys() { + val first = NotificationIntents.openMessage(context, "imap:a@b:INBOX:1") + val second = NotificationIntents.openMessage(context, "imap:a@b:INBOX:2") + assertFalse(first.filterEquals(second)) + assertTrue(first.filterEquals(NotificationIntents.openMessage(context, "imap:a@b:INBOX:1"))) + } +} diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index 0b1caa9..35a1514 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint import org.libremail.data.settings.SettingsRepository +import org.libremail.notifications.NotificationIntents import org.libremail.ui.LibreMailApp import org.libremail.ui.compose.ComposePrefill import org.libremail.ui.compose.IntentComposeParser @@ -38,6 +39,12 @@ class MainActivity : ComponentActivity() { */ private val pendingCompose = mutableStateOf(null) + /** + * The message a tapped new-mail notification asks to open, consumed once by the NavHost. Compose + * state for the same reason as [pendingCompose]. + */ + private val pendingOpenMessageId = mutableStateOf(null) + override fun onStart() { super.onStart() // Foreground: recover IDLE push if a background start was previously blocked. @@ -47,10 +54,11 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - // Only on a fresh launch — on a config-change recreation the NavHost restores the compose - // destination itself, so re-parsing the (unchanged) intent would open a duplicate. + // Only on a fresh launch — on a config-change recreation the NavHost restores the compose / + // reader destination itself, so re-parsing the (unchanged) intent would open a duplicate. if (savedInstanceState == null) { pendingCompose.value = IntentComposeParser.parse(intent) + pendingOpenMessageId.value = NotificationIntents.messageId(intent) } setContent { val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true) @@ -59,6 +67,8 @@ class MainActivity : ComponentActivity() { LibreMailApp( pendingCompose = pendingCompose.value, onComposeHandled = { pendingCompose.value = null }, + pendingOpenMessageId = pendingOpenMessageId.value, + onOpenMessageHandled = { pendingOpenMessageId.value = null }, ) } } @@ -68,6 +78,7 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) IntentComposeParser.parse(intent)?.let { pendingCompose.value = it } + NotificationIntents.messageId(intent)?.let { pendingOpenMessageId.value = it } } } diff --git a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt index 0278886..529b6aa 100644 --- a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt +++ b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt @@ -35,7 +35,6 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context: if (messages.isEmpty() || !hasPermission()) return ensureAccountChannel(account) val manager = NotificationManagerCompat.from(context) - val contentIntent = contentIntent() val channelId = channelId(account.id) val groupKey = groupKey(account.id) val summaryId = summaryId(account.id) @@ -52,7 +51,7 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context: .setAutoCancel(true) .setOnlyAlertOnce(true) .setGroup(groupKey) - .setContentIntent(contentIntent) + .setContentIntent(openMessageIntent(message.id)) .build() manager.notify(notificationId(message.id, summaryId), notification) } @@ -72,7 +71,7 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context: .setOnlyAlertOnce(true) .setGroup(groupKey) .setGroupSummary(true) - .setContentIntent(contentIntent) + .setContentIntent(openAppIntent()) .build() manager.notify(summaryId, summary) } @@ -105,7 +104,16 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context: manager.deleteNotificationChannelGroup(accountId) } - private fun contentIntent(): PendingIntent { + /** Opens the tapped message's reader (each message's intent is distinct — see [NotificationIntents]). */ + private fun openMessageIntent(messageId: String): PendingIntent = PendingIntent.getActivity( + context, + 0, + NotificationIntents.openMessage(context, messageId), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + /** Just brings the app to the foreground — used by the group summary, which has no single message. */ + private fun openAppIntent(): PendingIntent { val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP } diff --git a/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt b/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt new file mode 100644 index 0000000..a27a40b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.notifications + +import android.content.Context +import android.content.Intent +import android.net.Uri +import org.libremail.MainActivity + +/** + * Builds and parses the intent behind a tapped per-message new-mail notification, keeping both sides + * of the contract ([MailNotifier] builds, MainActivity parses) in one place. + * + * The per-message `data` URI is load-bearing: PendingIntent identity ignores extras, so without a + * distinct URI every message's notification would collapse onto one FLAG_UPDATE_CURRENT PendingIntent + * and always open the most-recently-notified message. The intent is explicit (component set), so the + * private scheme needs no manifest intent-filter and adds no exported surface. + */ +object NotificationIntents { + + private const val ACTION_OPEN_MESSAGE = "org.libremail.action.OPEN_MESSAGE" + private const val EXTRA_MESSAGE_ID = "org.libremail.extra.MESSAGE_ID" + + fun openMessage(context: Context, messageId: String): Intent = Intent(context, MainActivity::class.java).apply { + action = ACTION_OPEN_MESSAGE + data = Uri.parse("libremail://message/${Uri.encode(messageId)}") + putExtra(EXTRA_MESSAGE_ID, messageId) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + /** The tapped message's id, or null for any other intent (launcher, mailto:, share, …). */ + fun messageId(intent: Intent?): String? = + intent?.takeIf { it.action == ACTION_OPEN_MESSAGE }?.getStringExtra(EXTRA_MESSAGE_ID) +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 6d44535..0e7882c 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -55,6 +55,8 @@ fun LibreMailApp( startupViewModel: StartupReportViewModel = hiltViewModel(), pendingCompose: ComposePrefill? = null, onComposeHandled: () -> Unit = {}, + pendingOpenMessageId: String? = null, + onOpenMessageHandled: () -> Unit = {}, ) { val startDestination by appViewModel.startDestination.collectAsStateWithLifecycle() // Hold (render nothing) until the account count is known, so a cold start never flashes the @@ -79,6 +81,15 @@ fun LibreMailApp( onComposeHandled() } + // A tapped new-mail notification opens that message's reader on top of the current stack, so back + // lands where the user was (the mailbox on a cold start). If the account vanished in the meantime + // (start = onboarding) the request is consumed without navigating. + LaunchedEffect(pendingOpenMessageId) { + val messageId = pendingOpenMessageId ?: return@LaunchedEffect + if (start != Routes.ONBOARDING) navController.navigate(Routes.reader(messageId)) + onOpenMessageHandled() + } + NavHost( navController = navController, startDestination = start, From f99c2df85f9414f1ac268adfc72d12cb798a2e93 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 16:20:07 -0500 Subject: [PATCH 2/8] fix(compose): restore Bcc recipients when resuming a draft saveOrDeleteDraft persists the Bcc line, but the init-block restore never copied it back, so reopening a draft silently dropped its Bcc recipients (and re-saving then lost them for good). Co-Authored-By: Claude Fable 5 --- .../libremail/ui/compose/ComposeViewModel.kt | 1 + .../ui/compose/ComposeViewModelTest.kt | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 2ceff23..f010456 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -98,6 +98,7 @@ class ComposeViewModel @Inject constructor( it.copy( to = draft.to, cc = draft.cc, + bcc = draft.bcc, subject = draft.subject, body = draft.body, bodyHtml = draft.bodyHtml, diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt index da82df2..0eace28 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt @@ -153,6 +153,27 @@ class ComposeViewModelTest { assertEquals("

Draft body

", vm.state.value.bodyHtml) } + @Test + fun `resuming a draft restores the bcc recipients`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.getDraft("d1") } returns Draft( + id = "d1", + accountId = "imap:a", + to = "x@example.org", + cc = "", + bcc = "hidden@example.org", + subject = "Hi", + body = "Draft body", + updatedAt = 0L, + ) + val vm = viewModel( + savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")), + mailRepository = mailRepository, + ) + + assertEquals("hidden@example.org", vm.state.value.bcc) + } + @Test fun `send carries the HTML body through to the outgoing message`() = runTest(testDispatcher) { val mailRepository = mockk(relaxed = true) From 99ece7b6d73b9276ad0192f8e203b3379bf7d568 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 16:20:23 -0500 Subject: [PATCH 3/8] feat(compose): collapse Cc/Bcc into expandable links under the To field The Cc and Bcc fields now start collapsed into small left-aligned link buttons under the To box, freeing about two field heights of vertical space for the message body. Tapping a link expands it into the regular input field and focuses it; a field also expands on its own when it already carries recipients (reply-all/mailto prefill, resumed drafts) and never re-collapses once shown, so it cannot vanish mid-edit. The expansion state lives in the UI via rememberSaveable and survives rotation. Moving the Bcc field also gives it the medium shape every sibling field already had. Co-Authored-By: Claude Fable 5 --- .../libremail/ui/compose/ComposeScreenTest.kt | 28 +++++ .../org/libremail/ui/compose/ComposeScreen.kt | 102 ++++++++++++++---- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt index 97777c4..c6079e9 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt @@ -5,6 +5,8 @@ import android.Manifest import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -118,4 +120,30 @@ class ComposeScreenTest { composeTestRule.waitUntil(timeoutMillis = 5_000) { closed } } + + @Test + fun ccAndBcc_startCollapsed_expandViaLinksAndCarryThroughSend() { + val mailRepository = FakeMailRepository() + setContent(mailRepository) + + // Collapsed: the Cc/Bcc labels exist only as links, not as editable fields. + editableField(R.string.compose_cc).assertDoesNotExist() + editableField(R.string.compose_bcc).assertDoesNotExist() + + composeTestRule.onNodeWithText(string(R.string.compose_cc)).performClick() + editableField(R.string.compose_cc).performTextInput("cc@example.com") + composeTestRule.onNodeWithText(string(R.string.compose_bcc)).performClick() + editableField(R.string.compose_bcc).performTextInput("bcc@example.com") + + composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com") + composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick() + + composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() } + val sent = mailRepository.sentMessages.single() + assertEquals("cc@example.com", sent.cc) + assertEquals("bcc@example.com", sent.bcc) + } + + /** Matches the editable field labelled [labelRes] but not the collapsed Cc/Bcc link buttons. */ + private fun editableField(labelRes: Int) = composeTestRule.onNode(hasText(string(labelRes)) and hasSetTextAction()) } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 3acb89f..4d83df2 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -10,6 +10,7 @@ import android.provider.OpenableColumns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -50,9 +51,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType @@ -152,24 +156,11 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode SuggestionList(state.suggestions, viewModel::pickSuggestion) } - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = state.cc, - onValueChange = viewModel::onCcChange, - label = { Text(stringResource(R.string.compose_cc)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = state.bcc, - onValueChange = viewModel::onBccChange, - label = { Text(stringResource(R.string.compose_bcc)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - modifier = Modifier.fillMaxWidth(), + CcBccFields( + cc = state.cc, + onCcChange = viewModel::onCcChange, + bcc = state.bcc, + onBccChange = viewModel::onBccChange, ) Spacer(Modifier.height(8.dp)) OutlinedTextField( @@ -245,6 +236,81 @@ private fun FromRow(accounts: List, selectedId: String?, onSelect: (Str } } +/** + * Cc/Bcc start collapsed into link buttons under the To box so the body gets the vertical space. + * A field expands when its link is tapped (taking focus), or by itself once it carries recipients + * (reply-all/mailto prefill, a resumed draft). Once shown, a field never re-collapses — emptying + * it mid-edit must not make it vanish. + */ +@Composable +private fun CcBccFields(cc: String, onCcChange: (String) -> Unit, bcc: String, onBccChange: (String) -> Unit) { + var ccShown by rememberSaveable { mutableStateOf(cc.isNotBlank()) } + var bccShown by rememberSaveable { mutableStateOf(bcc.isNotBlank()) } + LaunchedEffect(cc, bcc) { + if (cc.isNotBlank()) ccShown = true + if (bcc.isNotBlank()) bccShown = true + } + val ccFocus = remember { FocusRequester() } + val bccFocus = remember { FocusRequester() } + // Deliberately not saveable: only a link tap moves focus, never rotation or a loading draft. + var pendingFocus by remember { mutableStateOf(null) } + LaunchedEffect(pendingFocus) { + pendingFocus?.requestFocus() + pendingFocus = null + } + + if (ccShown) { + Spacer(Modifier.height(8.dp)) + RecipientField(cc, onCcChange, R.string.compose_cc, ccFocus) + } + if (bccShown) { + Spacer(Modifier.height(8.dp)) + RecipientField(bcc, onBccChange, R.string.compose_bcc, bccFocus) + } + if (!ccShown || !bccShown) { + Row { + if (!ccShown) { + TextButton( + onClick = { + ccShown = true + pendingFocus = ccFocus + }, + ) { + Text(stringResource(R.string.compose_cc)) + } + } + if (!bccShown) { + TextButton( + onClick = { + bccShown = true + pendingFocus = bccFocus + }, + ) { + Text(stringResource(R.string.compose_bcc)) + } + } + } + } +} + +@Composable +private fun RecipientField( + value: String, + onValueChange: (String) -> Unit, + @StringRes labelRes: Int, + focusRequester: FocusRequester, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(stringResource(labelRes)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AttachmentsSection( From 2cefc4751b12cab58231503069552dcedf82b0ee Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 16:54:25 -0500 Subject: [PATCH 4/8] feat(richtext): parameterized styles, alignment/image/base-style channels, HTML round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RichStyle becomes a sealed interface (Bold/Italic/Underline/Strikethrough + FontFamily/FontSize/FontColor/Highlight); RichTextContent gains alignments, images, and baseStyle channels. The HTML serializer emits merged runs, text-align on

/

  • (splitting merged paragraphs at alignment boundaries), over the visible [image: name] token, and a single outer
    for the base style. The parser is a faithful inverse and additionally tolerates /, px font sizes, #rgb colors, and start/end alignment synonyms; unknown CSS is ignored without dropping text. hasFormatting() covers every new channel so ComposeViewModel.normalizedHtml() never silently drops serialized formatting. The editor carries parameterized style identity via string annotations (libremail:style / libremail:image), maps alignment onto ParagraphStyle ranges, holds baseStyle in separate field state, and RichTextEditing.toggleStyle now replaces a different value of the same kind while styleAt() answers "current value over the selection" for pickers. ColorSwatchRow is added for the upcoming color/highlight dialogs. No UI change. Closes #70 Co-Authored-By: Claude Fable 5 --- .../kotlin/org/libremail/richtext/RichText.kt | 359 +++------------- .../org/libremail/richtext/RichTextEditing.kt | 76 +++- .../org/libremail/richtext/RichTextHtml.kt | 212 ++++++++++ .../libremail/richtext/RichTextHtmlParser.kt | 387 ++++++++++++++++++ .../libremail/ui/compose/RichTextEditor.kt | 232 +++++++++-- .../ui/compose/format/ColorSwatch.kt | 5 + .../ui/compose/format/ColorSwatchRow.kt | 89 ++++ app/src/main/res/values/strings.xml | 1 + .../libremail/richtext/RichTextEditingTest.kt | 85 +++- .../libremail/richtext/RichTextHtmlTest.kt | 184 ++++++++- 10 files changed, 1267 insertions(+), 363 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/richtext/RichTextHtml.kt create mode 100644 app/src/main/kotlin/org/libremail/richtext/RichTextHtmlParser.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/compose/format/ColorSwatch.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/compose/format/ColorSwatchRow.kt diff --git a/app/src/main/kotlin/org/libremail/richtext/RichText.kt b/app/src/main/kotlin/org/libremail/richtext/RichText.kt index 923ee13..652ca8d 100644 --- a/app/src/main/kotlin/org/libremail/richtext/RichText.kt +++ b/app/src/main/kotlin/org/libremail/richtext/RichText.kt @@ -1,8 +1,33 @@ // 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 } +/** + * Inline character styles the compose editor supports. The simple toggles are singletons; the + * parameterized styles carry their value, and a well-formed [RichTextContent] never overlaps two + * values of the same kind (editing ops replace the old value instead of stacking a second one). + */ +sealed interface RichStyle { + data object Bold : RichStyle + + data object Italic : RichStyle + + data object Underline : RichStyle + + /** Struck-through text: serialized as ``, also parsed from ``/``. */ + data object Strikethrough : RichStyle + + /** A CSS font-family stack (e.g. `"Liberation Serif", serif`), serialized verbatim. */ + data class FontFamily(val css: String) : RichStyle + + /** Font size in points; parsed from `pt` or `px` (px convert at 3/4 pt per px, rounded). */ + data class FontSize(val pt: Int) : RichStyle + + /** Text color as ARGB; serialized as `#rrggbb`, so only opaque colors round-trip. */ + data class FontColor(val argb: Int) : RichStyle + + /** Background highlight as ARGB; serialized as `#rrggbb`, so only opaque colors round-trip. */ + data class Highlight(val argb: Int) : RichStyle +} /** 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) @@ -10,8 +35,32 @@ 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) +/** Paragraph alignment (START is the writing-direction default). */ +enum class RichAlign { START, CENTER, END } + /** - * The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links]. + * Paragraph alignment over the half-open range [[start], [end]) of the plain text. Ranges cover + * whole lines (including any block marker), and adjacent same-aligned lines canonically share one + * range — [RichTextHtml.fromHtml] always returns that merged form. + */ +data class RichAlignment(val start: Int, val end: Int, val align: RichAlign) + +/** + * An inline image attached by Content-ID. [[start], [end]) covers a visible [imageToken] in the + * plain text (`[image: name]`), which keeps the text/plain rendering readable; the HTML form + * replaces the token with `name`. + */ +data class RichImage(val start: Int, val end: Int, val contentId: String, val name: String) + +/** A message-wide default font family and/or size, serialized as one outer `
    ` wrapper. */ +data class RichBaseStyle(val fontCss: String? = null, val fontSizePt: Int? = null) + +/** The visible plain-text placeholder for an inline image named [name]. */ +fun imageToken(name: String): String = "[image: $name]" + +/** + * The compose editor's internal rich-text model: plain [text] plus inline [spans], [links], + * paragraph [alignments], inline [images], and an optional message-wide [baseStyle]. * * 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 @@ -22,16 +71,24 @@ data class RichTextContent( val text: String = "", val spans: List = emptyList(), val links: List = emptyList(), + val alignments: List = emptyList(), + val images: List = emptyList(), + val baseStyle: RichBaseStyle? = null, ) { 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. + * a link, a block marker, paragraph alignment, an inline image, or a base style. 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 } + fun hasFormatting(): Boolean = spans.isNotEmpty() || + links.isNotEmpty() || + alignments.isNotEmpty() || + images.isNotEmpty() || + baseStyle != null || + text.lineSequence().any { lineMarker(it) != null } } /** Recognized block markers and the tags they map to. */ @@ -39,297 +96,9 @@ 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 — `