diff --git a/README.md b/README.md index 125c37d..029a8a3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c7b0e30..72c7c2f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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. diff --git a/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt b/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt new file mode 100644 index 0000000..8a546ff --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.notifications + +import android.content.Intent +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Locks in the notification deep-link contract: a message id round-trips build → parse, and intents + * for different messages are distinct under [Intent.filterEquals] — the identity PendingIntent keys + * on — so per-message notifications never collapse onto one shared PendingIntent. + */ +@RunWith(AndroidJUnit4::class) +class NotificationIntentsTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun message_id_round_trips_through_the_intent() { + val id = "imap:user@example.com:INBOX:42" + assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id))) + } + + @Test + fun uri_hostile_ids_round_trip() { + val id = "imap:user@example.com:[Gmail]/All Mail:7?&%#" + assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id))) + } + + @Test + fun other_intents_carry_no_message_id() { + assertNull(NotificationIntents.messageId(null)) + assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_MAIN))) + assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:a@b.c")))) + } + + @Test + fun intents_for_different_messages_are_distinct_pending_intent_keys() { + val first = NotificationIntents.openMessage(context, "imap:a@b:INBOX:1") + val second = NotificationIntents.openMessage(context, "imap:a@b:INBOX:2") + assertFalse(first.filterEquals(second)) + assertTrue(first.filterEquals(NotificationIntents.openMessage(context, "imap:a@b:INBOX:1"))) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt index 97777c4..25355e6 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt @@ -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()) } diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index 0b1caa9..35a1514 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint import org.libremail.data.settings.SettingsRepository +import org.libremail.notifications.NotificationIntents import org.libremail.ui.LibreMailApp import org.libremail.ui.compose.ComposePrefill import org.libremail.ui.compose.IntentComposeParser @@ -38,6 +39,12 @@ class MainActivity : ComponentActivity() { */ private val pendingCompose = mutableStateOf(null) + /** + * The message a tapped new-mail notification asks to open, consumed once by the NavHost. Compose + * state for the same reason as [pendingCompose]. + */ + private val pendingOpenMessageId = mutableStateOf(null) + override fun onStart() { super.onStart() // Foreground: recover IDLE push if a background start was previously blocked. @@ -47,10 +54,11 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - // Only on a fresh launch — on a config-change recreation the NavHost restores the compose - // destination itself, so re-parsing the (unchanged) intent would open a duplicate. + // Only on a fresh launch — on a config-change recreation the NavHost restores the compose / + // reader destination itself, so re-parsing the (unchanged) intent would open a duplicate. if (savedInstanceState == null) { pendingCompose.value = IntentComposeParser.parse(intent) + pendingOpenMessageId.value = NotificationIntents.messageId(intent) } setContent { val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true) @@ -59,6 +67,8 @@ class MainActivity : ComponentActivity() { LibreMailApp( pendingCompose = pendingCompose.value, onComposeHandled = { pendingCompose.value = null }, + pendingOpenMessageId = pendingOpenMessageId.value, + onOpenMessageHandled = { pendingOpenMessageId.value = null }, ) } } @@ -68,6 +78,7 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) IntentComposeParser.parse(intent)?.let { pendingCompose.value = it } + NotificationIntents.messageId(intent)?.let { pendingOpenMessageId.value = it } } } diff --git a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt index 0278886..529b6aa 100644 --- a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt +++ b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt @@ -35,7 +35,6 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context: if (messages.isEmpty() || !hasPermission()) return ensureAccountChannel(account) val manager = NotificationManagerCompat.from(context) - val contentIntent = contentIntent() val channelId = channelId(account.id) val groupKey = groupKey(account.id) val summaryId = summaryId(account.id) @@ -52,7 +51,7 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context: .setAutoCancel(true) .setOnlyAlertOnce(true) .setGroup(groupKey) - .setContentIntent(contentIntent) + .setContentIntent(openMessageIntent(message.id)) .build() manager.notify(notificationId(message.id, summaryId), notification) } @@ -72,7 +71,7 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context: .setOnlyAlertOnce(true) .setGroup(groupKey) .setGroupSummary(true) - .setContentIntent(contentIntent) + .setContentIntent(openAppIntent()) .build() manager.notify(summaryId, summary) } @@ -105,7 +104,16 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context: manager.deleteNotificationChannelGroup(accountId) } - private fun contentIntent(): PendingIntent { + /** Opens the tapped message's reader (each message's intent is distinct — see [NotificationIntents]). */ + private fun openMessageIntent(messageId: String): PendingIntent = PendingIntent.getActivity( + context, + 0, + NotificationIntents.openMessage(context, messageId), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + /** Just brings the app to the foreground — used by the group summary, which has no single message. */ + private fun openAppIntent(): PendingIntent { val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP } diff --git a/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt b/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt new file mode 100644 index 0000000..a27a40b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.notifications + +import android.content.Context +import android.content.Intent +import android.net.Uri +import org.libremail.MainActivity + +/** + * Builds and parses the intent behind a tapped per-message new-mail notification, keeping both sides + * of the contract ([MailNotifier] builds, MainActivity parses) in one place. + * + * The per-message `data` URI is load-bearing: PendingIntent identity ignores extras, so without a + * distinct URI every message's notification would collapse onto one FLAG_UPDATE_CURRENT PendingIntent + * and always open the most-recently-notified message. The intent is explicit (component set), so the + * private scheme needs no manifest intent-filter and adds no exported surface. + */ +object NotificationIntents { + + private const val ACTION_OPEN_MESSAGE = "org.libremail.action.OPEN_MESSAGE" + private const val EXTRA_MESSAGE_ID = "org.libremail.extra.MESSAGE_ID" + + fun openMessage(context: Context, messageId: String): Intent = Intent(context, MainActivity::class.java).apply { + action = ACTION_OPEN_MESSAGE + data = Uri.parse("libremail://message/${Uri.encode(messageId)}") + putExtra(EXTRA_MESSAGE_ID, messageId) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + /** The tapped message's id, or null for any other intent (launcher, mailto:, share, …). */ + fun messageId(intent: Intent?): String? = + intent?.takeIf { it.action == ACTION_OPEN_MESSAGE }?.getStringExtra(EXTRA_MESSAGE_ID) +} diff --git a/app/src/main/kotlin/org/libremail/richtext/RichText.kt b/app/src/main/kotlin/org/libremail/richtext/RichText.kt index 923ee13..4da265e 100644 --- a/app/src/main/kotlin/org/libremail/richtext/RichText.kt +++ b/app/src/main/kotlin/org/libremail/richtext/RichText.kt @@ -1,8 +1,33 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.richtext -/** Inline character styles the compose editor supports. */ -enum class RichStyle { BOLD, ITALIC, UNDERLINE } +/** + * Inline character styles the compose editor supports. The simple toggles are singletons; the + * parameterized styles carry their value, and a well-formed [RichTextContent] never overlaps two + * values of the same kind (editing ops replace the old value instead of stacking a second one). + */ +sealed interface RichStyle { + data object Bold : RichStyle + + data object Italic : RichStyle + + data object Underline : RichStyle + + /** Struck-through text: serialized as ``, also parsed from ``/``. */ + data object Strikethrough : RichStyle + + /** A CSS font-family stack (e.g. `"Liberation Serif", serif`), serialized verbatim. */ + data class FontFamily(val css: String) : RichStyle + + /** Font size in points; parsed from `pt` or `px` (px convert at 3/4 pt per px, rounded). */ + data class FontSize(val pt: Int) : RichStyle + + /** Text color as ARGB; serialized as `#rrggbb`, so only opaque colors round-trip. */ + data class FontColor(val argb: Int) : RichStyle + + /** Background highlight as ARGB; serialized as `#rrggbb`, so only opaque colors round-trip. */ + data class Highlight(val argb: Int) : RichStyle +} /** A run of [style] over the half-open range [[start], [end]) of the plain text. */ data class RichSpan(val start: Int, val end: Int, val style: RichStyle) @@ -10,8 +35,32 @@ data class RichSpan(val start: Int, val end: Int, val style: RichStyle) /** A hyperlink over the half-open range [[start], [end]) pointing at [url]. */ data class RichLink(val start: Int, val end: Int, val url: String) +/** Paragraph alignment (START is the writing-direction default). */ +enum class RichAlign { START, CENTER, END } + /** - * The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links]. + * Paragraph alignment over the half-open range [[start], [end]) of the plain text. Ranges cover + * whole lines (including any block marker), and adjacent same-aligned lines canonically share one + * range — [RichTextHtml.fromHtml] always returns that merged form. + */ +data class RichAlignment(val start: Int, val end: Int, val align: RichAlign) + +/** + * An inline image attached by Content-ID. [[start], [end]) covers a visible [imageToken] in the + * plain text (`[image: name]`), which keeps the text/plain rendering readable; the HTML form + * replaces the token with `name`. + */ +data class RichImage(val start: Int, val end: Int, val contentId: String, val name: String) + +/** A message-wide default font family and/or size, serialized as one outer `
` wrapper. */ +data class RichBaseStyle(val fontCss: String? = null, val fontSizePt: Int? = null) + +/** The visible plain-text placeholder for an inline image named [name]. */ +fun imageToken(name: String): String = "[image: $name]" + +/** + * The compose editor's internal rich-text model: plain [text] plus inline [spans], [links], + * paragraph [alignments], inline [images], and an optional message-wide [baseStyle]. * * Block structure (unordered/ordered lists and block quotes) is encoded as recognizable line * prefixes inside [text] — "• " for bullets, "N. " for numbered items, and "> " for quotes — so @@ -22,16 +71,24 @@ data class RichTextContent( val text: String = "", val spans: List = emptyList(), val links: List = emptyList(), + val alignments: List = emptyList(), + val images: List = emptyList(), + val baseStyle: RichBaseStyle? = null, ) { val isBlank: Boolean get() = text.isBlank() /** * True when the content carries anything a plaintext field could not represent: inline styling, - * a link, or a block marker. When false, callers should send/persist plaintext only so an - * unformatted message stays byte-for-byte identical to the old plaintext-only path. + * a link, a block marker, paragraph alignment, an inline image, or a base style. When false, + * callers should send/persist plaintext only so an unformatted message stays byte-for-byte + * identical to the old plaintext-only path. */ - fun hasFormatting(): Boolean = - spans.isNotEmpty() || links.isNotEmpty() || text.lineSequence().any { lineMarker(it) != null } + fun hasFormatting(): Boolean = spans.isNotEmpty() || + links.isNotEmpty() || + alignments.isNotEmpty() || + images.isNotEmpty() || + baseStyle != null || + text.lineSequence().any { lineMarker(it) != null } } /** Recognized block markers and the tags they map to. */ @@ -39,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 — `