Merge branch 'main' into feat-fetch-all-retention
Resolve the build.gradle.kts conflict by keeping both additions: the androidTest Room-schema srcDir (this branch) and main's F-Droid dependenciesInfo block. Full fast gate + androidTest compile green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -151,6 +151,18 @@ The UI observes Room via `Flow`; a sync engine (Angus Mail over IMAP/SMTP, plus
|
||||
Graph for Outlook send) writes into Room, and an auth layer (AppAuth for OAuth and an Android
|
||||
Keystore-backed credential store for app passwords) handles sign-in.
|
||||
|
||||
## F-Droid
|
||||
|
||||
LibreMail is built to meet F-Droid's inclusion criteria: every dependency is
|
||||
FOSS-licensed, there are no Google Play Services / Firebase / proprietary SDKs, the
|
||||
build needs no `secrets.properties`, and there are **no anti-features to declare**
|
||||
(the privacy-sensitive extras above are all opt-in). The full dependency license
|
||||
audit, anti-feature review, and clean-room build verification live in
|
||||
[`docs/fdroid-compliance.md`](docs/fdroid-compliance.md); the store listing is under
|
||||
[`fastlane/metadata/android/`](fastlane/metadata/android/en-US), and
|
||||
[`docs/fdroid/org.libremail.app.yml`](docs/fdroid/org.libremail.app.yml) is the
|
||||
template for the eventual fdroiddata build recipe.
|
||||
|
||||
## License
|
||||
|
||||
LibreMail is licensed under the **GNU General Public License v3.0** — see
|
||||
|
||||
@@ -99,6 +99,16 @@ android {
|
||||
// Ship the exported Room schemas as androidTest assets so MigrationTestHelper can load them.
|
||||
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
|
||||
|
||||
// F-Droid compliance (issue #16): by default AGP embeds a "dependency info block" in the APK
|
||||
// signing block — a list of every dependency, encrypted so that ONLY Google Play can read it.
|
||||
// F-Droid's inclusion policy treats that opaque, Google-only blob as a blocker (it cannot be
|
||||
// verified from source and breaks reproducible builds), so keep it out of APKs and bundles.
|
||||
// See docs/fdroid-compliance.md.
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// Angus Mail / Jakarta Activation (added later) ship duplicate META-INF entries.
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package org.libremail.ui.compose
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
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
|
||||
@@ -16,6 +19,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -118,4 +122,52 @@ class ComposeScreenTest {
|
||||
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) { closed }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun send_whenBodyMentionsAttachmentWithoutOne_promptsBeforeSending() {
|
||||
val mailRepository = FakeMailRepository()
|
||||
setContent(mailRepository)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("I attached the report")
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
|
||||
|
||||
// "Yes" returns to composing: the dialog closes and nothing is sent.
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_yes)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertDoesNotExist()
|
||||
assertTrue(mailRepository.sentMessages.isEmpty())
|
||||
|
||||
// Sending again and answering "No" delivers the message as-is.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_no)).performClick()
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
|
||||
assertEquals("I attached the report", mailRepository.sentMessages.single().body)
|
||||
}
|
||||
|
||||
@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())
|
||||
}
|
||||
|
||||
@@ -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<ComposePrefill?>(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<String?>(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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 `<s>`, also parsed from `<del>`/`<strike>`. */
|
||||
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 `<img src="cid:contentId" alt="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 `<div style>` 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<RichSpan> = emptyList(),
|
||||
val links: List<RichLink> = emptyList(),
|
||||
val alignments: List<RichAlignment> = emptyList(),
|
||||
val images: List<RichImage> = 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,10 +96,6 @@ 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
|
||||
@@ -51,285 +104,18 @@ internal fun lineMarker(line: String): String? = when {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Merges spans with the exact same style value that touch or overlap, yielding the canonical
|
||||
* maximal-run form. Shared by the HTML parser and the editing ops so the two can never drift.
|
||||
*/
|
||||
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 = ""
|
||||
}
|
||||
internal fun mergeSameValueSpans(spans: List<RichSpan>): List<RichSpan> {
|
||||
val merged = ArrayList<RichSpan>()
|
||||
for (span in spans.sortedWith(compareBy({ it.start }, { it.end }))) {
|
||||
val i = merged.indexOfLast { it.style == span.style && span.start <= it.end }
|
||||
if (i >= 0) {
|
||||
merged[i] = merged[i].copy(end = maxOf(merged[i].end, span.end))
|
||||
} else {
|
||||
linkStart = text.length
|
||||
linkUrl = extractHref(body)
|
||||
merged.add(span)
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
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("&", "&")
|
||||
|
||||
@@ -15,17 +15,22 @@ data class EditResult(val content: RichTextContent, val selectionStart: Int, val
|
||||
*/
|
||||
object RichTextEditing {
|
||||
|
||||
/** Adds [style] over [[start], [end]) if it is not already fully styled, otherwise removes it. */
|
||||
/**
|
||||
* Toggles [style] over [[start], [end]): if the exact style already covers the whole range it is
|
||||
* removed; otherwise it is applied, replacing any other value of the same kind over the range
|
||||
* (e.g. picking a new [RichStyle.FontSize] replaces the old size instead of stacking a second one).
|
||||
*/
|
||||
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)
|
||||
val otherKinds = content.spans.filter { it.style::class != style::class }
|
||||
val sameKind = content.spans.filter { it.style::class == style::class }
|
||||
val cleared = subtractRange(sameKind, start, end)
|
||||
val updated = if (isFullyStyled(sameKind.filter { it.style == style }, start, end)) {
|
||||
cleared
|
||||
} else {
|
||||
mergeSameStyle(same + RichSpan(start, end, style))
|
||||
mergeSameValueSpans(cleared + RichSpan(start, end, style))
|
||||
}
|
||||
return content.copy(spans = (others + updated).sortedBy { it.start })
|
||||
return content.copy(spans = (otherKinds + updated).sortedBy { it.start })
|
||||
}
|
||||
|
||||
/** Links [[start], [end]) to [url], replacing any links that overlap the range. */
|
||||
@@ -45,6 +50,27 @@ object RichTextEditing {
|
||||
fun isStyled(content: RichTextContent, start: Int, end: Int, style: RichStyle): Boolean =
|
||||
start < end && isFullyStyled(content.spans.filter { it.style == style }, start, end)
|
||||
|
||||
/**
|
||||
* The single value of style kind [T] over the selection, or null when absent or mixed — one call
|
||||
* gives a picker its "current value". For a caret the boundaries count as inside, so the query
|
||||
* matches the run the user is typing at the end of; at a boundary between two runs the earlier
|
||||
* run wins.
|
||||
*/
|
||||
inline fun <reified T : RichStyle> styleAt(content: RichTextContent, start: Int, end: Int): T? =
|
||||
styleAt(content, start, end, T::class.java)
|
||||
|
||||
/** Non-reified form of [styleAt] for callers that carry the kind as a [Class]. */
|
||||
fun <T : RichStyle> styleAt(content: RichTextContent, start: Int, end: Int, kind: Class<T>): T? {
|
||||
val candidates = content.spans.filter { kind.isInstance(it.style) }
|
||||
val value = if (start >= end) {
|
||||
candidates.firstOrNull { it.start <= start && start <= it.end }?.style
|
||||
} else {
|
||||
candidates.map { it.style }.distinct()
|
||||
.firstOrNull { v -> isFullyStyled(candidates.filter { it.style == v }, start, end) }
|
||||
}
|
||||
return kind.cast(value)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
@@ -54,7 +80,7 @@ object RichTextEditing {
|
||||
/**
|
||||
* 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.
|
||||
* links, alignments, images 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
|
||||
@@ -71,9 +97,14 @@ object RichTextEditing {
|
||||
}
|
||||
}
|
||||
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))
|
||||
val updated = content.copy(
|
||||
text = newText,
|
||||
spans = content.spans.mapNotNull { remapSpan(it, remap) },
|
||||
links = content.links.mapNotNull { remapLink(it, remap) },
|
||||
alignments = content.alignments.mapNotNull { remapAlignment(it, remap) },
|
||||
images = content.images.mapNotNull { remapImage(it, remap) },
|
||||
)
|
||||
return EditResult(updated, remap(start), remap(end))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +126,18 @@ private fun remapLink(link: RichLink, remap: (Int) -> Int): RichLink? {
|
||||
return if (s < e) RichLink(s, e, link.url) else null
|
||||
}
|
||||
|
||||
private fun remapAlignment(alignment: RichAlignment, remap: (Int) -> Int): RichAlignment? {
|
||||
val s = remap(alignment.start)
|
||||
val e = remap(alignment.end)
|
||||
return if (s < e) alignment.copy(start = s, end = e) else null
|
||||
}
|
||||
|
||||
private fun remapImage(image: RichImage, remap: (Int) -> Int): RichImage? {
|
||||
val s = remap(image.start)
|
||||
val e = remap(image.end)
|
||||
return if (s < e) image.copy(start = s, end = e) else null
|
||||
}
|
||||
|
||||
// --- inline style helpers ---
|
||||
|
||||
private fun isFullyStyled(spans: List<RichSpan>, start: Int, end: Int): Boolean {
|
||||
@@ -117,19 +160,6 @@ private fun subtractRange(spans: List<RichSpan>, start: Int, end: Int): List<Ric
|
||||
}
|
||||
}
|
||||
|
||||
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+\\. ")
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.richtext
|
||||
|
||||
/**
|
||||
* 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> <s> <a> <span style> <img> <ul>/<ol>/<li> <blockquote> <br>`,
|
||||
* `text-align` on `<p>`/`<li>`, and a single outer `<div style>` for [RichBaseStyle] — is
|
||||
* deliberately narrow so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts
|
||||
* round-trip losslessly). Two canonical-form caveats: [fromHtml] returns maximally-merged spans and
|
||||
* alignment runs, and an inline span crossing a line break re-parses as one span per line.
|
||||
*/
|
||||
object RichTextHtml {
|
||||
|
||||
fun toHtml(content: RichTextContent): String {
|
||||
val body = bodyHtml(content)
|
||||
val base = content.baseStyle ?: return body
|
||||
return "<div style=\"" + escapeAttr(baseCss(base)) + "\">" + body + "</div>"
|
||||
}
|
||||
|
||||
/** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */
|
||||
fun toPlainText(content: RichTextContent): String = content.text
|
||||
|
||||
fun fromHtml(html: String): RichTextContent = RichTextHtmlParser(html).parse()
|
||||
}
|
||||
|
||||
/** The `font-family`/`font-size` declarations of the base-style wrapper (possibly empty). */
|
||||
private fun baseCss(base: RichBaseStyle): String = listOfNotNull(
|
||||
base.fontCss?.let { "font-family:$it" },
|
||||
base.fontSizePt?.let { "font-size:${it}pt" },
|
||||
).joinToString(";")
|
||||
|
||||
private fun bodyHtml(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()
|
||||
}
|
||||
|
||||
private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE }
|
||||
|
||||
/** One line of the plain text: [start] is the raw line start, content excludes the block marker. */
|
||||
private data class Line(val kind: Kind, val start: Int, val contentStart: Int, val contentEnd: Int)
|
||||
|
||||
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, start + (marker?.length ?: 0), end))
|
||||
if (nl == -1) break
|
||||
start = nl + 1
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** The alignment applying to [line]: the first alignment run overlapping the line's range. */
|
||||
private fun alignFor(content: RichTextContent, line: Line): RichAlign? =
|
||||
content.alignments.firstOrNull { it.start < line.contentEnd && it.end > line.start }?.align
|
||||
|
||||
/** Fixed left/center/right keeps the output readable in legacy email clients. */
|
||||
private fun cssAlign(align: RichAlign): String = when (align) {
|
||||
RichAlign.START -> "left"
|
||||
RichAlign.CENTER -> "center"
|
||||
RichAlign.END -> "right"
|
||||
}
|
||||
|
||||
private fun openBlockTag(tag: String, align: RichAlign?): String =
|
||||
if (align == null) "<$tag>" else "<$tag style=\"text-align:${cssAlign(align)}\">"
|
||||
|
||||
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(openBlockTag("li", alignFor(content, lines[i])))
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
sb.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
|
||||
}
|
||||
|
||||
/** Merges consecutive plain lines into one `<p>`, splitting where the alignment changes. */
|
||||
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
val align = alignFor(content, lines[from])
|
||||
sb.append(openBlockTag("p", align))
|
||||
val inner = StringBuilder()
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.PARAGRAPH && alignFor(content, lines[i]) == align) {
|
||||
if (i > from) inner.append("<br>")
|
||||
inner.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
// A group of only empty lines would otherwise vanish on parse (the closing tag's newline is a
|
||||
// no-op at a line start), so it emits one <br> per line to keep the blank lines round-trippable.
|
||||
val allEmpty = (from until i).all { lines[it].contentStart >= lines[it].contentEnd }
|
||||
sb.append(if (allEmpty) "<br>".repeat(i - from) else inner)
|
||||
sb.append("</p>")
|
||||
return i
|
||||
}
|
||||
|
||||
/** Renders the inline styling/links/images over [[from], [to]) as nested, fully-closed 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, link, or image 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) }
|
||||
content.images.forEach { add(it.start, it.end) }
|
||||
return cuts.toList()
|
||||
}
|
||||
|
||||
/** The simple toggle styles and their HTML tags, in emission (outermost-first) order. */
|
||||
private val SIMPLE_TAGS = listOf<Pair<RichStyle, String>>(
|
||||
RichStyle.Bold to "b",
|
||||
RichStyle.Italic to "i",
|
||||
RichStyle.Underline to "u",
|
||||
RichStyle.Strikethrough to "s",
|
||||
)
|
||||
|
||||
/** 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 image = content.images.firstOrNull { it.start <= a && a < it.end }
|
||||
if (image != null && a != image.start) return // interior cut of an image token; emitted at its start
|
||||
val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }
|
||||
val link = content.links.firstOrNull { it.start <= a && b <= it.end }
|
||||
val tags = buildList {
|
||||
if (link != null) add("a href=\"${escapeAttr(link.url)}\"" to "a")
|
||||
inlineCss(styles).takeIf { it.isNotEmpty() }?.let { add("span style=\"${escapeAttr(it)}\"" to "span") }
|
||||
SIMPLE_TAGS.forEach { (style, tag) -> if (style in styles) add(tag to tag) }
|
||||
}
|
||||
tags.forEach { (open, _) -> sb.append('<').append(open).append('>') }
|
||||
if (image != null) {
|
||||
sb.append("<img src=\"cid:").append(escapeAttr(image.contentId))
|
||||
.append("\" alt=\"").append(escapeAttr(image.name)).append("\">")
|
||||
} else {
|
||||
sb.append(escape(content.text.substring(a, b)))
|
||||
}
|
||||
tags.asReversed().forEach { (_, close) -> sb.append("</").append(close).append('>') }
|
||||
}
|
||||
|
||||
/** Merges the parameterized styles active on a run into one CSS declaration list (maybe empty). */
|
||||
private fun inlineCss(styles: List<RichStyle>): String {
|
||||
val parts = ArrayList<String>()
|
||||
styles.firstNotNullOfOrNull { it as? RichStyle.FontFamily }?.let { parts.add("font-family:${it.css}") }
|
||||
styles.firstNotNullOfOrNull { it as? RichStyle.FontSize }?.let { parts.add("font-size:${it.pt}pt") }
|
||||
styles.firstNotNullOfOrNull { it as? RichStyle.FontColor }?.let { parts.add("color:${cssColor(it.argb)}") }
|
||||
styles.firstNotNullOfOrNull { it as? RichStyle.Highlight }
|
||||
?.let { parts.add("background-color:${cssColor(it.argb)}") }
|
||||
return parts.joinToString(";")
|
||||
}
|
||||
|
||||
private const val RGB_MASK = 0xFFFFFF
|
||||
private const val RGB_HEX_DIGITS = 6
|
||||
private const val HEX_RADIX = 16
|
||||
|
||||
/** `#rrggbb` for the low 24 bits of [argb] (alpha is not representable in email-safe CSS). */
|
||||
internal fun cssColor(argb: Int): String = "#" + (argb and RGB_MASK).toString(HEX_RADIX).padStart(RGB_HEX_DIGITS, '0')
|
||||
|
||||
internal fun escape(s: String): String = s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
internal fun escapeAttr(s: String): String = escape(s).replace("\"", """)
|
||||
@@ -0,0 +1,373 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.richtext
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Parses the narrow HTML subset [RichTextHtml.toHtml] emits (plus tolerated variants: `strong`/`em`,
|
||||
* `del`/`strike`, `px` font sizes, `#rgb` colors, `start`/`end` alignment, and pretty-printer
|
||||
* whitespace) back into a [RichTextContent]. Unknown tags and unknown CSS properties are ignored
|
||||
* without dropping the text they wrap. A small state machine keeps the nesting shallow:
|
||||
* [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text; open
|
||||
* style-bearing tags live on a stack so spans of any kind can nest and interleave.
|
||||
*/
|
||||
internal class RichTextHtmlParser(private val html: String) {
|
||||
private val text = StringBuilder()
|
||||
private val spans = ArrayList<RichSpan>()
|
||||
private val links = ArrayList<RichLink>()
|
||||
private val alignments = ArrayList<RichAlignment>()
|
||||
private val images = ArrayList<RichImage>()
|
||||
private val openStyles = ArrayDeque<OpenStyles>()
|
||||
private var baseStyle: RichBaseStyle? = null
|
||||
private var linkStart = -1
|
||||
private var linkUrl = ""
|
||||
private var listType: Char? = null
|
||||
private var olCount = 0
|
||||
private var inQuote = false
|
||||
private var blockStart = -1
|
||||
private var blockAlign: RichAlign? = null
|
||||
|
||||
/** One still-open style-bearing tag: the [channel] whose closing tag ends it, and its styles. */
|
||||
private class OpenStyles(val channel: String, val start: Int, val styles: List<RichStyle>)
|
||||
|
||||
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()
|
||||
val name = body.substringBefore(' ').substringBefore('/').lowercase()
|
||||
if (!handleInlineTag(name, closing, body)) handleBlockTag(name, closing, body)
|
||||
}
|
||||
|
||||
/** Dispatches character-level tags; returns false when [name] is not an inline tag. */
|
||||
private fun handleInlineTag(name: String, closing: Boolean, body: String): Boolean {
|
||||
when (name) {
|
||||
"b", "strong" -> toggleStyles(closing, "b", listOf(RichStyle.Bold))
|
||||
"i", "em" -> toggleStyles(closing, "i", listOf(RichStyle.Italic))
|
||||
"u" -> toggleStyles(closing, "u", listOf(RichStyle.Underline))
|
||||
"s", "del", "strike" -> toggleStyles(closing, "s", listOf(RichStyle.Strikethrough))
|
||||
"span" -> toggleStyles(closing, "span", spanStyles(closing, body))
|
||||
"a" -> handleAnchor(closing, body)
|
||||
"img" -> if (!closing) handleImage(body)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleBlockTag(name: String, closing: Boolean, body: String) {
|
||||
when (name) {
|
||||
"br" -> {
|
||||
text.append('\n')
|
||||
if (inQuote) text.append(QUOTE_PREFIX)
|
||||
}
|
||||
"ul" -> handleList(closing, 'u')
|
||||
"ol" -> handleList(closing, 'o')
|
||||
"li" -> handleListItem(closing, body)
|
||||
"blockquote" -> handleQuote(closing)
|
||||
"p" -> handleParagraph(closing, body)
|
||||
"div" -> handleDiv(closing, body)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun spanStyles(closing: Boolean, body: String): List<RichStyle> =
|
||||
if (closing) emptyList() else parseInlineStyles(extractStyleAttr(body))
|
||||
|
||||
/** Opens [styles] on [channel], or on a closing tag records spans for the matching open tag. */
|
||||
private fun toggleStyles(closing: Boolean, channel: String, styles: List<RichStyle>) {
|
||||
if (!closing) {
|
||||
openStyles.addLast(OpenStyles(channel, text.length, styles))
|
||||
return
|
||||
}
|
||||
val idx = openStyles.indexOfLast { it.channel == channel }
|
||||
if (idx >= 0) recordSpans(openStyles.removeAt(idx), text.length)
|
||||
}
|
||||
|
||||
private fun recordSpans(open: OpenStyles, end: Int) {
|
||||
if (open.start >= end) return
|
||||
open.styles.forEach { style -> spans.add(RichSpan(open.start, end, style)) }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/** `<img src="cid:…" alt="name">` becomes a visible [imageToken] backed by a [RichImage]. */
|
||||
private fun handleImage(body: String) {
|
||||
val src = extractAttr(body, SRC_ATTR) ?: return
|
||||
if (!src.startsWith(CID_PREFIX)) return
|
||||
val name = extractAttr(body, ALT_ATTR).orEmpty()
|
||||
val start = text.length
|
||||
text.append(imageToken(name))
|
||||
images.add(RichImage(start, text.length, src.removePrefix(CID_PREFIX), name))
|
||||
}
|
||||
|
||||
private fun handleList(closing: Boolean, type: Char) {
|
||||
if (closing) {
|
||||
listType = null
|
||||
} else {
|
||||
listType = type
|
||||
if (type == 'o') olCount = 0
|
||||
}
|
||||
newlineIfNeeded()
|
||||
}
|
||||
|
||||
private fun handleListItem(closing: Boolean, body: String) {
|
||||
if (closing) {
|
||||
endAlignedBlock()
|
||||
return
|
||||
}
|
||||
newlineIfNeeded()
|
||||
// The alignment run starts at the raw line start, so it covers the block marker too.
|
||||
startAlignedBlock(parseTextAlign(extractStyleAttr(body)))
|
||||
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 handleParagraph(closing: Boolean, body: String) {
|
||||
if (closing) {
|
||||
endAlignedBlock()
|
||||
newlineIfNeeded()
|
||||
} else {
|
||||
newlineIfNeeded()
|
||||
startAlignedBlock(parseTextAlign(extractStyleAttr(body)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A leading `<div style>` before any content is the base-style wrapper [RichTextHtml.toHtml]
|
||||
* emits — it must not add a newline (and its closing tag lands right after the last block's
|
||||
* newline, where [newlineIfNeeded] is a no-op, so the wrapper never adds stray text).
|
||||
*/
|
||||
private fun handleDiv(closing: Boolean, body: String) {
|
||||
val wrapperCss = if (!closing && text.isEmpty() && baseStyle == null) extractStyleAttr(body) else null
|
||||
if (wrapperCss != null) {
|
||||
baseStyle = parseBaseStyle(wrapperCss)
|
||||
} else {
|
||||
newlineIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAlignedBlock(align: RichAlign?) {
|
||||
endAlignedBlock()
|
||||
blockStart = text.length
|
||||
blockAlign = align
|
||||
}
|
||||
|
||||
private fun endAlignedBlock() {
|
||||
val align = blockAlign
|
||||
if (align != null && blockStart >= 0 && blockStart < text.length) {
|
||||
alignments.add(RichAlignment(blockStart, text.length, align))
|
||||
}
|
||||
blockStart = -1
|
||||
blockAlign = null
|
||||
}
|
||||
|
||||
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 {
|
||||
endAlignedBlock()
|
||||
while (openStyles.isNotEmpty()) recordSpans(openStyles.removeLast(), text.length)
|
||||
if (linkStart >= 0) links.add(RichLink(linkStart, text.length, linkUrl))
|
||||
val out = text.toString().trimEnd('\n')
|
||||
val len = out.length
|
||||
return RichTextContent(
|
||||
text = out,
|
||||
spans = mergeSameValueSpans(spans.mapNotNull { it.clampedTo(len) }),
|
||||
links = links.mapNotNull { it.clampedTo(len) },
|
||||
alignments = mergedAlignments(alignments.mapNotNull { it.clampedTo(len) }),
|
||||
images = images.filter { it.end <= len },
|
||||
baseStyle = baseStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RichSpan.clampedTo(len: Int): RichSpan? {
|
||||
val e = minOf(end, len)
|
||||
return if (start < e) copy(end = e) else null
|
||||
}
|
||||
|
||||
private fun RichLink.clampedTo(len: Int): RichLink? {
|
||||
val e = minOf(end, len)
|
||||
return if (start < e) copy(end = e) else null
|
||||
}
|
||||
|
||||
private fun RichAlignment.clampedTo(len: Int): RichAlignment? {
|
||||
val e = minOf(end, len)
|
||||
return if (start < e) copy(end = e) else null
|
||||
}
|
||||
|
||||
/** Merges same-alignment runs on adjacent lines (ranges separated by exactly the newline). */
|
||||
private fun mergedAlignments(alignments: List<RichAlignment>): List<RichAlignment> {
|
||||
val merged = ArrayList<RichAlignment>()
|
||||
for (alignment in alignments.sortedBy { it.start }) {
|
||||
val last = merged.lastOrNull()
|
||||
if (last != null && last.align == alignment.align && alignment.start <= last.end + 1) {
|
||||
merged[merged.size - 1] = last.copy(end = maxOf(last.end, alignment.end))
|
||||
} else {
|
||||
merged.add(alignment)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// --- tag-attribute and CSS helpers ---
|
||||
|
||||
private const val CID_PREFIX = "cid:"
|
||||
|
||||
private fun attrRegex(name: String) =
|
||||
Regex("(?:^|\\s)$name\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE)
|
||||
|
||||
private val HREF_ATTR = attrRegex("href")
|
||||
private val STYLE_ATTR = attrRegex("style")
|
||||
private val SRC_ATTR = attrRegex("src")
|
||||
private val ALT_ATTR = attrRegex("alt")
|
||||
|
||||
private fun extractAttr(tagBody: String, attr: Regex): String? {
|
||||
val match = attr.find(tagBody) ?: return null
|
||||
val (doubleQuoted, singleQuoted) = match.destructured
|
||||
return unescape(doubleQuoted.ifEmpty { singleQuoted })
|
||||
}
|
||||
|
||||
internal fun extractHref(tagBody: String): String = extractAttr(tagBody, HREF_ATTR) ?: ""
|
||||
|
||||
/** The tag's `style` attribute value, or null when absent (an empty attribute yields ""). */
|
||||
internal fun extractStyleAttr(tagBody: String): String? = extractAttr(tagBody, STYLE_ATTR)
|
||||
|
||||
private inline fun forEachCssDeclaration(css: String, action: (prop: String, value: String) -> Unit) {
|
||||
css.split(';').forEach { declaration ->
|
||||
val prop = declaration.substringBefore(':').trim().lowercase()
|
||||
val value = declaration.substringAfter(':', "").trim()
|
||||
if (prop.isNotEmpty() && value.isNotEmpty()) action(prop, value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps recognized inline CSS declarations to [RichStyle]s; unknown properties are skipped. */
|
||||
internal fun parseInlineStyles(css: String?): List<RichStyle> {
|
||||
if (css == null) return emptyList()
|
||||
val styles = ArrayList<RichStyle>()
|
||||
forEachCssDeclaration(css) { prop, value -> styleForCss(prop, value)?.let(styles::add) }
|
||||
return styles
|
||||
}
|
||||
|
||||
private fun styleForCss(prop: String, value: String): RichStyle? = when (prop) {
|
||||
"font-family" -> RichStyle.FontFamily(value)
|
||||
"font-size" -> parseFontSizePt(value)?.let { RichStyle.FontSize(it) }
|
||||
"color" -> parseCssColor(value)?.let { RichStyle.FontColor(it) }
|
||||
"background-color" -> parseCssColor(value)?.let { RichStyle.Highlight(it) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Reads the base-style wrapper's declarations; unknown properties are ignored. */
|
||||
internal fun parseBaseStyle(css: String): RichBaseStyle {
|
||||
var fontCss: String? = null
|
||||
var fontSizePt: Int? = null
|
||||
forEachCssDeclaration(css) { prop, value ->
|
||||
when (prop) {
|
||||
"font-family" -> fontCss = value
|
||||
"font-size" -> fontSizePt = parseFontSizePt(value)
|
||||
}
|
||||
}
|
||||
return RichBaseStyle(fontCss, fontSizePt)
|
||||
}
|
||||
|
||||
private val FONT_SIZE = Regex("^(\\d+(?:\\.\\d+)?)\\s*(pt|px)$", RegexOption.IGNORE_CASE)
|
||||
private const val PT_PER_PX = 3.0 / 4.0
|
||||
|
||||
/** A CSS font size in points; `px` values convert at 3/4 pt per px, rounded to the nearest int. */
|
||||
internal fun parseFontSizePt(value: String): Int? {
|
||||
val match = FONT_SIZE.find(value) ?: return null
|
||||
val (number, unit) = match.destructured
|
||||
val size = number.toDoubleOrNull() ?: return null
|
||||
val pt = if (unit.equals("px", ignoreCase = true)) size * PT_PER_PX else size
|
||||
return pt.roundToInt().takeIf { it > 0 }
|
||||
}
|
||||
|
||||
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
|
||||
private const val SHORT_HEX_LEN = 3
|
||||
private const val LONG_HEX_LEN = 6
|
||||
private const val COLOR_RADIX = 16
|
||||
|
||||
/** `#rgb` or `#rrggbb` as an opaque ARGB int, or null for anything else. */
|
||||
internal fun parseCssColor(value: String): Int? {
|
||||
if (!value.startsWith("#")) return null
|
||||
val hex = value.drop(1)
|
||||
val expanded = when (hex.length) {
|
||||
SHORT_HEX_LEN -> buildString { hex.forEach { append(it).append(it) } }
|
||||
LONG_HEX_LEN -> hex
|
||||
else -> return null
|
||||
}
|
||||
val rgb = expanded.toIntOrNull(COLOR_RADIX) ?: return null
|
||||
return OPAQUE_ALPHA or rgb
|
||||
}
|
||||
|
||||
/** The [RichAlign] from a style attribute's `text-align` declaration, or null. */
|
||||
internal fun parseTextAlign(css: String?): RichAlign? {
|
||||
if (css == null) return null
|
||||
var align: RichAlign? = null
|
||||
forEachCssDeclaration(css) { prop, value ->
|
||||
if (prop == "text-align") align = richAlignFor(value.lowercase())
|
||||
}
|
||||
return align
|
||||
}
|
||||
|
||||
private fun richAlignFor(value: String): RichAlign? = when (value) {
|
||||
"left", "start" -> RichAlign.START
|
||||
"center" -> RichAlign.CENTER
|
||||
"right", "end" -> RichAlign.END
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun unescape(s: String): String = s
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,9 @@ 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.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -28,6 +31,8 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -50,9 +55,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 +160,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(
|
||||
@@ -182,6 +177,8 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
)
|
||||
AttachmentsSection(
|
||||
attachments = state.attachments,
|
||||
highlight = state.highlightAttach,
|
||||
onHighlightShown = viewModel::consumeAttachHighlight,
|
||||
onAttach = { attachmentPicker.launch(arrayOf("*/*")) },
|
||||
onRemove = viewModel::removeAttachment,
|
||||
)
|
||||
@@ -207,6 +204,29 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showAttachmentPrompt) {
|
||||
AttachmentPromptDialog(
|
||||
onAttach = viewModel::attachInstead,
|
||||
onSendAnyway = viewModel::sendAnyway,
|
||||
onDismiss = viewModel::dismissAttachmentPrompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when Send is tapped on a message that mentions an attachment but carries none. "Yes"
|
||||
* returns to composing with the attach button highlighted; "No" sends the message as-is.
|
||||
*/
|
||||
@Composable
|
||||
private fun AttachmentPromptDialog(onAttach: () -> Unit, onSendAnyway: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.confirm_attachment_title)) },
|
||||
text = { Text(stringResource(R.string.confirm_attachment_text)) },
|
||||
confirmButton = { TextButton(onClick = onAttach) { Text(stringResource(R.string.action_yes)) } },
|
||||
dismissButton = { TextButton(onClick = onSendAnyway) { Text(stringResource(R.string.action_no)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -245,15 +265,111 @@ private fun FromRow(accounts: List<Account>, 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<FocusRequester?>(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(
|
||||
attachments: List<OutgoingAttachment>,
|
||||
highlight: Boolean,
|
||||
onHighlightShown: () -> Unit,
|
||||
onAttach: () -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
) {
|
||||
// Answering "Yes" on the attachment prompt lands back here: pulse the attach button a few
|
||||
// times to draw the eye, then report the highlight as consumed.
|
||||
val pulse = remember { Animatable(0f) }
|
||||
LaunchedEffect(highlight) {
|
||||
if (highlight) {
|
||||
repeat(3) {
|
||||
pulse.animateTo(1f, tween(durationMillis = 300))
|
||||
pulse.animateTo(0f, tween(durationMillis = 300))
|
||||
}
|
||||
onHighlightShown()
|
||||
} else {
|
||||
pulse.snapTo(0f)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
TextButton(onClick = onAttach) {
|
||||
TextButton(
|
||||
onClick = onAttach,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = pulse.value),
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.compose_attach))
|
||||
|
||||
@@ -48,6 +48,10 @@ data class ComposeUiState(
|
||||
val contactsAllowed: Boolean = false,
|
||||
val sending: Boolean = false,
|
||||
val error: String? = null,
|
||||
/** Send was tapped while the text mentions an attachment but none is attached: ask first. */
|
||||
val showAttachmentPrompt: Boolean = false,
|
||||
/** Draw the eye to the attach button — the user answered the attachment prompt with "Yes". */
|
||||
val highlightAttach: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -90,6 +94,9 @@ class ComposeViewModel @Inject constructor(
|
||||
/** The signature block last appended to the body, so a From-change can swap it out cleanly. */
|
||||
private var appliedSignatureBlock = SignatureBlock.EMPTY
|
||||
|
||||
/** Word-bounded "attach" and variants (attached, attachment(s), attaching, attaches). */
|
||||
private val attachmentMention = Regex("""\battach(?:ed|ment|ments|ing|es)?\b""", RegexOption.IGNORE_CASE)
|
||||
|
||||
init {
|
||||
if (draftId != null) {
|
||||
viewModelScope.launch {
|
||||
@@ -98,6 +105,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,
|
||||
@@ -260,7 +268,24 @@ class ComposeViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun send() {
|
||||
fun send() = trySend(checkAttachments = true)
|
||||
|
||||
/** "No" on the attachment prompt — the user confirmed nothing needs attaching, so send as-is. */
|
||||
fun sendAnyway() {
|
||||
_state.update { it.copy(showAttachmentPrompt = false) }
|
||||
trySend(checkAttachments = false)
|
||||
}
|
||||
|
||||
/** "Yes" on the attachment prompt — back to composing, with the attach button highlighted. */
|
||||
fun attachInstead() = _state.update { it.copy(showAttachmentPrompt = false, highlightAttach = true) }
|
||||
|
||||
/** The prompt was dismissed without choosing: stay composing, no send, no highlight. */
|
||||
fun dismissAttachmentPrompt() = _state.update { it.copy(showAttachmentPrompt = false) }
|
||||
|
||||
/** The attach-button highlight has finished animating. */
|
||||
fun consumeAttachHighlight() = _state.update { it.copy(highlightAttach = false) }
|
||||
|
||||
private fun trySend(checkAttachments: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val s = _state.value
|
||||
// Await the account list if it hasn't emitted yet, so an early tap doesn't wrongly
|
||||
@@ -270,37 +295,39 @@ class ComposeViewModel @Inject constructor(
|
||||
when {
|
||||
account == null -> _state.update { it.copy(error = "Add an account first") }
|
||||
s.to.isBlank() -> _state.update { it.copy(error = "Add a recipient") }
|
||||
else -> {
|
||||
_state.update { it.copy(sending = true, error = null) }
|
||||
mailRepository.sendMessage(
|
||||
OutgoingMessage(
|
||||
accountId = account.id,
|
||||
to = s.to,
|
||||
cc = s.cc,
|
||||
bcc = s.bcc,
|
||||
subject = s.subject,
|
||||
body = s.body,
|
||||
bodyHtml = s.bodyHtml,
|
||||
attachments = s.attachments,
|
||||
),
|
||||
).fold(
|
||||
onSuccess = {
|
||||
draftId?.let { mailRepository.deleteDraft(it) }
|
||||
_state.update { it.copy(sending = false) }
|
||||
finish()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
sending = false,
|
||||
error =
|
||||
e.message ?: "Could not send",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
checkAttachments && s.attachments.isEmpty() && mentionsAttachment(s) ->
|
||||
_state.update { it.copy(showAttachmentPrompt = true) }
|
||||
else -> performSend(account, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The classic forgotten-attachment guard: does the subject or body talk about attaching? */
|
||||
private fun mentionsAttachment(s: ComposeUiState): Boolean =
|
||||
attachmentMention.containsMatchIn(s.subject) || attachmentMention.containsMatchIn(s.body)
|
||||
|
||||
private suspend fun performSend(account: Account, s: ComposeUiState) {
|
||||
_state.update { it.copy(sending = true, error = null) }
|
||||
mailRepository.sendMessage(
|
||||
OutgoingMessage(
|
||||
accountId = account.id,
|
||||
to = s.to,
|
||||
cc = s.cc,
|
||||
bcc = s.bcc,
|
||||
subject = s.subject,
|
||||
body = s.body,
|
||||
bodyHtml = s.bodyHtml,
|
||||
attachments = s.attachments,
|
||||
),
|
||||
).fold(
|
||||
onSuccess = {
|
||||
draftId?.let { mailRepository.deleteDraft(it) }
|
||||
_state.update { it.copy(sending = false) }
|
||||
finish()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_state.update { it.copy(sending = false, error = e.message ?: "Could not send") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,17 +30,26 @@ 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.ParagraphStyle
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.libremail.R
|
||||
import org.libremail.richtext.BlockMarker
|
||||
import org.libremail.richtext.RichAlign
|
||||
import org.libremail.richtext.RichAlignment
|
||||
import org.libremail.richtext.RichBaseStyle
|
||||
import org.libremail.richtext.RichImage
|
||||
import org.libremail.richtext.RichLink
|
||||
import org.libremail.richtext.RichSpan
|
||||
import org.libremail.richtext.RichStyle
|
||||
@@ -51,6 +60,16 @@ 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"
|
||||
|
||||
/**
|
||||
* String-annotation tag carrying a parameterized style's identity (see [encodeStyle]), so e.g. a
|
||||
* [RichStyle.FontColor] span can never be confused with the link-color paint or any other span that
|
||||
* happens to share its visual [SpanStyle].
|
||||
*/
|
||||
internal const val STYLE_TAG = "libremail:style"
|
||||
|
||||
/** String-annotation tag carrying an inline image's content id and display name over its token. */
|
||||
internal const val IMAGE_TAG = "libremail:image"
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -60,6 +79,9 @@ private const val URL_TAG = "libremail:url"
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* [resolveFont] maps a CSS font-family stack to a Compose [FontFamily] for display; the default
|
||||
* resolves nothing, leaving the system font (the model still round-trips the CSS value untouched).
|
||||
*/
|
||||
@Composable
|
||||
fun RichTextBodyField(
|
||||
@@ -68,21 +90,28 @@ fun RichTextBodyField(
|
||||
onBodyChange: (plain: String, html: String?) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
resolveFont: (String) -> FontFamily? = { null },
|
||||
) {
|
||||
val linkColor = MaterialTheme.colorScheme.primary
|
||||
var value by remember { mutableStateOf(seedValue(body, bodyHtml, linkColor)) }
|
||||
val seed = remember { seedContent(body, bodyHtml) }
|
||||
var value by remember { mutableStateOf(seed.toSeededValue(linkColor, resolveFont)) }
|
||||
// The message-wide base style is position-independent, so it lives outside the AnnotatedString
|
||||
// (which only carries positioned spans) and is recombined with the field's content on emit.
|
||||
var baseStyle by remember { mutableStateOf(seed.baseStyle) }
|
||||
// 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)
|
||||
val content = seedContent(body, bodyHtml)
|
||||
value = content.toSeededValue(linkColor, resolveFont)
|
||||
baseStyle = content.baseStyle
|
||||
lastEmitted = body to bodyHtml
|
||||
}
|
||||
|
||||
fun emit(newValue: TextFieldValue) {
|
||||
value = newValue
|
||||
val content = newValue.annotatedString.toRichContent()
|
||||
val content = newValue.annotatedString.toRichContent(baseStyle)
|
||||
val html = if (content.hasFormatting()) RichTextHtml.toHtml(content) else null
|
||||
lastEmitted = content.text to html
|
||||
onBodyChange(content.text, html)
|
||||
@@ -93,8 +122,8 @@ fun RichTextBodyField(
|
||||
Column(modifier) {
|
||||
FormattingToolbar(
|
||||
value = value,
|
||||
onToggleStyle = { style -> emit(applyStyle(value, style, linkColor)) },
|
||||
onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor)) },
|
||||
onToggleStyle = { style -> emit(applyStyle(value, style, linkColor, resolveFont)) },
|
||||
onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor, resolveFont)) },
|
||||
onLink = { showLinkDialog = true },
|
||||
)
|
||||
OutlinedTextField(
|
||||
@@ -102,6 +131,7 @@ fun RichTextBodyField(
|
||||
onValueChange = ::emit,
|
||||
label = { Text(label) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
textStyle = applyBaseStyle(LocalTextStyle.current, baseStyle, resolveFont),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
)
|
||||
@@ -113,7 +143,7 @@ fun RichTextBodyField(
|
||||
enabled = hasSelection,
|
||||
onDismiss = { showLinkDialog = false },
|
||||
onConfirm = { url ->
|
||||
emit(applyLink(value, url, linkColor))
|
||||
emit(applyLink(value, url, linkColor, resolveFont))
|
||||
showLinkDialog = false
|
||||
},
|
||||
)
|
||||
@@ -141,23 +171,23 @@ private fun FormattingToolbar(
|
||||
FormatButton(
|
||||
label = "B",
|
||||
description = stringResource(R.string.format_bold),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.BOLD),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.Bold),
|
||||
fontWeight = FontWeight.Bold,
|
||||
onClick = { onToggleStyle(RichStyle.BOLD) },
|
||||
onClick = { onToggleStyle(RichStyle.Bold) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "I",
|
||||
description = stringResource(R.string.format_italic),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.ITALIC),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.Italic),
|
||||
fontStyle = FontStyle.Italic,
|
||||
onClick = { onToggleStyle(RichStyle.ITALIC) },
|
||||
onClick = { onToggleStyle(RichStyle.Italic) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "U",
|
||||
description = stringResource(R.string.format_underline),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.UNDERLINE),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.Underline),
|
||||
underline = true,
|
||||
onClick = { onToggleStyle(RichStyle.UNDERLINE) },
|
||||
onClick = { onToggleStyle(RichStyle.Underline) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "•",
|
||||
@@ -250,17 +280,28 @@ private fun LinkDialog(enabled: Boolean, onDismiss: () -> Unit, onConfirm: (Stri
|
||||
|
||||
// --- editor-op plumbing (TextFieldValue <-> RichTextContent) ---
|
||||
|
||||
private fun applyStyle(value: TextFieldValue, style: RichStyle, linkColor: Color): TextFieldValue {
|
||||
/** Toggles [style] over the selection and rebuilds the field value (one-liner for toolbar wiring). */
|
||||
internal fun applyStyle(
|
||||
value: TextFieldValue,
|
||||
style: RichStyle,
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily? = { null },
|
||||
): TextFieldValue {
|
||||
val updated = RichTextEditing.toggleStyle(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
value.selection.max,
|
||||
style,
|
||||
)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
|
||||
}
|
||||
|
||||
private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Color): TextFieldValue {
|
||||
private fun applyBlock(
|
||||
value: TextFieldValue,
|
||||
marker: BlockMarker,
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily?,
|
||||
): TextFieldValue {
|
||||
val result = RichTextEditing.toggleBlock(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
@@ -268,55 +309,172 @@ private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Co
|
||||
marker,
|
||||
)
|
||||
return TextFieldValue(
|
||||
result.content.toAnnotatedString(linkColor),
|
||||
result.content.toAnnotatedString(linkColor, resolveFont),
|
||||
TextRange(result.selectionStart, result.selectionEnd),
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyLink(value: TextFieldValue, url: String, linkColor: Color): TextFieldValue {
|
||||
private fun applyLink(
|
||||
value: TextFieldValue,
|
||||
url: String,
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily?,
|
||||
): TextFieldValue {
|
||||
val updated = RichTextEditing.applyLink(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
value.selection.max,
|
||||
url,
|
||||
)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), 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)
|
||||
private fun seedContent(body: String, bodyHtml: String?): RichTextContent =
|
||||
if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body)
|
||||
|
||||
private fun RichTextContent.toSeededValue(linkColor: Color, resolveFont: (String) -> FontFamily?): TextFieldValue {
|
||||
val annotated = toAnnotatedString(linkColor, resolveFont)
|
||||
return TextFieldValue(annotated, TextRange(annotated.length))
|
||||
}
|
||||
|
||||
/** Applies the message-wide base font family/size on top of the field's ambient text style. */
|
||||
internal fun applyBaseStyle(
|
||||
textStyle: TextStyle,
|
||||
base: RichBaseStyle?,
|
||||
resolveFont: (String) -> FontFamily?,
|
||||
): TextStyle {
|
||||
if (base == null) return textStyle
|
||||
return textStyle.copy(
|
||||
fontFamily = base.fontCss?.let(resolveFont) ?: textStyle.fontFamily,
|
||||
fontSize = base.fontSizePt?.let { it.sp } ?: textStyle.fontSize,
|
||||
)
|
||||
}
|
||||
|
||||
/** Maps the app rich-text model onto a Compose [AnnotatedString] for display/editing. */
|
||||
internal fun RichTextContent.toAnnotatedString(linkColor: Color): AnnotatedString = buildAnnotatedString {
|
||||
internal fun RichTextContent.toAnnotatedString(
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily? = { null },
|
||||
): AnnotatedString = buildAnnotatedString {
|
||||
append(text)
|
||||
spans.forEach { span -> addStyle(spanStyleFor(span.style), span.start, span.end) }
|
||||
spans.forEach { span ->
|
||||
addStyle(spanStyleFor(span.style, resolveFont), span.start, span.end)
|
||||
encodeStyle(span.style)?.let { addStringAnnotation(STYLE_TAG, it, span.start, span.end) }
|
||||
}
|
||||
links.forEach { link ->
|
||||
addStyle(SpanStyle(color = linkColor), link.start, link.end)
|
||||
addStringAnnotation(URL_TAG, link.url, link.start, link.end)
|
||||
}
|
||||
images.forEach { image ->
|
||||
addStringAnnotation(IMAGE_TAG, encodeImage(image), image.start, image.end)
|
||||
}
|
||||
disjoint(alignments).forEach { alignment ->
|
||||
addStyle(ParagraphStyle(textAlign = alignment.align.toTextAlign()), alignment.start, alignment.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) }
|
||||
/**
|
||||
* Maps a Compose [AnnotatedString] back to the app model. Simple styles are recovered from their
|
||||
* single-attribute [SpanStyle]s; parameterized styles and images from their string annotations
|
||||
* (their visual paint is deliberately ignored, so the link color can never masquerade as a
|
||||
* [RichStyle.FontColor]). [baseStyle] is position-independent and rides alongside unchanged.
|
||||
*/
|
||||
internal fun AnnotatedString.toRichContent(baseStyle: RichBaseStyle? = null): RichTextContent {
|
||||
val simple = spanStyles.mapNotNull { range ->
|
||||
simpleStyleOf(range.item)?.let { RichSpan(range.start, range.end, it) }
|
||||
}
|
||||
val parameterized = getStringAnnotations(STYLE_TAG, 0, length).mapNotNull { range ->
|
||||
decodeStyle(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)
|
||||
val images = getStringAnnotations(IMAGE_TAG, 0, length).mapNotNull(::decodeImage)
|
||||
val alignments = paragraphStyles.mapNotNull { range ->
|
||||
range.item.textAlign.toRichAlign()?.let { RichAlignment(range.start, range.end, it) }
|
||||
}
|
||||
return RichTextContent(
|
||||
text = text,
|
||||
spans = (simple + parameterized).sortedBy { it.start },
|
||||
links = links,
|
||||
alignments = alignments,
|
||||
images = images,
|
||||
baseStyle = baseStyle,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
/** The visual paint for [style]; identity is carried separately (see [STYLE_TAG]). */
|
||||
private fun spanStyleFor(style: RichStyle, resolveFont: (String) -> FontFamily?): SpanStyle = when (style) {
|
||||
RichStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
RichStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
RichStyle.Underline -> SpanStyle(textDecoration = TextDecoration.Underline)
|
||||
RichStyle.Strikethrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
|
||||
is RichStyle.FontFamily -> SpanStyle(fontFamily = resolveFont(style.css))
|
||||
is RichStyle.FontSize -> SpanStyle(fontSize = style.pt.sp)
|
||||
is RichStyle.FontColor -> SpanStyle(color = Color(style.argb))
|
||||
is RichStyle.Highlight -> SpanStyle(background = Color(style.argb))
|
||||
}
|
||||
|
||||
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
|
||||
private fun simpleStyleOf(span: SpanStyle): RichStyle? = when {
|
||||
span.fontWeight == FontWeight.Bold -> RichStyle.Bold
|
||||
span.fontStyle == FontStyle.Italic -> RichStyle.Italic
|
||||
span.textDecoration == TextDecoration.Underline -> RichStyle.Underline
|
||||
span.textDecoration == TextDecoration.LineThrough -> RichStyle.Strikethrough
|
||||
else -> null // parameterized styles and the link paint are carried by string annotations instead
|
||||
}
|
||||
|
||||
private const val STYLE_FAMILY = "family"
|
||||
private const val STYLE_SIZE = "size"
|
||||
private const val STYLE_COLOR = "color"
|
||||
private const val STYLE_HIGHLIGHT = "highlight"
|
||||
|
||||
/** Compact identity payload for parameterized styles; simple styles need none. */
|
||||
private fun encodeStyle(style: RichStyle): String? = when (style) {
|
||||
RichStyle.Bold, RichStyle.Italic, RichStyle.Underline, RichStyle.Strikethrough -> null
|
||||
is RichStyle.FontFamily -> "$STYLE_FAMILY:${style.css}"
|
||||
is RichStyle.FontSize -> "$STYLE_SIZE:${style.pt}"
|
||||
is RichStyle.FontColor -> "$STYLE_COLOR:${style.argb}"
|
||||
is RichStyle.Highlight -> "$STYLE_HIGHLIGHT:${style.argb}"
|
||||
}
|
||||
|
||||
private fun decodeStyle(payload: String): RichStyle? {
|
||||
val value = payload.substringAfter(':', "")
|
||||
return when (payload.substringBefore(':')) {
|
||||
STYLE_FAMILY -> RichStyle.FontFamily(value)
|
||||
STYLE_SIZE -> value.toIntOrNull()?.let { RichStyle.FontSize(it) }
|
||||
STYLE_COLOR -> value.toIntOrNull()?.let { RichStyle.FontColor(it) }
|
||||
STYLE_HIGHLIGHT -> value.toIntOrNull()?.let { RichStyle.Highlight(it) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private const val IMAGE_SEPARATOR = '\n'
|
||||
|
||||
private fun encodeImage(image: RichImage): String = image.contentId + IMAGE_SEPARATOR + image.name
|
||||
|
||||
private fun decodeImage(range: AnnotatedString.Range<String>): RichImage? {
|
||||
val sep = range.item.indexOf(IMAGE_SEPARATOR)
|
||||
if (sep < 0) return null
|
||||
return RichImage(range.start, range.end, range.item.take(sep), range.item.substring(sep + 1))
|
||||
}
|
||||
|
||||
private fun RichAlign.toTextAlign(): TextAlign = when (this) {
|
||||
RichAlign.START -> TextAlign.Start
|
||||
RichAlign.CENTER -> TextAlign.Center
|
||||
RichAlign.END -> TextAlign.End
|
||||
}
|
||||
|
||||
private fun TextAlign.toRichAlign(): RichAlign? = when (this) {
|
||||
TextAlign.Start, TextAlign.Left -> RichAlign.START
|
||||
TextAlign.Center -> RichAlign.CENTER
|
||||
TextAlign.End, TextAlign.Right -> RichAlign.END
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Paragraph styles must not overlap inside an [AnnotatedString]; sorts and drops any that would. */
|
||||
private fun disjoint(alignments: List<RichAlignment>): List<RichAlignment> {
|
||||
val result = ArrayList<RichAlignment>(alignments.size)
|
||||
for (alignment in alignments.sortedBy { it.start }) {
|
||||
if (alignment.start < alignment.end && (result.isEmpty() || alignment.start >= result.last().end)) {
|
||||
result.add(alignment)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose.format
|
||||
|
||||
/** One selectable swatch for [ColorSwatchRow]: an opaque ARGB color and its accessible label. */
|
||||
data class ColorSwatch(val argb: Int, val label: String)
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose.format
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.libremail.R
|
||||
|
||||
/**
|
||||
* A horizontal row of color swatches with a leading "no color" entry, shared by the font-color and
|
||||
* highlight pickers. The selected swatch (or the "no color" entry when [selectedArgb] is null)
|
||||
* shows a primary-colored ring; every swatch is a labeled button for TalkBack.
|
||||
*/
|
||||
@Composable
|
||||
fun ColorSwatchRow(
|
||||
swatches: List<ColorSwatch>,
|
||||
selectedArgb: Int?,
|
||||
onSelect: (Int?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
noneLabel: String = stringResource(R.string.format_color_none),
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Swatch(color = null, label = noneLabel, isSelected = selectedArgb == null, onClick = { onSelect(null) })
|
||||
swatches.forEach { swatch ->
|
||||
Swatch(
|
||||
color = Color(swatch.argb),
|
||||
label = swatch.label,
|
||||
isSelected = selectedArgb == swatch.argb,
|
||||
onClick = { onSelect(swatch.argb) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Swatch(color: Color?, label: String, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val ring = if (isSelected) colors.primary else colors.outlineVariant
|
||||
val slash = colors.outline
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color ?: Color.Transparent)
|
||||
.border(if (isSelected) 3.dp else 1.dp, ring, CircleShape)
|
||||
.clickable(onClick = onClick, role = Role.Button)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
selected = isSelected
|
||||
},
|
||||
) {
|
||||
if (color == null) {
|
||||
Canvas(Modifier.matchParentSize()) {
|
||||
drawLine(
|
||||
color = slash,
|
||||
start = Offset(size.width * 0.25f, size.height * 0.75f),
|
||||
end = Offset(size.width * 0.75f, size.height * 0.25f),
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@
|
||||
<string name="action_more">More options</string>
|
||||
<string name="move_picker_title">Move to</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="action_yes">Yes</string>
|
||||
<string name="action_no">No</string>
|
||||
<string name="confirm_spam_title">Move to Spam?</string>
|
||||
<string name="confirm_spam_text">Move %1$d message(s) to the Spam folder?</string>
|
||||
<string name="confirm_trash_title">Move to Trash?</string>
|
||||
@@ -53,6 +55,8 @@
|
||||
<string name="confirm_delete_text">This permanently deletes %1$d message(s) and can\'t be undone.</string>
|
||||
<string name="confirm_reply_all_title">Reply to all?</string>
|
||||
<string name="confirm_reply_all_text">Reply to everyone included on this message?</string>
|
||||
<string name="confirm_attachment_title">Need to attach anything?</string>
|
||||
<string name="confirm_attachment_text">Your message mentions an attachment, but nothing is attached.</string>
|
||||
|
||||
<!-- Folder navigation drawer -->
|
||||
<string name="drawer_open">Show folders</string>
|
||||
@@ -88,6 +92,7 @@
|
||||
<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>
|
||||
<string name="format_color_none">No color</string>
|
||||
|
||||
<!-- Drafts -->
|
||||
<string name="drafts_title">Drafts</string>
|
||||
|
||||
@@ -4,6 +4,7 @@ package org.libremail.richtext
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RichTextEditingTest {
|
||||
@@ -11,23 +12,76 @@ 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 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)
|
||||
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)
|
||||
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)),
|
||||
listOf(RichSpan(0, 1, RichStyle.Bold), RichSpan(3, 5, RichStyle.Bold)),
|
||||
result.spans.sortedBy { it.start },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleStyle with a different value of the same kind replaces it`() {
|
||||
val base = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.FontSize(12))))
|
||||
val resized = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.FontSize(18))
|
||||
assertEquals(listOf(RichSpan(0, 5, RichStyle.FontSize(18))), resized.spans)
|
||||
|
||||
val removed = RichTextEditing.toggleStyle(resized, 0, 5, RichStyle.FontSize(18))
|
||||
assertTrue(removed.spans.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleStyle replaces only the selected part of an old value`() {
|
||||
val base = RichTextContent("abcdef", spans = listOf(RichSpan(0, 6, RichStyle.FontColor(1))))
|
||||
val result = RichTextEditing.toggleStyle(base, 2, 4, RichStyle.FontColor(2))
|
||||
assertEquals(
|
||||
listOf(
|
||||
RichSpan(0, 2, RichStyle.FontColor(1)),
|
||||
RichSpan(2, 4, RichStyle.FontColor(2)),
|
||||
RichSpan(4, 6, RichStyle.FontColor(1)),
|
||||
),
|
||||
result.spans,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `distinct style kinds toggle independently`() {
|
||||
val base = RichTextContent("hello")
|
||||
val styled = RichTextEditing.toggleStyle(
|
||||
RichTextEditing.toggleStyle(base, 0, 5, RichStyle.Bold),
|
||||
0,
|
||||
5,
|
||||
RichStyle.Strikethrough,
|
||||
)
|
||||
assertEquals(
|
||||
setOf(RichSpan(0, 5, RichStyle.Bold), RichSpan(0, 5, RichStyle.Strikethrough)),
|
||||
styled.spans.toSet(),
|
||||
)
|
||||
assertTrue(RichTextEditing.isStyled(styled, 0, 5, RichStyle.Strikethrough))
|
||||
assertTrue(RichTextEditing.isStyled(styled, 0, 5, RichStyle.Bold))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `styleAt reports the selection's single value or null when mixed`() {
|
||||
val content = RichTextContent(
|
||||
"abcd",
|
||||
spans = listOf(RichSpan(0, 2, RichStyle.FontSize(10)), RichSpan(2, 4, RichStyle.FontSize(14))),
|
||||
)
|
||||
assertEquals(RichStyle.FontSize(10), RichTextEditing.styleAt<RichStyle.FontSize>(content, 0, 2))
|
||||
assertNull(RichTextEditing.styleAt<RichStyle.FontSize>(content, 0, 4))
|
||||
assertEquals(RichStyle.FontSize(14), RichTextEditing.styleAt<RichStyle.FontSize>(content, 3, 3))
|
||||
assertNull(RichTextEditing.styleAt<RichStyle.FontColor>(content, 0, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyLink links the selection and removes overlapping links`() {
|
||||
val base = RichTextContent("see here")
|
||||
@@ -40,10 +94,10 @@ class RichTextEditingTest {
|
||||
|
||||
@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 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)
|
||||
assertEquals(listOf(RichSpan(2, 4, RichStyle.Bold)), result.content.spans)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -67,4 +121,19 @@ class RichTextEditingTest {
|
||||
val result = RichTextEditing.toggleBlock(base, 0, 3, BlockMarker.QUOTE)
|
||||
assertEquals("> a", result.content.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleBlock keeps and remaps the alignment image and base-style channels`() {
|
||||
val base = RichTextContent(
|
||||
text = "hi [image: x]",
|
||||
alignments = listOf(RichAlignment(0, 13, RichAlign.CENTER)),
|
||||
images = listOf(RichImage(3, 13, "c1", "x")),
|
||||
baseStyle = RichBaseStyle(fontSizePt = 12),
|
||||
)
|
||||
val result = RichTextEditing.toggleBlock(base, 0, 0, BlockMarker.BULLET)
|
||||
assertEquals("• hi [image: x]", result.content.text)
|
||||
assertEquals(listOf(RichAlignment(2, 15, RichAlign.CENTER)), result.content.alignments)
|
||||
assertEquals(listOf(RichImage(5, 15, "c1", "x")), result.content.images)
|
||||
assertEquals(base.baseStyle, result.content.baseStyle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ class RichTextHtmlTest {
|
||||
val content = RichTextContent(
|
||||
text = "bold italic under",
|
||||
spans = listOf(
|
||||
RichSpan(0, 4, RichStyle.BOLD),
|
||||
RichSpan(5, 11, RichStyle.ITALIC),
|
||||
RichSpan(12, 17, RichStyle.UNDERLINE),
|
||||
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))
|
||||
@@ -31,7 +31,7 @@ class RichTextHtmlTest {
|
||||
fun `overlapping styles stay valid html`() {
|
||||
val content = RichTextContent(
|
||||
text = "abcd",
|
||||
spans = listOf(RichSpan(0, 3, RichStyle.BOLD), RichSpan(1, 4, RichStyle.ITALIC)),
|
||||
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))
|
||||
@@ -61,14 +61,22 @@ class RichTextHtmlTest {
|
||||
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())
|
||||
assertTrue(RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.Bold))).hasFormatting())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasFormatting covers the alignment image and base style channels`() {
|
||||
assertTrue(RichTextContent("x", alignments = listOf(RichAlignment(0, 1, RichAlign.CENTER))).hasFormatting())
|
||||
assertTrue(RichTextContent("[image: a]", images = listOf(RichImage(0, 10, "cid1", "a"))).hasFormatting())
|
||||
assertTrue(RichTextContent("x", baseStyle = RichBaseStyle()).hasFormatting())
|
||||
assertFalse(RichTextContent("x").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("bold", spans = listOf(RichSpan(0, 4, RichStyle.Bold))),
|
||||
RichTextContent("• Milk\n• Eggs"),
|
||||
RichTextContent("1. One\n2. Two"),
|
||||
RichTextContent("> a\n> b"),
|
||||
@@ -95,11 +103,187 @@ class RichTextHtmlTest {
|
||||
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)
|
||||
assertEquals(listOf(RichSpan(8, 11, RichStyle.Bold)), restored.spans)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty content produces empty html`() {
|
||||
assertEquals("", RichTextHtml.toHtml(RichTextContent("")))
|
||||
}
|
||||
|
||||
// --- parameterized styles, alignment, images, base style (foundation for the formatting epic) ---
|
||||
|
||||
@Test
|
||||
fun `strikethrough renders as s and parses del and strike too`() {
|
||||
val content = RichTextContent("gone", spans = listOf(RichSpan(0, 4, RichStyle.Strikethrough)))
|
||||
assertEquals("<p><s>gone</s></p>", RichTextHtml.toHtml(content))
|
||||
listOf("<p><s>gone</s></p>", "<p><del>gone</del></p>", "<p><strike>gone</strike></p>").forEach { html ->
|
||||
assertEquals(content, RichTextHtml.fromHtml(html), html)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parameterized styles on one run merge into a single span tag`() {
|
||||
val content = RichTextContent(
|
||||
text = "ab",
|
||||
spans = listOf(
|
||||
RichSpan(0, 2, RichStyle.FontSize(14)),
|
||||
RichSpan(0, 2, RichStyle.FontColor(0xFFFF0000.toInt())),
|
||||
RichSpan(0, 2, RichStyle.Highlight(0xFFFFFF00.toInt())),
|
||||
RichSpan(0, 2, RichStyle.FontFamily("Georgia, serif")),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"<p><span style=\"font-family:Georgia, serif;font-size:14pt;color:#ff0000;" +
|
||||
"background-color:#ffff00\">ab</span></p>",
|
||||
RichTextHtml.toHtml(content),
|
||||
)
|
||||
assertRoundTrips(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every new style and channel round-trips and keeps hasFormatting true`() {
|
||||
listOf(
|
||||
RichTextContent("struck", spans = listOf(RichSpan(0, 6, RichStyle.Strikethrough))),
|
||||
RichTextContent("serif", spans = listOf(RichSpan(0, 5, RichStyle.FontFamily("Georgia, serif")))),
|
||||
RichTextContent("sized", spans = listOf(RichSpan(0, 5, RichStyle.FontSize(18)))),
|
||||
RichTextContent("red", spans = listOf(RichSpan(0, 3, RichStyle.FontColor(0xFFCC0000.toInt())))),
|
||||
RichTextContent("hi", spans = listOf(RichSpan(0, 2, RichStyle.Highlight(0xFFFFFF00.toInt())))),
|
||||
RichTextContent(
|
||||
text = "mixed run",
|
||||
spans = listOf(
|
||||
RichSpan(0, 5, RichStyle.Bold),
|
||||
RichSpan(2, 9, RichStyle.FontSize(12)),
|
||||
RichSpan(2, 5, RichStyle.FontColor(0xFF336699.toInt())),
|
||||
),
|
||||
),
|
||||
RichTextContent("left\ncentered", alignments = listOf(RichAlignment(5, 13, RichAlign.CENTER))),
|
||||
RichTextContent("a\nb\nc", alignments = listOf(RichAlignment(0, 3, RichAlign.END))),
|
||||
RichTextContent("• Milk\n• Eggs", alignments = listOf(RichAlignment(0, 13, RichAlign.CENTER))),
|
||||
RichTextContent(
|
||||
text = "see [image: cat.png] here",
|
||||
images = listOf(RichImage(4, 20, "img1@libremail", "cat.png")),
|
||||
),
|
||||
RichTextContent("plain", baseStyle = RichBaseStyle(fontCss = "Arial, sans-serif", fontSizePt = 12)),
|
||||
RichTextContent("plain", baseStyle = RichBaseStyle(fontSizePt = 11)),
|
||||
RichTextContent(
|
||||
text = "• item\nnote [image: dog.png]",
|
||||
spans = listOf(RichSpan(2, 6, RichStyle.Bold), RichSpan(7, 11, RichStyle.FontSize(10))),
|
||||
alignments = listOf(RichAlignment(0, 6, RichAlign.CENTER)),
|
||||
images = listOf(RichImage(12, 28, "dog@mail", "dog.png")),
|
||||
baseStyle = RichBaseStyle("Georgia, serif", 12),
|
||||
),
|
||||
).forEach(::assertRoundTrips)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alignment splits merged paragraphs and marks list items`() {
|
||||
val paragraphs = RichTextContent("a\nb", alignments = listOf(RichAlignment(2, 3, RichAlign.CENTER)))
|
||||
assertEquals("<p>a</p><p style=\"text-align:center\">b</p>", RichTextHtml.toHtml(paragraphs))
|
||||
val list = RichTextContent("• Milk\n• Eggs", alignments = listOf(RichAlignment(0, 13, RichAlign.CENTER)))
|
||||
assertEquals(
|
||||
"<ul><li style=\"text-align:center\">Milk</li><li style=\"text-align:center\">Eggs</li></ul>",
|
||||
RichTextHtml.toHtml(list),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank line between differently aligned paragraphs survives round-trip`() {
|
||||
val content = RichTextContent(
|
||||
text = "a\n\nb",
|
||||
alignments = listOf(RichAlignment(0, 1, RichAlign.CENTER), RichAlignment(3, 4, RichAlign.CENTER)),
|
||||
)
|
||||
// The empty middle group must emit an explicit <br> or the blank line vanishes on parse.
|
||||
assertEquals(
|
||||
"<p style=\"text-align:center\">a</p><p><br></p><p style=\"text-align:center\">b</p>",
|
||||
RichTextHtml.toHtml(content),
|
||||
)
|
||||
assertRoundTrips(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser accepts start and end alignment synonyms`() {
|
||||
val restored = RichTextHtml.fromHtml("<p style=\"text-align:start\">a</p><p style=\"text-align:end\">b</p>")
|
||||
assertEquals("a\nb", restored.text)
|
||||
assertEquals(
|
||||
listOf(RichAlignment(0, 1, RichAlign.START), RichAlignment(2, 3, RichAlign.END)),
|
||||
restored.alignments,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `images replace their token and keep surrounding style wrappers`() {
|
||||
val content = RichTextContent(
|
||||
text = "[image: pic]",
|
||||
spans = listOf(RichSpan(0, 12, RichStyle.Bold)),
|
||||
links = listOf(RichLink(0, 12, "http://x")),
|
||||
images = listOf(RichImage(0, 12, "c1", "pic")),
|
||||
)
|
||||
assertEquals(
|
||||
"<p><a href=\"http://x\"><b><img src=\"cid:c1\" alt=\"pic\"></b></a></p>",
|
||||
RichTextHtml.toHtml(content),
|
||||
)
|
||||
assertRoundTrips(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser converts px sizes and short hex colors`() {
|
||||
val restored = RichTextHtml.fromHtml("<p><span style=\"font-size:16px;color:#f00\">x</span></p>")
|
||||
assertEquals(
|
||||
setOf(
|
||||
RichSpan(0, 1, RichStyle.FontSize(12)),
|
||||
RichSpan(0, 1, RichStyle.FontColor(0xFFFF0000.toInt())),
|
||||
),
|
||||
restored.spans.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown css properties are ignored without dropping the run`() {
|
||||
val restored = RichTextHtml.fromHtml("<p><span style=\"mso-spacerun:yes;letter-spacing:2px\">kept</span></p>")
|
||||
assertEquals("kept", restored.text)
|
||||
assertTrue(restored.spans.isEmpty(), restored.spans.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `font families with quotes round-trip through attribute escaping`() {
|
||||
val family = RichStyle.FontFamily("\"Open Sans\", sans-serif")
|
||||
val content = RichTextContent("x", spans = listOf(RichSpan(0, 1, family)))
|
||||
val html = RichTextHtml.toHtml(content)
|
||||
assertTrue(html.contains(""Open Sans""), html)
|
||||
assertRoundTrips(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base style wrapper adds no stray text or newlines`() {
|
||||
val restored = RichTextHtml.fromHtml("<div style=\"font-size:12pt\"><p>a<br>b</p></div>")
|
||||
assertEquals("a\nb", restored.text)
|
||||
assertEquals(RichBaseStyle(fontSizePt = 12), restored.baseStyle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base style survives empty text`() {
|
||||
val content = RichTextContent("", baseStyle = RichBaseStyle(fontCss = "Georgia, serif"))
|
||||
val html = RichTextHtml.toHtml(content)
|
||||
assertEquals("<div style=\"font-family:Georgia, serif\"></div>", html)
|
||||
assertEquals(content, RichTextHtml.fromHtml(html))
|
||||
}
|
||||
|
||||
/**
|
||||
* The strict guarantee every channel needs: serialize, parse back, get the same model, and stay
|
||||
* "formatted" — ComposeViewModel.normalizedHtml() silently drops any HTML that parses back as
|
||||
* unformatted, so a violation here means user formatting is destroyed on From-account switches.
|
||||
*/
|
||||
private fun assertRoundTrips(original: RichTextContent) {
|
||||
assertTrue(original.hasFormatting(), "hasFormatting must be true for: $original")
|
||||
val html = RichTextHtml.toHtml(original)
|
||||
val restored = RichTextHtml.fromHtml(html)
|
||||
assertEquals(original.text, restored.text, "text of: $html")
|
||||
assertEquals(original.spans.toSet(), restored.spans.toSet(), "spans of: $html")
|
||||
assertEquals(original.links.toSet(), restored.links.toSet(), "links of: $html")
|
||||
assertEquals(original.alignments, restored.alignments, "alignments of: $html")
|
||||
assertEquals(original.images, restored.images, "images of: $html")
|
||||
assertEquals(original.baseStyle, restored.baseStyle, "baseStyle of: $html")
|
||||
assertTrue(restored.hasFormatting(), "formatting must survive reparsing: $html")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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.OutgoingAttachment
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.domain.model.Signature
|
||||
@@ -31,6 +32,7 @@ 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.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -153,6 +155,27 @@ class ComposeViewModelTest {
|
||||
assertEquals("<p>Draft <b>body</b></p>", vm.state.value.bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resuming a draft restores the bcc recipients`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(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<MailRepository>(relaxed = true)
|
||||
@@ -211,4 +234,119 @@ class ComposeViewModelTest {
|
||||
coVerify { mailRepository.sendMessage(capture(sent)) }
|
||||
assertEquals("secret@example.org", sent.captured.bcc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asks about attachments when the body mentions one but none is attached`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("I attached the report.", null)
|
||||
vm.send()
|
||||
|
||||
assertTrue(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asks about attachments when only the subject mentions one`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onSubjectChange("Contract attachment")
|
||||
vm.send()
|
||||
|
||||
assertTrue(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches attachment variants but not lookalike words`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
|
||||
listOf("Attached is the file", "attaching it now", "see the ATTACHMENTS", "can you attach it").forEach {
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange(it, null)
|
||||
vm.send()
|
||||
assertTrue(vm.state.value.showAttachmentPrompt, "should prompt for: $it")
|
||||
}
|
||||
|
||||
listOf("planning an attack", "the base is attachable", "no keyword here").forEach {
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange(it, null)
|
||||
vm.send()
|
||||
assertFalse(vm.state.value.showAttachmentPrompt, "should not prompt for: $it")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sends without asking when an attachment is present`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("The report is attached.", null)
|
||||
vm.addAttachments(listOf(OutgoingAttachment("content://docs/report.pdf", "report.pdf")))
|
||||
vm.send()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 1) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendAnyway sends the message the prompt held back`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("I attached the report.", null)
|
||||
vm.send()
|
||||
assertTrue(vm.state.value.showAttachmentPrompt)
|
||||
|
||||
vm.sendAnyway()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 1) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachInstead returns to composing and highlights the attach button`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("Attachment coming.", null)
|
||||
vm.send()
|
||||
|
||||
vm.attachInstead()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
assertTrue(vm.state.value.highlightAttach)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
|
||||
vm.consumeAttachHighlight()
|
||||
assertFalse(vm.state.value.highlightAttach)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissing the prompt cancels the send without highlighting`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("See attached.", null)
|
||||
vm.send()
|
||||
|
||||
vm.dismissAttachmentPrompt()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
assertFalse(vm.state.value.highlightAttach)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# F-Droid compliance
|
||||
|
||||
Audit for issue #16, performed 2026-07-01 against `main` (versionName 0.1.0 /
|
||||
versionCode 1). **Verdict: LibreMail meets F-Droid's inclusion criteria with no
|
||||
anti-features to declare.** Every runtime dependency is FOSS-licensed and
|
||||
GPL-3.0-or-later-compatible, there are no Google Play Services / Firebase /
|
||||
proprietary artifacts, no non-free Gradle plugins, and a clean-room build (no
|
||||
`secrets.properties`, no proprietary keys) produces an installable APK.
|
||||
|
||||
Companion deliverables:
|
||||
|
||||
- `fastlane/metadata/android/en-US/` — the store listing F-Droid reads from this repo.
|
||||
- `docs/fdroid/org.libremail.app.yml` — template + instructions for the build recipe
|
||||
that goes into [fdroiddata](https://gitlab.com/fdroid/fdroiddata).
|
||||
|
||||
## 1. Dependency license audit
|
||||
|
||||
### 1.1 Runtime dependencies (what ships in the APK)
|
||||
|
||||
Enumerated with `./gradlew :app:dependencies --configuration releaseRuntimeClasspath`.
|
||||
Direct dependencies, with the resolved versions at audit time:
|
||||
|
||||
| Dependency | Version | License | Role |
|
||||
|---|---|---|---|
|
||||
| `androidx.core:core-ktx` | 1.17.0 | Apache-2.0 | AndroidX core |
|
||||
| `androidx.lifecycle:lifecycle-runtime-ktx` / `-runtime-compose` / `-viewmodel-compose` | 2.9.4 | Apache-2.0 | Lifecycle/MVVM |
|
||||
| `androidx.activity:activity-compose` | 1.12.4 | Apache-2.0 | Compose host activity |
|
||||
| `androidx.navigation:navigation-compose` | 2.9.8 | Apache-2.0 | Navigation |
|
||||
| `androidx.compose.*` (BOM 2026.06.00: ui, ui-graphics, material3, material-icons-core, …) | 1.11.3 / m3 1.4.0 | Apache-2.0 | UI toolkit |
|
||||
| `androidx.webkit:webkit` | 1.12.1 | Apache-2.0 | Hardened WebView compat |
|
||||
| `androidx.work:work-runtime-ktx` | 2.11.2 | Apache-2.0 | Background sync/outbox |
|
||||
| `androidx.datastore:datastore-preferences` | 1.2.1 | Apache-2.0 | Settings store |
|
||||
| `androidx.room:room-runtime` / `room-ktx` | 2.8.4 | Apache-2.0 | Local mail cache |
|
||||
| `androidx.hilt:hilt-navigation-compose` / `hilt-work` | 1.3.0 | Apache-2.0 | Hilt integrations |
|
||||
| `org.jetbrains.kotlin:kotlin-stdlib` | 2.4.0 | Apache-2.0 | Kotlin runtime |
|
||||
| `org.jetbrains.kotlinx:kotlinx-coroutines-android` | 1.10.2 | Apache-2.0 | Coroutines |
|
||||
| `com.google.dagger:hilt-android` (Dagger/Hilt) | 2.60 | Apache-2.0 | Dependency injection |
|
||||
| `org.eclipse.angus:angus-mail` (+ `angus-activation`) | 2.0.5 / 2.0.3 | EPL-2.0 OR GPL-2.0 w/ Classpath-exception OR EDL-1.0 (BSD-3-Clause) | IMAP/SMTP transport |
|
||||
| `net.openid:appauth` | 0.11.1 | Apache-2.0 | OAuth 2.0 + PKCE (Outlook) |
|
||||
| `net.zetetic:sqlcipher-android` | 4.16.0 | BSD-3-Clause-style (SQLCipher Community Edition) | Opt-in cache encryption |
|
||||
|
||||
Transitive dependencies, grouped (full tree available from the Gradle command above):
|
||||
|
||||
| Group | License | Notes |
|
||||
|---|---|---|
|
||||
| `androidx.*` (~60 artifacts: appcompat, browser, collection, emoji2, fragment, savedstate, sqlite, startup, tracing, window, …) | Apache-2.0 | AndroidX |
|
||||
| `org.jetbrains.*` (kotlin-stdlib, kotlinx-coroutines, kotlinx-serialization, annotations) | Apache-2.0 | JetBrains |
|
||||
| `com.google.dagger:*` (dagger, hilt-core, dagger-lint-aar) | Apache-2.0 | via Hilt |
|
||||
| `com.google.code.findbugs:jsr305` 3.0.2 | Apache-2.0 | annotations only |
|
||||
| `com.google.guava:listenablefuture` 1.0 | Apache-2.0 | empty stub artifact (not Guava) |
|
||||
| `com.squareup.okio:okio` 3.9.1 | Apache-2.0 | via DataStore |
|
||||
| `jakarta.mail:jakarta.mail-api` 2.1.5 | EPL-2.0 OR GPL-2.0 w/ CPE OR EDL-1.0 | via Angus Mail |
|
||||
| `jakarta.activation:jakarta.activation-api` 2.1.4 | EDL-1.0 (BSD-3-Clause) | via Angus Mail |
|
||||
| `jakarta.inject:jakarta.inject-api` 2.0.1, `javax.inject:javax.inject` 1 | Apache-2.0 | DI annotations |
|
||||
| `org.jspecify:jspecify` 1.0.0 | Apache-2.0 | nullness annotations |
|
||||
|
||||
**GPL compatibility.** Everything is Apache-2.0 or BSD-3-Clause except the
|
||||
Jakarta/Angus mail stack, which is tri-licensed; LibreMail uses it under the
|
||||
EDL-1.0 (BSD-3-Clause) / GPL-2.0-with-Classpath-exception options, both of which are
|
||||
GPL-3.0-or-later-compatible. **No proprietary, source-unavailable, or
|
||||
"free for open source use only" artifact appears anywhere in the tree.** In
|
||||
particular there is **no** `com.google.android.gms:*` (Play Services), **no**
|
||||
`com.google.firebase:*`, no Play Billing/Install Referrer, and no analytics or
|
||||
crash-reporting SDK.
|
||||
|
||||
Native libraries in the release APK — all from the audited dependencies above:
|
||||
`libsqlcipher.so` (SQLCipher), `libdatastore_shared_counter.so` (AndroidX DataStore),
|
||||
`libandroidx.graphics.path.so` (AndroidX, via Compose).
|
||||
|
||||
### 1.2 Build-time dependencies (never ship in the APK)
|
||||
|
||||
Enumerated with `./gradlew buildEnvironment :app:buildEnvironment`:
|
||||
|
||||
| Plugin / tool | License |
|
||||
|---|---|
|
||||
| Android Gradle Plugin 9.2.x (`com.android.tools.*`) | Apache-2.0 |
|
||||
| Kotlin Gradle plugin + Compose compiler 2.4.0 | Apache-2.0 |
|
||||
| KSP 2.3.9 | Apache-2.0 |
|
||||
| Hilt Gradle plugin 2.60 | Apache-2.0 |
|
||||
| ktlint-gradle 14.2.0 (`org.jlleitschuh.gradle`) | MIT |
|
||||
| detekt 2.0.0-alpha.5 (`dev.detekt`) | Apache-2.0 |
|
||||
|
||||
Their transitive tooling deps (protobuf, Tink, flatbuffers, bouncycastle,
|
||||
juniversalchardet, jose4j, …) are Apache-2.0/MIT/MPL — all FOSS. **No non-free
|
||||
Gradle plugin is used** (no Play Publisher, no Crashlytics/Google Services plugin,
|
||||
no proprietary obfuscator; R8 ships with AGP and is Apache-2.0).
|
||||
|
||||
Artifacts resolve exclusively from open repositories: `google()` and
|
||||
`mavenCentral()` (plus `gradlePluginPortal()` for the lint/format plugins).
|
||||
`gradle-wrapper.jar` is the standard Gradle 9.6 wrapper (Apache-2.0), verifiable
|
||||
against the official distribution.
|
||||
|
||||
### 1.3 APK payload hygiene
|
||||
|
||||
AGP by default embeds a *dependency info block* in the APK signing block: a list of
|
||||
every dependency **encrypted with a Google Play public key**, readable only by
|
||||
Google. That opaque blob is a known F-Droid blocker (it cannot be verified from
|
||||
source and breaks reproducible-build verification), so this repo disables it in
|
||||
`app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Anti-feature review
|
||||
|
||||
Reviewed against the [F-Droid anti-feature list](https://f-droid.org/docs/Anti-Features/),
|
||||
based on the manifest and source at audit time. **Declared anti-features: none.**
|
||||
|
||||
| Anti-feature | Verdict | Reasoning |
|
||||
|---|---|---|
|
||||
| `Ads` | Clear | No advertising of any kind. |
|
||||
| `Tracking` | Clear | No analytics/telemetry SDK; no identifiers are collected. Debug reporting is off by default, local-only, user-reviewed, and user-submitted (§2.1). |
|
||||
| `NonFreeNet` | Clear | Generic IMAP/SMTP client, fully functional against free-software mail servers; the Microsoft integration is optional and user-chosen (§2.2). |
|
||||
| `NonFreeAdd` | Clear | No add-ons; nothing is upsold. |
|
||||
| `NonFreeDep` | Clear | Dependency audit in §1: every dependency is FOSS. |
|
||||
| `NonFreeAssets` | Clear | All assets are first-party vector drawables carrying the repo's GPL SPDX headers; no bundled proprietary art, fonts, or blobs. |
|
||||
| `NSFW` | Clear | N/A. |
|
||||
| `UpstreamNonFree` | Clear | This repo is the upstream and is wholly GPL-3.0-or-later. |
|
||||
| `KnownVuln` | Clear | No dependency with a known security vulnerability is pinned at audit time (all on current stable lines). |
|
||||
| `ApplicationDebuggable` | Clear | Release builds are non-debuggable (AGP default) and R8-minified. |
|
||||
| `TetheredNet` | Clear | No tethered/proprietary backend; the app talks to the user's own mail servers. |
|
||||
| `NoSourceSince` | Clear | N/A — source is published. |
|
||||
|
||||
### 2.1 Debug reporting is not `Tracking`
|
||||
|
||||
The crash/debug-report pipeline (`org.libremail.reporting.*`) is designed to stay on
|
||||
the right side of F-Droid's Tracking definition ("reports user activity ... without
|
||||
consent"):
|
||||
|
||||
- **Off by default.** Nothing is captured until the user enables debug reporting.
|
||||
- **Local capture only.** Reports (app/OS version, device model, stack trace, a
|
||||
non-PII settings summary, recent in-app log lines) are stored on-device.
|
||||
`DiagnosticsCollector` deliberately collects no account emails, server names,
|
||||
message content, or hardware/advertising identifiers.
|
||||
- **User-initiated, reviewed submission.** A report leaves the device only when the
|
||||
user opens it, sees the full contents plus a PII disclaimer, and taps Submit
|
||||
(`ReportSubmitter` is the single egress seam).
|
||||
- **No endpoint in F-Droid builds.** The ingest URL comes from
|
||||
`BuildConfig.DEBUG_REPORT_ENDPOINT`, default **empty** (settable only via the
|
||||
git-ignored `secrets.properties`). An F-Droid build therefore *cannot* transmit a
|
||||
report anywhere; the UI steers users to copy/save the report instead.
|
||||
|
||||
### 2.2 Outlook OAuth / Microsoft Graph is not `NonFreeNet`
|
||||
|
||||
`NonFreeNet` applies to apps that *promote or depend entirely on* a non-free network
|
||||
service. LibreMail is a general-purpose email client: it works fully against any
|
||||
IMAP/SMTP server, including self-hosted free-software stacks (Dovecot/Postfix, …),
|
||||
and no Microsoft endpoint is ever contacted unless the user adds an
|
||||
Outlook/Microsoft account. For transparency:
|
||||
|
||||
- Adding an Outlook account uses OAuth 2.0 + PKCE against
|
||||
`login.microsoftonline.com` and sends via Microsoft Graph
|
||||
(`graph.microsoft.com`), with SMTP/XOAUTH2 fallback — proprietary services, but
|
||||
the *user's own mailbox provider*, exactly like connecting to any other mail host.
|
||||
- The build bundles a default Azure **public client id** (`OUTLOOK_OAUTH_CLIENT_ID`
|
||||
in `app/build.gradle.kts`). A public-client id is an identifier, not a secret or a
|
||||
key, and is overridable via `secrets.properties`. This mirrors what established
|
||||
F-Droid mail clients (K-9 Mail / Thunderbird) ship for Gmail/Outlook OAuth without
|
||||
a `NonFreeNet` flag.
|
||||
- Gmail/Yahoo/iCloud accounts use plain app-password IMAP/SMTP — no proprietary
|
||||
SDK. Onboarding links to each vendor's app-password page open in the system
|
||||
browser only on an explicit tap.
|
||||
|
||||
Should F-Droid reviewers read the built-in Outlook convenience differently,
|
||||
declaring `NonFreeNet` on the fdroiddata side is the documented fallback; nothing
|
||||
in the app needs to change.
|
||||
|
||||
### 2.3 Android Backup (Google transport) is opt-in
|
||||
|
||||
`android:allowBackup="true"` is required at the manifest level, but
|
||||
`LibreMailBackupAgent` enforces the runtime preference: **backup is off by
|
||||
default**, and with it disabled the agent ships nothing. When the user opts in, the
|
||||
allowlist in `res/xml/data_extraction_rules.xml` / `backup_rules.xml` backs up
|
||||
*only* the settings DataStore — never credentials, the mail cache, or the
|
||||
Keystore-sealed cache passphrase. Because Android Auto Backup can route through
|
||||
Google's transport, the feature stays disabled unless explicitly chosen; F-Droid
|
||||
has no anti-feature for opt-in platform backup.
|
||||
|
||||
### 2.4 Complete network surface
|
||||
|
||||
| Destination | When | Consent |
|
||||
|---|---|---|
|
||||
| User-configured IMAP/SMTP servers | Mail sync/send | Inherent (user adds the account) |
|
||||
| `login.microsoftonline.com` | Outlook sign-in / token refresh | Only if an Outlook account is added |
|
||||
| `graph.microsoft.com` (`sendMail`) | Outlook send | Only if an Outlook account is added |
|
||||
| Vendor app-password help pages (Google/Yahoo/Apple) | Opened in the system browser | Explicit tap during setup |
|
||||
| Remote images in HTML mail | Blocked by default | Per-user opt-in (tracking-pixel protection) |
|
||||
| `DEBUG_REPORT_ENDPOINT` | Debug-report submission | Empty by default → impossible; otherwise explicit Submit tap |
|
||||
|
||||
No other endpoint exists in the code; there is no update checker, no push relay
|
||||
(new-mail notifications are generated on-device; instant push is a direct IMAP IDLE
|
||||
connection to the user's server), and no font/asset CDN.
|
||||
|
||||
## 3. Clean-room build verification
|
||||
|
||||
Verified 2026-07-01 on this branch, in a checkout containing **no
|
||||
`secrets.properties`** (and no other proprietary keys — the file is optional by
|
||||
design; `OUTLOOK_OAUTH_CLIENT_ID` has an in-tree default and
|
||||
`DEBUG_REPORT_ENDPOINT` defaults to empty):
|
||||
|
||||
```
|
||||
$ JAVA_HOME=<JDK 21> ./gradlew :app:assembleRelease
|
||||
BUILD SUCCESSFUL
|
||||
app/build/outputs/apk/release/app-release.apk (~12.6 MiB)
|
||||
```
|
||||
|
||||
The APK is installable: with no release keystore configured, release builds are
|
||||
signed with the debug key (see `signingConfigs` in `app/build.gradle.kts`) — fine
|
||||
for local testing and irrelevant to F-Droid, which builds from source and signs
|
||||
with its own key. APK contents were inspected: single `classes.dex`, resources,
|
||||
and the three native libraries listed in §1.1 — no bundled binaries of unknown
|
||||
origin. `./gradlew :app:assembleDebug`, the unit tests, static analysis
|
||||
(ktlint/detekt), and the emulator E2E suites run on every PR in CI, likewise
|
||||
without any secrets configured.
|
||||
|
||||
## 4. Publishing checklist (for the maintainer)
|
||||
|
||||
1. Tag releases `v<versionName>` **and bump `versionCode`** in
|
||||
`app/build.gradle.kts` in the same commit. (At audit time tags `v0.1.0` and
|
||||
`v0.2.0` both point at versionCode 1 / versionName 0.1.0 — F-Droid's
|
||||
`UpdateCheckMode: Tags` needs the code to increase per release tag.)
|
||||
2. Copy `docs/fdroid/org.libremail.app.yml` into a fork of fdroiddata as
|
||||
`metadata/org.libremail.app.yml`, run `fdroid lint org.libremail.app` and a test
|
||||
`fdroid build`, then open the merge request.
|
||||
3. The store listing (title/short/full description, per-release changelogs) is read
|
||||
from `fastlane/metadata/android/en-US/` in this repo — add a
|
||||
`changelogs/<versionCode>.txt` for each release, and optionally
|
||||
`images/phoneScreenshots/`.
|
||||
4. Keep this document current when dependencies or network behaviors change; if a
|
||||
future feature genuinely trips an anti-feature, declare it in the fdroiddata
|
||||
metadata rather than hiding it.
|
||||
@@ -0,0 +1,49 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# TEMPLATE for LibreMail's F-Droid build recipe ("app metadata").
|
||||
#
|
||||
# The real recipe does NOT live in this repository: F-Droid builds every app from a
|
||||
# metadata file kept in the fdroiddata repo (https://gitlab.com/fdroid/fdroiddata) at
|
||||
# metadata/org.libremail.app.yml. To submit LibreMail, fork fdroiddata, copy this file
|
||||
# there (dropping this comment block — `fdroid rewritemeta` strips comments anyway),
|
||||
# validate it, and open a merge request:
|
||||
#
|
||||
# fdroid readmeta # parse check
|
||||
# fdroid lint org.libremail.app # style/policy check
|
||||
# fdroid build -v -l org.libremail.app # test build (needs the Android SDK)
|
||||
#
|
||||
# Notes for the submitter:
|
||||
# - Summary/Description/changelogs are deliberately NOT set here: F-Droid pulls the
|
||||
# localized store listing from this repo's fastlane/metadata/android/ tree.
|
||||
# - Each release must be git-tagged (v<versionName>) AND bump versionCode in
|
||||
# app/build.gradle.kts; UpdateCheckMode: Tags matches tags against versionCode.
|
||||
# - The build needs no secrets.properties: the Outlook OAuth client id (a public,
|
||||
# non-secret GUID) has an in-tree default, and the debug-report endpoint defaults
|
||||
# to empty (reports then cannot be submitted anywhere). See docs/fdroid-compliance.md.
|
||||
# - JDK: 17-21 (AGP 9.x does not support JDK 25).
|
||||
|
||||
Categories:
|
||||
- Internet
|
||||
License: GPL-3.0-or-later
|
||||
AuthorName: Jason Ross
|
||||
SourceCode: https://github.com/JMR-dev/LibreMail
|
||||
IssueTracker: https://github.com/JMR-dev/LibreMail/issues
|
||||
Changelog: https://github.com/JMR-dev/LibreMail/releases
|
||||
|
||||
AutoName: LibreMail
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/JMR-dev/LibreMail.git
|
||||
|
||||
Builds:
|
||||
- versionName: 0.1.0
|
||||
versionCode: 1
|
||||
commit: v0.1.0
|
||||
subdir: app
|
||||
gradle:
|
||||
- yes
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags ^v[0-9]+\.[0-9]+\.[0-9]+$
|
||||
CurrentVersion: 0.1.0
|
||||
CurrentVersionCode: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
First release of LibreMail.
|
||||
|
||||
* Outlook/Microsoft (OAuth 2.0), Gmail/Yahoo/iCloud (app password), and generic IMAP/SMTP accounts
|
||||
* Offline-first mail cache with full-history backfill and background sync
|
||||
* Rich-text compose, drafts, signatures, attachments, and a reliable outbox
|
||||
* Unified inbox across multiple accounts, local + server search
|
||||
* New-mail notifications and optional IMAP IDLE instant push
|
||||
* Hardened HTML reader: JavaScript off, remote images blocked by default
|
||||
@@ -0,0 +1,23 @@
|
||||
LibreMail is a free and open-source email client for Android, built with Kotlin, Jetpack Compose and Material 3 (Material You). It aims for a friendly default experience with power-user features tucked under an Advanced Settings group.
|
||||
|
||||
Features:
|
||||
|
||||
* Send and receive email with Outlook/Microsoft (OAuth 2.0), Gmail, Yahoo and iCloud (app password), and any other IMAP/SMTP provider
|
||||
* Guided first-run onboarding: welcome, vendor picker, per-vendor setup, then straight to your inbox
|
||||
* Offline-first: a local mail cache with full-history backfill (resumable) and an optional device-only retention limit
|
||||
* Multiple accounts with a unified inbox and per-account filtering
|
||||
* Rich-text compose with a formatting toolbar, per-account signatures, drafts, and a reliable background outbox
|
||||
* Message bodies rendered in a hardened WebView: JavaScript off, remote images blocked by default (tracking-pixel protection)
|
||||
* On-device new-mail notifications (no push service), with optional instant push via IMAP IDLE
|
||||
* Search across cached mail and the server (IMAP SEARCH)
|
||||
* Attachments: download on demand, open in a system viewer, and attach files when composing
|
||||
* mailto: link handling and share-to-email
|
||||
* Material You dynamic theming, light/dark, edge-to-edge
|
||||
|
||||
Privacy and security:
|
||||
|
||||
* No ads, no analytics SDK, no tracking. By default network traffic goes only to your mail providers.
|
||||
* Credentials are encrypted with the Android Keystore; OAuth uses Authorization Code + PKCE.
|
||||
* Optional (opt-in) extras: SQLCipher cache encryption, a biometric/device-credential app lock, Android Backup of settings only, and local-only debug reports that are sent nowhere unless you explicitly submit one.
|
||||
|
||||
LibreMail is licensed under the GNU General Public License v3.0 or later.
|
||||
@@ -0,0 +1 @@
|
||||
Privacy-respecting, offline-first email for any IMAP/SMTP provider
|
||||
@@ -0,0 +1 @@
|
||||
LibreMail
|
||||
Reference in New Issue
Block a user