diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..d1be1df --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,135 @@ + +# LibreMail Privacy Policy + +**Effective date: 2026-07-01** · Applies to the LibreMail Android app (`org.libremail.app`). + +LibreMail is a free and open-source (GPL-3.0-or-later) email client. This policy describes what +the app does with your data. Because the source code is public, every statement here can be +verified against the code at . + +## Summary + +- **We run no servers and receive no data from you.** The LibreMail project has no backend: the + app talks only to the email provider(s) *you* configure (e.g. your Gmail, Outlook, Yahoo, + iCloud, or self-hosted IMAP/SMTP server) and, for Outlook accounts, to Microsoft's sign-in and + Graph endpoints. +- **Your mail stays on your device.** Messages are cached locally so the app works offline; the + cache can optionally be encrypted at rest. +- **No ads, no analytics, no tracking.** The app contains no advertising, analytics, or tracking + SDK of any kind, and no Google Play Services or Firebase dependency. +- **Nothing is sent to the developers** — including crash reports, which are strictly opt-in, + stored locally, shown to you for review, and (in this build) cannot be uploaded at all because + no ingest endpoint is configured. + +## What the app stores on your device + +All of the following lives in the app's private storage on your device only: + +- **Account settings** — your email address, display name, and server host/port/security + settings for each account you add. +- **Credentials** — your per-account app password or OAuth tokens, encrypted with a hardware- + backed key in the Android Keystore before being written to storage. +- **Mail cache** — headers, message bodies, and folder state, in a local database so your mail is + available offline. You can optionally enable **cache encryption** (SQLCipher) in Settings; the + database key is random, never leaves the device, and is itself sealed by the Android Keystore. +- **Attachments** you download or attach, in the app's cache directory (Android may clear this + automatically to reclaim space). +- **Preferences** — theme, notification, sync, and privacy toggles. +- **Debug reports** — only if a crash occurs or you ask the app to capture one; see + [Diagnostics](#diagnostics-and-debug-reports). + +Uninstalling the app, or clearing its storage in Android settings, deletes all of the above. + +## What leaves your device + +The app makes network connections **only** to servers that operate your email service: + +- **Your mail servers** — the IMAP and SMTP hosts of each account you configure (for the built-in + presets: `imap/smtp.gmail.com`, `imap/smtp.mail.yahoo.com`, `imap/smtp.mail.me.com`; + `outlook.office.com` for Outlook). This traffic is your email itself: signing in, downloading + your mail, sending the messages you write, and — when you use server search — your search + query. That is the app doing its job as your email client; none of it goes to us. +- **Microsoft identity platform and Graph** (`login.microsoftonline.com`, + `graph.microsoft.com`) — only for Outlook/Hotmail accounts, to sign you in with OAuth 2.0 and + to send mail via Microsoft's API. +- **Remote images in emails** — blocked by default. If you enable "load remote images", the + message viewer will fetch images from the servers referenced by the email (which can reveal + your IP address to the sender), so it stays off unless you turn it on. + +Every mail connection uses TLS (SSL/TLS or STARTTLS) with server-certificate hostname +verification; the account-setup UI does not offer an unencrypted option. + +The app never transmits your data to the LibreMail project or any third party of ours. There is +no telemetry, no "phone home", and no ad or analytics traffic. + +## Contacts (`READ_CONTACTS` permission) + +When composing a message, LibreMail can suggest recipients from your device contacts. The app +asks for the contacts permission the first time you open the compose screen: + +- Contact lookups run **entirely on the device** and return at most a handful of name/email + matches for what you typed. Your contact list is never uploaded, copied, or synced anywhere. +- The only way a contact detail leaves the device is when *you* put an address in an email you + send — it then appears in that email, like in any mail client. +- The permission is optional: if you deny it, autocomplete is silently disabled and everything + else keeps working. + +## Notifications (`POST_NOTIFICATIONS` permission) + +Used to show new-mail notifications (per-account, with sender/subject hidden on a locked screen) +and the persistent low-priority status notification Android requires while the optional +instant-push connection is active. New-mail notifications are generated **on the device** from +your synced mail — there is no push server and no cloud messaging service involved. You can +decline the permission or disable notifications per account in system settings. + +## Instant push (foreground service) + +For instant mail delivery the app can hold an open IMAP IDLE connection to your mail server in a +foreground service (shown as a persistent notification). This connects only to your own mail +server, and can be turned off in Settings ("push mail"), which falls back to periodic background +sync. + +## Diagnostics and debug reports + +LibreMail has **no automatic crash or usage reporting**. What exists instead: + +- If the app crashes, or you use "Report a problem", a report is saved **locally** on your + device. It contains the app version, Android version, device make/model, a stack trace (for + crashes), a short summary of non-identifying settings, and recent internal log lines — by + design no account addresses, server names, or message content fields are collected. +- You can view the full report text (with a plain-language notice to check it for anything + personal), copy it, share it yourself, or delete it. It is transmitted **only** if you + explicitly tap Submit — never in the background. +- In the builds produced from this repository **no upload endpoint is configured**, so even an + explicit Submit cannot send anything; the report simply stays on your device. If a future + release adds an endpoint, submission will remain strictly opt-in and user-initiated, and this + policy will be updated. + +## Android Backup + +Android's cloud backup is **off by default** for LibreMail. If you enable "Include settings in +Android Backup" in Settings, only your app preferences are backed up through your device's +Android Backup transport (typically Google's). Your credentials, the mail cache, and the cache +encryption key are always excluded from backups. + +## Data deletion + +- **Remove an account** (in the app's account settings) — deletes that account's stored + credentials, its cached messages, folders, and per-account settings from the local database, + and its notification channels. Copies of downloaded attachments in the app's cache directory + are cleared by Android's normal cache management, or immediately via "Clear cache" in system + settings. +- **Uninstall the app / clear storage** — removes all locally stored app data. +- **Your mailbox is unaffected**: mail lives with your email provider; deleting data in + LibreMail does not delete mail from the server unless you explicitly delete messages in the + app. We hold no copy of your data, so there is nothing for us to delete on any server. + +## Children + +LibreMail is a general-audience utility that requires an existing email account. It is not +directed at children, and — as described above — it collects no data from any user. + +## Changes and contact + +Changes to this policy are made in the public repository with full version history. Questions or +concerns: open an issue at . diff --git a/README.md b/README.md index 125c37d..e770ec9 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,11 @@ token. A working client ID ships with the build; to use your own Azure app regis LibreMail is offline-first: your mail lives in a local cache, and by default network traffic goes only to your mail providers (IMAP/SMTP, plus Microsoft's OAuth and Graph endpoints for -Outlook). There is no analytics SDK and no always-on telemetry. The privacy-sensitive extras -are all **opt-in**: +Outlook). There is no analytics SDK and no always-on telemetry. The full privacy policy lives in +[`PRIVACY.md`](PRIVACY.md); Google Play compliance notes (data-safety mapping, permissions +justification) are under [`docs/`](docs/). Because Gmail uses an app password (no Google OAuth +scopes), no Google restricted-scope verification or CASA assessment applies; Outlook's OAuth +client is governed by Microsoft's Azure rules. The privacy-sensitive extras are all **opt-in**: - **Cache encryption** — the Room cache can be encrypted at rest with **SQLCipher**. With the optional **app lock** (biometric or device credential) enabled, the cache key is bound to @@ -151,6 +154,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 f064dac..b47135b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -96,6 +96,16 @@ android { buildConfig = true } + // 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/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt index 0be3688..fafb851 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt @@ -54,8 +54,8 @@ class MailboxScreenTest { smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), ) - private fun message(uid: String, subject: String, bodyFetched: Boolean = false) = Message( - id = "imap:a:INBOX:$uid", + private fun message(uid: String, subject: String, bodyFetched: Boolean = false, folder: String = "INBOX") = Message( + id = "imap:a:$folder:$uid", accountId = "imap:a", sender = "Sender $uid", senderEmail = "s$uid@example.org", @@ -66,12 +66,12 @@ class MailboxScreenTest { timestampMillis = 1_000L, isRead = true, isStarred = false, - folder = "INBOX", + folder = folder, inInbox = true, bodyFetched = bodyFetched, ) - private fun setContent(repo: FakeMailRepository) { + private fun setContent(repo: FakeMailRepository): MailboxViewModel { val viewModel = MailboxViewModel( repo, FakeAccountRepository(accounts = listOf(account)), @@ -92,6 +92,7 @@ class MailboxScreenTest { ) } } + return viewModel } private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) { @@ -132,11 +133,58 @@ class MailboxScreenTest { composeTestRule.onNodeWithText("Second").performClick() composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick() - composeTestRule.onNodeWithText(string(R.string.action_archive)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_select_all)).assertIsDisplayed() composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist() composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist() } + @Test + fun archiveIcon_isDirect_andArchivesTheSelection() { + val repo = FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second"))) + setContent(repo) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + composeTestRule.onNodeWithText("Second").performClick() + // A direct icon button — no trip through the overflow menu. + composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).performClick() + + composeTestRule.waitUntil(5_000) { repo.archivedIds.isNotEmpty() } + assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), repo.archivedIds.first().toSet()) + } + + @Test + fun spamIcon_isDirect_andConfirmsBeforeReporting() { + val repo = FakeMailRepository(messages = listOf(message("1", "First"))) + setContent(repo) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).performClick() + composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_move)).performClick() + + composeTestRule.waitUntil(5_000) { repo.spammedIds.isNotEmpty() } + assertEquals(listOf("imap:a:INBOX:1"), repo.spammedIds.first()) + } + + @Test + fun archiveIcon_hides_whileViewingTheArchiveFolder() { + val repo = FakeMailRepository( + messages = listOf(message("1", "Old news", folder = "Archive")), + folders = listOf(Folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE, selectable = true)), + ) + val viewModel = setContent(repo) + viewModel.selectFolder("imap:a", "Archive") + waitForText("Old news") + + composeTestRule.onNodeWithText("Old news").performTouchInput { longClick() } + + composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).assertIsDisplayed() + } + @Test fun delete_confirmsMoveToTrash_thenTrashesViaRepository() { val repo = FakeMailRepository(messages = listOf(message("1", "First"))) diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index 3d8d516..2f706b7 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch 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 @@ -47,6 +48,12 @@ class MainActivity : FragmentActivity() { */ 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. @@ -68,10 +75,11 @@ class MainActivity : FragmentActivity() { } } } - // 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) @@ -83,6 +91,8 @@ class MainActivity : FragmentActivity() { LibreMailApp( pendingCompose = pendingCompose.value, onComposeHandled = { pendingCompose.value = null }, + pendingOpenMessageId = pendingOpenMessageId.value, + onOpenMessageHandled = { pendingOpenMessageId.value = null }, ) } } @@ -93,6 +103,7 @@ class MainActivity : FragmentActivity() { 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/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index 6ae11cd..388a65f 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -256,9 +256,16 @@ class MailRepositoryImpl @Inject constructor( } } - /** Resolves the full name of an account's folder for [role], refreshing the cache once if needed. */ + /** + * Resolves the full name of an account's folder for [role], refreshing the cache once if needed. + * Among same-role selectable folders (e.g. `[Gmail]/Spam` via RFC 6154 `\Junk` plus a user label + * "Spam" matched by name), the server-advertised special-use folder wins regardless of LIST order, + * so mail reaches the provider's built-in mailbox; absent one, the earliest LISTed folder is kept + * (`maxByOrNull` returns the first max). + */ private suspend fun resolveRoleFolder(accountId: String, role: FolderRole): String? { - fun pick(folders: List) = folders.firstOrNull { it.role == role.name && it.selectable }?.fullName + fun pick(folders: List) = + folders.filter { it.role == role.name && it.selectable }.maxByOrNull { it.specialUse }?.fullName pick(folderDao.getForAccountOnce(accountId))?.let { return it } // The folder cache can be cold (the user may not have opened the drawer yet); refresh and retry. runCatching { refreshFolders(accountId) } 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 — `