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:
2026-07-01 22:18:50 -05:00
co-authored by Claude Opus 4.8
27 changed files with 2123 additions and 420 deletions
+12
View File
@@ -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
+10
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
private fun escapeAttr(s: String): String = escape(s).replace("\"", "&quot;")
private fun unescape(s: String): String = s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
internal fun escapeAttr(s: String): String = escape(s).replace("\"", "&quot;")
@@ -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("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
@@ -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(),
)
}
}
}
}
+5
View File
@@ -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("&quot;Open Sans&quot;"), 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()) }
}
}
+236
View File
@@ -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.
+49
View File
@@ -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