Merge branch 'main' into feat-screen-unlock
# Conflicts: # app/src/main/kotlin/org/libremail/MainActivity.kt
This commit is contained in:
+135
@@ -0,0 +1,135 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# 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 <https://github.com/JMR-dev/LibreMail>.
|
||||
|
||||
## 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 <https://github.com/JMR-dev/LibreMail/issues>.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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<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.
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<FolderEntity>) = folders.firstOrNull { it.role == role.name && it.selectable }?.fullName
|
||||
fun pick(folders: List<FolderEntity>) =
|
||||
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) }
|
||||
|
||||
@@ -35,7 +35,6 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context:
|
||||
if (messages.isEmpty() || !hasPermission()) return
|
||||
ensureAccountChannel(account)
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
val contentIntent = contentIntent()
|
||||
val channelId = channelId(account.id)
|
||||
val groupKey = groupKey(account.id)
|
||||
val summaryId = summaryId(account.id)
|
||||
@@ -52,7 +51,7 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context:
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(groupKey)
|
||||
.setContentIntent(contentIntent)
|
||||
.setContentIntent(openMessageIntent(message.id))
|
||||
.build()
|
||||
manager.notify(notificationId(message.id, summaryId), notification)
|
||||
}
|
||||
@@ -72,7 +71,7 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context:
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(groupKey)
|
||||
.setGroupSummary(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.setContentIntent(openAppIntent())
|
||||
.build()
|
||||
manager.notify(summaryId, summary)
|
||||
}
|
||||
@@ -105,7 +104,16 @@ class MailNotifier @Inject constructor(@ApplicationContext private val context:
|
||||
manager.deleteNotificationChannelGroup(accountId)
|
||||
}
|
||||
|
||||
private fun contentIntent(): PendingIntent {
|
||||
/** Opens the tapped message's reader (each message's intent is distinct — see [NotificationIntents]). */
|
||||
private fun openMessageIntent(messageId: String): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
NotificationIntents.openMessage(context, messageId),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
/** Just brings the app to the foreground — used by the group summary, which has no single message. */
|
||||
private fun openAppIntent(): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import org.libremail.MainActivity
|
||||
|
||||
/**
|
||||
* Builds and parses the intent behind a tapped per-message new-mail notification, keeping both sides
|
||||
* of the contract ([MailNotifier] builds, MainActivity parses) in one place.
|
||||
*
|
||||
* The per-message `data` URI is load-bearing: PendingIntent identity ignores extras, so without a
|
||||
* distinct URI every message's notification would collapse onto one FLAG_UPDATE_CURRENT PendingIntent
|
||||
* and always open the most-recently-notified message. The intent is explicit (component set), so the
|
||||
* private scheme needs no manifest intent-filter and adds no exported surface.
|
||||
*/
|
||||
object NotificationIntents {
|
||||
|
||||
private const val ACTION_OPEN_MESSAGE = "org.libremail.action.OPEN_MESSAGE"
|
||||
private const val EXTRA_MESSAGE_ID = "org.libremail.extra.MESSAGE_ID"
|
||||
|
||||
fun openMessage(context: Context, messageId: String): Intent = Intent(context, MainActivity::class.java).apply {
|
||||
action = ACTION_OPEN_MESSAGE
|
||||
data = Uri.parse("libremail://message/${Uri.encode(messageId)}")
|
||||
putExtra(EXTRA_MESSAGE_ID, messageId)
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
|
||||
/** The tapped message's id, or null for any other intent (launcher, mailto:, share, …). */
|
||||
fun messageId(intent: Intent?): String? =
|
||||
intent?.takeIf { it.action == ACTION_OPEN_MESSAGE }?.getStringExtra(EXTRA_MESSAGE_ID)
|
||||
}
|
||||
@@ -1,8 +1,33 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.richtext
|
||||
|
||||
/** Inline character styles the compose editor supports. */
|
||||
enum class RichStyle { BOLD, ITALIC, UNDERLINE }
|
||||
/**
|
||||
* Inline character styles the compose editor supports. The simple toggles are singletons; the
|
||||
* parameterized styles carry their value, and a well-formed [RichTextContent] never overlaps two
|
||||
* values of the same kind (editing ops replace the old value instead of stacking a second one).
|
||||
*/
|
||||
sealed interface RichStyle {
|
||||
data object Bold : RichStyle
|
||||
|
||||
data object Italic : RichStyle
|
||||
|
||||
data object Underline : RichStyle
|
||||
|
||||
/** Struck-through text: serialized as `<s>`, also parsed from `<del>`/`<strike>`. */
|
||||
data object Strikethrough : RichStyle
|
||||
|
||||
/** A CSS font-family stack (e.g. `"Liberation Serif", serif`), serialized verbatim. */
|
||||
data class FontFamily(val css: String) : RichStyle
|
||||
|
||||
/** Font size in points; parsed from `pt` or `px` (px convert at 3/4 pt per px, rounded). */
|
||||
data class FontSize(val pt: Int) : RichStyle
|
||||
|
||||
/** Text color as ARGB; serialized as `#rrggbb`, so only opaque colors round-trip. */
|
||||
data class FontColor(val argb: Int) : RichStyle
|
||||
|
||||
/** Background highlight as ARGB; serialized as `#rrggbb`, so only opaque colors round-trip. */
|
||||
data class Highlight(val argb: Int) : RichStyle
|
||||
}
|
||||
|
||||
/** A run of [style] over the half-open range [[start], [end]) of the plain text. */
|
||||
data class RichSpan(val start: Int, val end: Int, val style: RichStyle)
|
||||
@@ -10,8 +35,32 @@ data class RichSpan(val start: Int, val end: Int, val style: RichStyle)
|
||||
/** A hyperlink over the half-open range [[start], [end]) pointing at [url]. */
|
||||
data class RichLink(val start: Int, val end: Int, val url: String)
|
||||
|
||||
/** Paragraph alignment (START is the writing-direction default). */
|
||||
enum class RichAlign { START, CENTER, END }
|
||||
|
||||
/**
|
||||
* The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links].
|
||||
* Paragraph alignment over the half-open range [[start], [end]) of the plain text. Ranges cover
|
||||
* whole lines (including any block marker), and adjacent same-aligned lines canonically share one
|
||||
* range — [RichTextHtml.fromHtml] always returns that merged form.
|
||||
*/
|
||||
data class RichAlignment(val start: Int, val end: Int, val align: RichAlign)
|
||||
|
||||
/**
|
||||
* An inline image attached by Content-ID. [[start], [end]) covers a visible [imageToken] in the
|
||||
* plain text (`[image: name]`), which keeps the text/plain rendering readable; the HTML form
|
||||
* replaces the token with `<img src="cid:contentId" alt="name">`.
|
||||
*/
|
||||
data class RichImage(val start: Int, val end: Int, val contentId: String, val name: String)
|
||||
|
||||
/** A message-wide default font family and/or size, serialized as one outer `<div style>` wrapper. */
|
||||
data class RichBaseStyle(val fontCss: String? = null, val fontSizePt: Int? = null)
|
||||
|
||||
/** The visible plain-text placeholder for an inline image named [name]. */
|
||||
fun imageToken(name: String): String = "[image: $name]"
|
||||
|
||||
/**
|
||||
* The compose editor's internal rich-text model: plain [text] plus inline [spans], [links],
|
||||
* paragraph [alignments], inline [images], and an optional message-wide [baseStyle].
|
||||
*
|
||||
* Block structure (unordered/ordered lists and block quotes) is encoded as recognizable line
|
||||
* prefixes inside [text] — "• " for bullets, "N. " for numbered items, and "> " for quotes — so
|
||||
@@ -22,16 +71,24 @@ data class RichTextContent(
|
||||
val text: String = "",
|
||||
val spans: List<RichSpan> = emptyList(),
|
||||
val links: List<RichLink> = emptyList(),
|
||||
val alignments: List<RichAlignment> = emptyList(),
|
||||
val images: List<RichImage> = emptyList(),
|
||||
val baseStyle: RichBaseStyle? = null,
|
||||
) {
|
||||
val isBlank: Boolean get() = text.isBlank()
|
||||
|
||||
/**
|
||||
* True when the content carries anything a plaintext field could not represent: inline styling,
|
||||
* a link, or a block marker. When false, callers should send/persist plaintext only so an
|
||||
* unformatted message stays byte-for-byte identical to the old plaintext-only path.
|
||||
* a link, a block marker, paragraph alignment, an inline image, or a base style. When false,
|
||||
* callers should send/persist plaintext only so an unformatted message stays byte-for-byte
|
||||
* identical to the old plaintext-only path.
|
||||
*/
|
||||
fun hasFormatting(): Boolean =
|
||||
spans.isNotEmpty() || links.isNotEmpty() || text.lineSequence().any { lineMarker(it) != null }
|
||||
fun hasFormatting(): Boolean = spans.isNotEmpty() ||
|
||||
links.isNotEmpty() ||
|
||||
alignments.isNotEmpty() ||
|
||||
images.isNotEmpty() ||
|
||||
baseStyle != null ||
|
||||
text.lineSequence().any { lineMarker(it) != null }
|
||||
}
|
||||
|
||||
/** Recognized block markers and the tags they map to. */
|
||||
@@ -39,10 +96,6 @@ internal const val BULLET_PREFIX = "• "
|
||||
internal const val QUOTE_PREFIX = "> "
|
||||
private val ORDERED_PREFIX = Regex("^\\d+\\. ")
|
||||
|
||||
private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE }
|
||||
|
||||
private data class Line(val kind: Kind, val contentStart: Int, val contentEnd: Int)
|
||||
|
||||
/** The block marker prefixing [line], or null for an ordinary paragraph line. */
|
||||
internal fun lineMarker(line: String): String? = when {
|
||||
line.startsWith(BULLET_PREFIX) -> BULLET_PREFIX
|
||||
@@ -51,285 +104,18 @@ internal fun lineMarker(line: String): String? = when {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or
|
||||
* Compose types), so the whole conversion is unit-testable on the JVM.
|
||||
*
|
||||
* The emitted subset — `<b> <i> <u> <a> <ul>/<ol>/<li> <blockquote> <br>` — is deliberately narrow
|
||||
* so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts round-trip losslessly).
|
||||
* Merges spans with the exact same style value that touch or overlap, yielding the canonical
|
||||
* maximal-run form. Shared by the HTML parser and the editing ops so the two can never drift.
|
||||
*/
|
||||
object RichTextHtml {
|
||||
|
||||
fun toHtml(content: RichTextContent): String {
|
||||
if (content.text.isEmpty()) return ""
|
||||
val lines = classify(content.text)
|
||||
val sb = StringBuilder()
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
i = when (lines[i].kind) {
|
||||
Kind.BULLET -> emitList(sb, content, lines, i, Kind.BULLET, "ul")
|
||||
Kind.ORDERED -> emitList(sb, content, lines, i, Kind.ORDERED, "ol")
|
||||
Kind.QUOTE -> emitQuote(sb, content, lines, i)
|
||||
Kind.PARAGRAPH -> emitParagraph(sb, content, lines, i)
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */
|
||||
fun toPlainText(content: RichTextContent): String = content.text
|
||||
|
||||
fun fromHtml(html: String): RichTextContent = HtmlToRichParser(html).parse()
|
||||
}
|
||||
|
||||
private fun classify(text: String): List<Line> {
|
||||
val lines = ArrayList<Line>()
|
||||
var start = 0
|
||||
while (true) {
|
||||
val nl = text.indexOf('\n', start)
|
||||
val end = if (nl == -1) text.length else nl
|
||||
val marker = lineMarker(text.substring(start, end))
|
||||
val kind = when (marker) {
|
||||
BULLET_PREFIX -> Kind.BULLET
|
||||
QUOTE_PREFIX -> Kind.QUOTE
|
||||
null -> Kind.PARAGRAPH
|
||||
else -> Kind.ORDERED
|
||||
}
|
||||
lines.add(Line(kind, start + (marker?.length ?: 0), end))
|
||||
if (nl == -1) break
|
||||
start = nl + 1
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun emitList(
|
||||
sb: StringBuilder,
|
||||
content: RichTextContent,
|
||||
lines: List<Line>,
|
||||
from: Int,
|
||||
kind: Kind,
|
||||
tag: String,
|
||||
): Int {
|
||||
sb.append("<").append(tag).append(">")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == kind) {
|
||||
sb.append("<li>").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("</li>")
|
||||
i++
|
||||
}
|
||||
sb.append("</").append(tag).append(">")
|
||||
return i
|
||||
}
|
||||
|
||||
private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
sb.append("<blockquote>")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.QUOTE) {
|
||||
if (i > from) sb.append("<br>")
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
sb.append("</blockquote>")
|
||||
return i
|
||||
}
|
||||
|
||||
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
sb.append("<p>")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) {
|
||||
if (i > from) sb.append("<br>")
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
sb.append("</p>")
|
||||
return i
|
||||
}
|
||||
|
||||
/** Renders the inline styling/links over [[from], [to]) as nested `<a>/<b>/<i>/<u>` tags. */
|
||||
private fun renderInline(content: RichTextContent, from: Int, to: Int): String {
|
||||
if (from >= to) return ""
|
||||
val points = cutPoints(content, from, to)
|
||||
val sb = StringBuilder()
|
||||
for (idx in 0 until points.size - 1) {
|
||||
val a = points[idx]
|
||||
val b = points[idx + 1]
|
||||
if (a < b) appendRun(sb, content, a, b)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** The sorted set of offsets where a span or link starts/ends within [[from], [to]]. */
|
||||
private fun cutPoints(content: RichTextContent, from: Int, to: Int): List<Int> {
|
||||
val cuts = sortedSetOf(from, to)
|
||||
fun add(start: Int, end: Int) {
|
||||
if (end > from && start < to) {
|
||||
cuts.add(start.coerceIn(from, to))
|
||||
cuts.add(end.coerceIn(from, to))
|
||||
}
|
||||
}
|
||||
content.spans.forEach { add(it.start, it.end) }
|
||||
content.links.forEach { add(it.start, it.end) }
|
||||
return cuts.toList()
|
||||
}
|
||||
|
||||
/** Emits one constant-styling run [[a], [b]) with fully-closed tags, so nesting is always valid. */
|
||||
private fun appendRun(sb: StringBuilder, content: RichTextContent, a: Int, b: Int) {
|
||||
val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }.toSet()
|
||||
val link = content.links.firstOrNull { it.start <= a && b <= it.end }
|
||||
if (link != null) sb.append("<a href=\"").append(escapeAttr(link.url)).append("\">")
|
||||
if (RichStyle.BOLD in styles) sb.append("<b>")
|
||||
if (RichStyle.ITALIC in styles) sb.append("<i>")
|
||||
if (RichStyle.UNDERLINE in styles) sb.append("<u>")
|
||||
sb.append(escape(content.text.substring(a, b)))
|
||||
if (RichStyle.UNDERLINE in styles) sb.append("</u>")
|
||||
if (RichStyle.ITALIC in styles) sb.append("</i>")
|
||||
if (RichStyle.BOLD in styles) sb.append("</b>")
|
||||
if (link != null) sb.append("</a>")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the narrow HTML subset [toHtml][RichTextHtml.toHtml] emits (plus `strong`/`em` and
|
||||
* pretty-printer whitespace) back into a [RichTextContent]. A small state machine keeps the nesting
|
||||
* shallow: [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text.
|
||||
*/
|
||||
private class HtmlToRichParser(private val html: String) {
|
||||
private val text = StringBuilder()
|
||||
private val spans = ArrayList<RichSpan>()
|
||||
private val links = ArrayList<RichLink>()
|
||||
private var boldStart = -1
|
||||
private var italicStart = -1
|
||||
private var underlineStart = -1
|
||||
private var linkStart = -1
|
||||
private var linkUrl = ""
|
||||
private var listType: Char? = null
|
||||
private var olCount = 0
|
||||
private var inQuote = false
|
||||
|
||||
fun parse(): RichTextContent {
|
||||
var i = 0
|
||||
while (i < html.length) {
|
||||
if (html[i] == '<') {
|
||||
val gt = html.indexOf('>', i)
|
||||
if (gt == -1) break
|
||||
handleTag(html.substring(i + 1, gt).trim())
|
||||
i = gt + 1
|
||||
} else {
|
||||
val lt = html.indexOf('<', i)
|
||||
val end = if (lt == -1) html.length else lt
|
||||
handleText(html.substring(i, end))
|
||||
i = end
|
||||
}
|
||||
}
|
||||
return finish()
|
||||
}
|
||||
|
||||
private fun atLineStart() = text.isEmpty() || text.last() == '\n'
|
||||
|
||||
private fun newlineIfNeeded() {
|
||||
if (!atLineStart()) text.append('\n')
|
||||
}
|
||||
|
||||
private fun handleTag(raw: String) {
|
||||
val closing = raw.startsWith("/")
|
||||
val body = raw.removePrefix("/").trim()
|
||||
when (body.substringBefore(' ').substringBefore('/').lowercase()) {
|
||||
"br" -> {
|
||||
text.append('\n')
|
||||
if (inQuote) text.append(QUOTE_PREFIX)
|
||||
}
|
||||
"b", "strong" -> boldStart = toggle(closing, boldStart, RichStyle.BOLD)
|
||||
"i", "em" -> italicStart = toggle(closing, italicStart, RichStyle.ITALIC)
|
||||
"u" -> underlineStart = toggle(closing, underlineStart, RichStyle.UNDERLINE)
|
||||
"a" -> handleAnchor(closing, body)
|
||||
"ul" -> handleList(closing, 'u')
|
||||
"ol" -> handleList(closing, 'o')
|
||||
"li" -> if (!closing) startListItem()
|
||||
"blockquote" -> handleQuote(closing)
|
||||
"p", "div" -> newlineIfNeeded()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a style (returns the current offset) or closes it (records the span, returns -1). */
|
||||
private fun toggle(closing: Boolean, openOffset: Int, style: RichStyle): Int {
|
||||
if (!closing) return text.length
|
||||
if (openOffset >= 0) spans.add(RichSpan(openOffset, text.length, style))
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun handleAnchor(closing: Boolean, body: String) {
|
||||
if (closing) {
|
||||
if (linkStart >= 0) {
|
||||
links.add(RichLink(linkStart, text.length, linkUrl))
|
||||
linkStart = -1
|
||||
linkUrl = ""
|
||||
}
|
||||
internal fun mergeSameValueSpans(spans: List<RichSpan>): List<RichSpan> {
|
||||
val merged = ArrayList<RichSpan>()
|
||||
for (span in spans.sortedWith(compareBy({ it.start }, { it.end }))) {
|
||||
val i = merged.indexOfLast { it.style == span.style && span.start <= it.end }
|
||||
if (i >= 0) {
|
||||
merged[i] = merged[i].copy(end = maxOf(merged[i].end, span.end))
|
||||
} else {
|
||||
linkStart = text.length
|
||||
linkUrl = extractHref(body)
|
||||
merged.add(span)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleList(closing: Boolean, type: Char) {
|
||||
if (closing) {
|
||||
listType = null
|
||||
} else {
|
||||
listType = type
|
||||
if (type == 'o') olCount = 0
|
||||
}
|
||||
newlineIfNeeded()
|
||||
}
|
||||
|
||||
private fun startListItem() {
|
||||
newlineIfNeeded()
|
||||
if (listType == 'o') {
|
||||
olCount++
|
||||
text.append("$olCount. ")
|
||||
} else {
|
||||
text.append(BULLET_PREFIX)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleQuote(closing: Boolean) {
|
||||
newlineIfNeeded()
|
||||
inQuote = !closing
|
||||
if (!closing) text.append(QUOTE_PREFIX)
|
||||
}
|
||||
|
||||
private fun handleText(chunk: String) {
|
||||
// Drop the insignificant whitespace a pretty-printer leaves between block tags (blank runs at
|
||||
// a line start, or any blank run with a newline), but keep a real space between inline runs.
|
||||
if (!(chunk.isBlank() && (atLineStart() || chunk.contains('\n')))) text.append(unescape(chunk))
|
||||
}
|
||||
|
||||
private fun finish(): RichTextContent {
|
||||
val out = text.toString().trimEnd('\n')
|
||||
val len = out.length
|
||||
if (boldStart in 0 until len) spans.add(RichSpan(boldStart, len, RichStyle.BOLD))
|
||||
if (italicStart in 0 until len) spans.add(RichSpan(italicStart, len, RichStyle.ITALIC))
|
||||
if (underlineStart in 0 until len) spans.add(RichSpan(underlineStart, len, RichStyle.UNDERLINE))
|
||||
if (linkStart in 0 until len) links.add(RichLink(linkStart, len, linkUrl))
|
||||
return RichTextContent(
|
||||
text = out,
|
||||
spans = spans.filter { it.end <= len && it.start < it.end },
|
||||
links = links.filter { it.end <= len && it.start < it.end },
|
||||
)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
private fun extractHref(tagBody: String): String {
|
||||
val match = Regex("href\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE).find(tagBody) ?: return ""
|
||||
val (doubleQuoted, singleQuoted) = match.destructured
|
||||
return unescape(doubleQuoted.ifEmpty { singleQuoted })
|
||||
}
|
||||
|
||||
private fun escape(s: String): String = s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
private fun escapeAttr(s: String): String = escape(s).replace("\"", """)
|
||||
|
||||
private fun unescape(s: String): String = s
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
|
||||
@@ -15,17 +15,22 @@ data class EditResult(val content: RichTextContent, val selectionStart: Int, val
|
||||
*/
|
||||
object RichTextEditing {
|
||||
|
||||
/** Adds [style] over [[start], [end]) if it is not already fully styled, otherwise removes it. */
|
||||
/**
|
||||
* Toggles [style] over [[start], [end]): if the exact style already covers the whole range it is
|
||||
* removed; otherwise it is applied, replacing any other value of the same kind over the range
|
||||
* (e.g. picking a new [RichStyle.FontSize] replaces the old size instead of stacking a second one).
|
||||
*/
|
||||
fun toggleStyle(content: RichTextContent, start: Int, end: Int, style: RichStyle): RichTextContent {
|
||||
if (start >= end) return content
|
||||
val others = content.spans.filter { it.style != style }
|
||||
val same = content.spans.filter { it.style == style }
|
||||
val updated = if (isFullyStyled(same, start, end)) {
|
||||
subtractRange(same, start, end)
|
||||
val otherKinds = content.spans.filter { it.style::class != style::class }
|
||||
val sameKind = content.spans.filter { it.style::class == style::class }
|
||||
val cleared = subtractRange(sameKind, start, end)
|
||||
val updated = if (isFullyStyled(sameKind.filter { it.style == style }, start, end)) {
|
||||
cleared
|
||||
} else {
|
||||
mergeSameStyle(same + RichSpan(start, end, style))
|
||||
mergeSameValueSpans(cleared + RichSpan(start, end, style))
|
||||
}
|
||||
return content.copy(spans = (others + updated).sortedBy { it.start })
|
||||
return content.copy(spans = (otherKinds + updated).sortedBy { it.start })
|
||||
}
|
||||
|
||||
/** Links [[start], [end]) to [url], replacing any links that overlap the range. */
|
||||
@@ -45,6 +50,27 @@ object RichTextEditing {
|
||||
fun isStyled(content: RichTextContent, start: Int, end: Int, style: RichStyle): Boolean =
|
||||
start < end && isFullyStyled(content.spans.filter { it.style == style }, start, end)
|
||||
|
||||
/**
|
||||
* The single value of style kind [T] over the selection, or null when absent or mixed — one call
|
||||
* gives a picker its "current value". For a caret the boundaries count as inside, so the query
|
||||
* matches the run the user is typing at the end of; at a boundary between two runs the earlier
|
||||
* run wins.
|
||||
*/
|
||||
inline fun <reified T : RichStyle> styleAt(content: RichTextContent, start: Int, end: Int): T? =
|
||||
styleAt(content, start, end, T::class.java)
|
||||
|
||||
/** Non-reified form of [styleAt] for callers that carry the kind as a [Class]. */
|
||||
fun <T : RichStyle> styleAt(content: RichTextContent, start: Int, end: Int, kind: Class<T>): T? {
|
||||
val candidates = content.spans.filter { kind.isInstance(it.style) }
|
||||
val value = if (start >= end) {
|
||||
candidates.firstOrNull { it.start <= start && start <= it.end }?.style
|
||||
} else {
|
||||
candidates.map { it.style }.distinct()
|
||||
.firstOrNull { v -> isFullyStyled(candidates.filter { it.style == v }, start, end) }
|
||||
}
|
||||
return kind.cast(value)
|
||||
}
|
||||
|
||||
/** Whether every line the selection touches carries [marker]. */
|
||||
fun hasBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): Boolean {
|
||||
val lineStarts = lineStartsTouching(content.text, start, end)
|
||||
@@ -54,7 +80,7 @@ object RichTextEditing {
|
||||
/**
|
||||
* Toggles [marker] across every line the selection touches: if all those lines already carry it,
|
||||
* it is removed; otherwise it is applied (replacing any other block marker already there). Spans,
|
||||
* links and the selection are shifted to track the inserted/removed prefixes.
|
||||
* links, alignments, images and the selection are shifted to track the inserted/removed prefixes.
|
||||
*/
|
||||
fun toggleBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): EditResult {
|
||||
val text = content.text
|
||||
@@ -71,9 +97,14 @@ object RichTextEditing {
|
||||
}
|
||||
}
|
||||
val (newText, remap) = applyEdits(text, edits)
|
||||
val newSpans = content.spans.mapNotNull { remapSpan(it, remap) }
|
||||
val newLinks = content.links.mapNotNull { remapLink(it, remap) }
|
||||
return EditResult(RichTextContent(newText, newSpans, newLinks), remap(start), remap(end))
|
||||
val updated = content.copy(
|
||||
text = newText,
|
||||
spans = content.spans.mapNotNull { remapSpan(it, remap) },
|
||||
links = content.links.mapNotNull { remapLink(it, remap) },
|
||||
alignments = content.alignments.mapNotNull { remapAlignment(it, remap) },
|
||||
images = content.images.mapNotNull { remapImage(it, remap) },
|
||||
)
|
||||
return EditResult(updated, remap(start), remap(end))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +126,18 @@ private fun remapLink(link: RichLink, remap: (Int) -> Int): RichLink? {
|
||||
return if (s < e) RichLink(s, e, link.url) else null
|
||||
}
|
||||
|
||||
private fun remapAlignment(alignment: RichAlignment, remap: (Int) -> Int): RichAlignment? {
|
||||
val s = remap(alignment.start)
|
||||
val e = remap(alignment.end)
|
||||
return if (s < e) alignment.copy(start = s, end = e) else null
|
||||
}
|
||||
|
||||
private fun remapImage(image: RichImage, remap: (Int) -> Int): RichImage? {
|
||||
val s = remap(image.start)
|
||||
val e = remap(image.end)
|
||||
return if (s < e) image.copy(start = s, end = e) else null
|
||||
}
|
||||
|
||||
// --- inline style helpers ---
|
||||
|
||||
private fun isFullyStyled(spans: List<RichSpan>, start: Int, end: Int): Boolean {
|
||||
@@ -117,19 +160,6 @@ private fun subtractRange(spans: List<RichSpan>, start: Int, end: Int): List<Ric
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeSameStyle(spans: List<RichSpan>): List<RichSpan> {
|
||||
val merged = ArrayList<RichSpan>()
|
||||
for (span in spans.sortedBy { it.start }) {
|
||||
val last = merged.lastOrNull()
|
||||
if (last != null && span.start <= last.end) {
|
||||
merged[merged.size - 1] = last.copy(end = maxOf(last.end, span.end))
|
||||
} else {
|
||||
merged.add(span)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// --- block marker helpers ---
|
||||
|
||||
private val ORDERED = Regex("^\\d+\\. ")
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.richtext
|
||||
|
||||
/**
|
||||
* Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or
|
||||
* Compose types), so the whole conversion is unit-testable on the JVM.
|
||||
*
|
||||
* The emitted subset — `<b> <i> <u> <s> <a> <span style> <img> <ul>/<ol>/<li> <blockquote> <br>`,
|
||||
* `text-align` on `<p>`/`<li>`, and a single outer `<div style>` for [RichBaseStyle] — is
|
||||
* deliberately narrow so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts
|
||||
* round-trip losslessly). Two canonical-form caveats: [fromHtml] returns maximally-merged spans and
|
||||
* alignment runs, and an inline span crossing a line break re-parses as one span per line.
|
||||
*/
|
||||
object RichTextHtml {
|
||||
|
||||
fun toHtml(content: RichTextContent): String {
|
||||
val body = bodyHtml(content)
|
||||
val base = content.baseStyle ?: return body
|
||||
return "<div style=\"" + escapeAttr(baseCss(base)) + "\">" + body + "</div>"
|
||||
}
|
||||
|
||||
/** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */
|
||||
fun toPlainText(content: RichTextContent): String = content.text
|
||||
|
||||
fun fromHtml(html: String): RichTextContent = RichTextHtmlParser(html).parse()
|
||||
}
|
||||
|
||||
/** The `font-family`/`font-size` declarations of the base-style wrapper (possibly empty). */
|
||||
private fun baseCss(base: RichBaseStyle): String = listOfNotNull(
|
||||
base.fontCss?.let { "font-family:$it" },
|
||||
base.fontSizePt?.let { "font-size:${it}pt" },
|
||||
).joinToString(";")
|
||||
|
||||
private fun bodyHtml(content: RichTextContent): String {
|
||||
if (content.text.isEmpty()) return ""
|
||||
val lines = classify(content.text)
|
||||
val sb = StringBuilder()
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
i = when (lines[i].kind) {
|
||||
Kind.BULLET -> emitList(sb, content, lines, i, Kind.BULLET, "ul")
|
||||
Kind.ORDERED -> emitList(sb, content, lines, i, Kind.ORDERED, "ol")
|
||||
Kind.QUOTE -> emitQuote(sb, content, lines, i)
|
||||
Kind.PARAGRAPH -> emitParagraph(sb, content, lines, i)
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE }
|
||||
|
||||
/** One line of the plain text: [start] is the raw line start, content excludes the block marker. */
|
||||
private data class Line(val kind: Kind, val start: Int, val contentStart: Int, val contentEnd: Int)
|
||||
|
||||
private fun classify(text: String): List<Line> {
|
||||
val lines = ArrayList<Line>()
|
||||
var start = 0
|
||||
while (true) {
|
||||
val nl = text.indexOf('\n', start)
|
||||
val end = if (nl == -1) text.length else nl
|
||||
val marker = lineMarker(text.substring(start, end))
|
||||
val kind = when (marker) {
|
||||
BULLET_PREFIX -> Kind.BULLET
|
||||
QUOTE_PREFIX -> Kind.QUOTE
|
||||
null -> Kind.PARAGRAPH
|
||||
else -> Kind.ORDERED
|
||||
}
|
||||
lines.add(Line(kind, start, start + (marker?.length ?: 0), end))
|
||||
if (nl == -1) break
|
||||
start = nl + 1
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** The alignment applying to [line]: the first alignment run overlapping the line's range. */
|
||||
private fun alignFor(content: RichTextContent, line: Line): RichAlign? =
|
||||
content.alignments.firstOrNull { it.start < line.contentEnd && it.end > line.start }?.align
|
||||
|
||||
/** Fixed left/center/right keeps the output readable in legacy email clients. */
|
||||
private fun cssAlign(align: RichAlign): String = when (align) {
|
||||
RichAlign.START -> "left"
|
||||
RichAlign.CENTER -> "center"
|
||||
RichAlign.END -> "right"
|
||||
}
|
||||
|
||||
private fun openBlockTag(tag: String, align: RichAlign?): String =
|
||||
if (align == null) "<$tag>" else "<$tag style=\"text-align:${cssAlign(align)}\">"
|
||||
|
||||
private fun emitList(
|
||||
sb: StringBuilder,
|
||||
content: RichTextContent,
|
||||
lines: List<Line>,
|
||||
from: Int,
|
||||
kind: Kind,
|
||||
tag: String,
|
||||
): Int {
|
||||
sb.append("<").append(tag).append(">")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == kind) {
|
||||
sb.append(openBlockTag("li", alignFor(content, lines[i])))
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
sb.append("</li>")
|
||||
i++
|
||||
}
|
||||
sb.append("</").append(tag).append(">")
|
||||
return i
|
||||
}
|
||||
|
||||
private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
sb.append("<blockquote>")
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.QUOTE) {
|
||||
if (i > from) sb.append("<br>")
|
||||
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
sb.append("</blockquote>")
|
||||
return i
|
||||
}
|
||||
|
||||
/** Merges consecutive plain lines into one `<p>`, splitting where the alignment changes. */
|
||||
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
|
||||
val align = alignFor(content, lines[from])
|
||||
sb.append(openBlockTag("p", align))
|
||||
val inner = StringBuilder()
|
||||
var i = from
|
||||
while (i < lines.size && lines[i].kind == Kind.PARAGRAPH && alignFor(content, lines[i]) == align) {
|
||||
if (i > from) inner.append("<br>")
|
||||
inner.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
|
||||
i++
|
||||
}
|
||||
// A group of only empty lines would otherwise vanish on parse (the closing tag's newline is a
|
||||
// no-op at a line start), so it emits one <br> per line to keep the blank lines round-trippable.
|
||||
val allEmpty = (from until i).all { lines[it].contentStart >= lines[it].contentEnd }
|
||||
sb.append(if (allEmpty) "<br>".repeat(i - from) else inner)
|
||||
sb.append("</p>")
|
||||
return i
|
||||
}
|
||||
|
||||
/** Renders the inline styling/links/images over [[from], [to]) as nested, fully-closed tags. */
|
||||
private fun renderInline(content: RichTextContent, from: Int, to: Int): String {
|
||||
if (from >= to) return ""
|
||||
val points = cutPoints(content, from, to)
|
||||
val sb = StringBuilder()
|
||||
for (idx in 0 until points.size - 1) {
|
||||
val a = points[idx]
|
||||
val b = points[idx + 1]
|
||||
if (a < b) appendRun(sb, content, a, b)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** The sorted set of offsets where a span, link, or image starts/ends within [[from], [to]]. */
|
||||
private fun cutPoints(content: RichTextContent, from: Int, to: Int): List<Int> {
|
||||
val cuts = sortedSetOf(from, to)
|
||||
fun add(start: Int, end: Int) {
|
||||
if (end > from && start < to) {
|
||||
cuts.add(start.coerceIn(from, to))
|
||||
cuts.add(end.coerceIn(from, to))
|
||||
}
|
||||
}
|
||||
content.spans.forEach { add(it.start, it.end) }
|
||||
content.links.forEach { add(it.start, it.end) }
|
||||
content.images.forEach { add(it.start, it.end) }
|
||||
return cuts.toList()
|
||||
}
|
||||
|
||||
/** The simple toggle styles and their HTML tags, in emission (outermost-first) order. */
|
||||
private val SIMPLE_TAGS = listOf<Pair<RichStyle, String>>(
|
||||
RichStyle.Bold to "b",
|
||||
RichStyle.Italic to "i",
|
||||
RichStyle.Underline to "u",
|
||||
RichStyle.Strikethrough to "s",
|
||||
)
|
||||
|
||||
/** Emits one constant-styling run [[a], [b]) with fully-closed tags, so nesting is always valid. */
|
||||
private fun appendRun(sb: StringBuilder, content: RichTextContent, a: Int, b: Int) {
|
||||
val image = content.images.firstOrNull { it.start <= a && a < it.end }
|
||||
if (image != null && a != image.start) return // interior cut of an image token; emitted at its start
|
||||
val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }
|
||||
val link = content.links.firstOrNull { it.start <= a && b <= it.end }
|
||||
val tags = buildList {
|
||||
if (link != null) add("a href=\"${escapeAttr(link.url)}\"" to "a")
|
||||
inlineCss(styles).takeIf { it.isNotEmpty() }?.let { add("span style=\"${escapeAttr(it)}\"" to "span") }
|
||||
SIMPLE_TAGS.forEach { (style, tag) -> if (style in styles) add(tag to tag) }
|
||||
}
|
||||
tags.forEach { (open, _) -> sb.append('<').append(open).append('>') }
|
||||
if (image != null) {
|
||||
sb.append("<img src=\"cid:").append(escapeAttr(image.contentId))
|
||||
.append("\" alt=\"").append(escapeAttr(image.name)).append("\">")
|
||||
} else {
|
||||
sb.append(escape(content.text.substring(a, b)))
|
||||
}
|
||||
tags.asReversed().forEach { (_, close) -> sb.append("</").append(close).append('>') }
|
||||
}
|
||||
|
||||
/** Merges the parameterized styles active on a run into one CSS declaration list (maybe empty). */
|
||||
private fun inlineCss(styles: List<RichStyle>): String {
|
||||
val parts = ArrayList<String>()
|
||||
styles.firstNotNullOfOrNull { it as? RichStyle.FontFamily }?.let { parts.add("font-family:${it.css}") }
|
||||
styles.firstNotNullOfOrNull { it as? RichStyle.FontSize }?.let { parts.add("font-size:${it.pt}pt") }
|
||||
styles.firstNotNullOfOrNull { it as? RichStyle.FontColor }?.let { parts.add("color:${cssColor(it.argb)}") }
|
||||
styles.firstNotNullOfOrNull { it as? RichStyle.Highlight }
|
||||
?.let { parts.add("background-color:${cssColor(it.argb)}") }
|
||||
return parts.joinToString(";")
|
||||
}
|
||||
|
||||
private const val RGB_MASK = 0xFFFFFF
|
||||
private const val RGB_HEX_DIGITS = 6
|
||||
private const val HEX_RADIX = 16
|
||||
|
||||
/** `#rrggbb` for the low 24 bits of [argb] (alpha is not representable in email-safe CSS). */
|
||||
internal fun cssColor(argb: Int): String = "#" + (argb and RGB_MASK).toString(HEX_RADIX).padStart(RGB_HEX_DIGITS, '0')
|
||||
|
||||
internal fun escape(s: String): String = s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
internal fun escapeAttr(s: String): String = escape(s).replace("\"", """)
|
||||
@@ -0,0 +1,373 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.richtext
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Parses the narrow HTML subset [RichTextHtml.toHtml] emits (plus tolerated variants: `strong`/`em`,
|
||||
* `del`/`strike`, `px` font sizes, `#rgb` colors, `start`/`end` alignment, and pretty-printer
|
||||
* whitespace) back into a [RichTextContent]. Unknown tags and unknown CSS properties are ignored
|
||||
* without dropping the text they wrap. A small state machine keeps the nesting shallow:
|
||||
* [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text; open
|
||||
* style-bearing tags live on a stack so spans of any kind can nest and interleave.
|
||||
*/
|
||||
internal class RichTextHtmlParser(private val html: String) {
|
||||
private val text = StringBuilder()
|
||||
private val spans = ArrayList<RichSpan>()
|
||||
private val links = ArrayList<RichLink>()
|
||||
private val alignments = ArrayList<RichAlignment>()
|
||||
private val images = ArrayList<RichImage>()
|
||||
private val openStyles = ArrayDeque<OpenStyles>()
|
||||
private var baseStyle: RichBaseStyle? = null
|
||||
private var linkStart = -1
|
||||
private var linkUrl = ""
|
||||
private var listType: Char? = null
|
||||
private var olCount = 0
|
||||
private var inQuote = false
|
||||
private var blockStart = -1
|
||||
private var blockAlign: RichAlign? = null
|
||||
|
||||
/** One still-open style-bearing tag: the [channel] whose closing tag ends it, and its styles. */
|
||||
private class OpenStyles(val channel: String, val start: Int, val styles: List<RichStyle>)
|
||||
|
||||
fun parse(): RichTextContent {
|
||||
var i = 0
|
||||
while (i < html.length) {
|
||||
if (html[i] == '<') {
|
||||
val gt = html.indexOf('>', i)
|
||||
if (gt == -1) break
|
||||
handleTag(html.substring(i + 1, gt).trim())
|
||||
i = gt + 1
|
||||
} else {
|
||||
val lt = html.indexOf('<', i)
|
||||
val end = if (lt == -1) html.length else lt
|
||||
handleText(html.substring(i, end))
|
||||
i = end
|
||||
}
|
||||
}
|
||||
return finish()
|
||||
}
|
||||
|
||||
private fun atLineStart() = text.isEmpty() || text.last() == '\n'
|
||||
|
||||
private fun newlineIfNeeded() {
|
||||
if (!atLineStart()) text.append('\n')
|
||||
}
|
||||
|
||||
private fun handleTag(raw: String) {
|
||||
val closing = raw.startsWith("/")
|
||||
val body = raw.removePrefix("/").trim()
|
||||
val name = body.substringBefore(' ').substringBefore('/').lowercase()
|
||||
if (!handleInlineTag(name, closing, body)) handleBlockTag(name, closing, body)
|
||||
}
|
||||
|
||||
/** Dispatches character-level tags; returns false when [name] is not an inline tag. */
|
||||
private fun handleInlineTag(name: String, closing: Boolean, body: String): Boolean {
|
||||
when (name) {
|
||||
"b", "strong" -> toggleStyles(closing, "b", listOf(RichStyle.Bold))
|
||||
"i", "em" -> toggleStyles(closing, "i", listOf(RichStyle.Italic))
|
||||
"u" -> toggleStyles(closing, "u", listOf(RichStyle.Underline))
|
||||
"s", "del", "strike" -> toggleStyles(closing, "s", listOf(RichStyle.Strikethrough))
|
||||
"span" -> toggleStyles(closing, "span", spanStyles(closing, body))
|
||||
"a" -> handleAnchor(closing, body)
|
||||
"img" -> if (!closing) handleImage(body)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleBlockTag(name: String, closing: Boolean, body: String) {
|
||||
when (name) {
|
||||
"br" -> {
|
||||
text.append('\n')
|
||||
if (inQuote) text.append(QUOTE_PREFIX)
|
||||
}
|
||||
"ul" -> handleList(closing, 'u')
|
||||
"ol" -> handleList(closing, 'o')
|
||||
"li" -> handleListItem(closing, body)
|
||||
"blockquote" -> handleQuote(closing)
|
||||
"p" -> handleParagraph(closing, body)
|
||||
"div" -> handleDiv(closing, body)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun spanStyles(closing: Boolean, body: String): List<RichStyle> =
|
||||
if (closing) emptyList() else parseInlineStyles(extractStyleAttr(body))
|
||||
|
||||
/** Opens [styles] on [channel], or on a closing tag records spans for the matching open tag. */
|
||||
private fun toggleStyles(closing: Boolean, channel: String, styles: List<RichStyle>) {
|
||||
if (!closing) {
|
||||
openStyles.addLast(OpenStyles(channel, text.length, styles))
|
||||
return
|
||||
}
|
||||
val idx = openStyles.indexOfLast { it.channel == channel }
|
||||
if (idx >= 0) recordSpans(openStyles.removeAt(idx), text.length)
|
||||
}
|
||||
|
||||
private fun recordSpans(open: OpenStyles, end: Int) {
|
||||
if (open.start >= end) return
|
||||
open.styles.forEach { style -> spans.add(RichSpan(open.start, end, style)) }
|
||||
}
|
||||
|
||||
private fun handleAnchor(closing: Boolean, body: String) {
|
||||
if (closing) {
|
||||
if (linkStart >= 0) {
|
||||
links.add(RichLink(linkStart, text.length, linkUrl))
|
||||
linkStart = -1
|
||||
linkUrl = ""
|
||||
}
|
||||
} else {
|
||||
linkStart = text.length
|
||||
linkUrl = extractHref(body)
|
||||
}
|
||||
}
|
||||
|
||||
/** `<img src="cid:…" alt="name">` becomes a visible [imageToken] backed by a [RichImage]. */
|
||||
private fun handleImage(body: String) {
|
||||
val src = extractAttr(body, SRC_ATTR) ?: return
|
||||
if (!src.startsWith(CID_PREFIX)) return
|
||||
val name = extractAttr(body, ALT_ATTR).orEmpty()
|
||||
val start = text.length
|
||||
text.append(imageToken(name))
|
||||
images.add(RichImage(start, text.length, src.removePrefix(CID_PREFIX), name))
|
||||
}
|
||||
|
||||
private fun handleList(closing: Boolean, type: Char) {
|
||||
if (closing) {
|
||||
listType = null
|
||||
} else {
|
||||
listType = type
|
||||
if (type == 'o') olCount = 0
|
||||
}
|
||||
newlineIfNeeded()
|
||||
}
|
||||
|
||||
private fun handleListItem(closing: Boolean, body: String) {
|
||||
if (closing) {
|
||||
endAlignedBlock()
|
||||
return
|
||||
}
|
||||
newlineIfNeeded()
|
||||
// The alignment run starts at the raw line start, so it covers the block marker too.
|
||||
startAlignedBlock(parseTextAlign(extractStyleAttr(body)))
|
||||
if (listType == 'o') {
|
||||
olCount++
|
||||
text.append("$olCount. ")
|
||||
} else {
|
||||
text.append(BULLET_PREFIX)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleQuote(closing: Boolean) {
|
||||
newlineIfNeeded()
|
||||
inQuote = !closing
|
||||
if (!closing) text.append(QUOTE_PREFIX)
|
||||
}
|
||||
|
||||
private fun handleParagraph(closing: Boolean, body: String) {
|
||||
if (closing) {
|
||||
endAlignedBlock()
|
||||
newlineIfNeeded()
|
||||
} else {
|
||||
newlineIfNeeded()
|
||||
startAlignedBlock(parseTextAlign(extractStyleAttr(body)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A leading `<div style>` before any content is the base-style wrapper [RichTextHtml.toHtml]
|
||||
* emits — it must not add a newline (and its closing tag lands right after the last block's
|
||||
* newline, where [newlineIfNeeded] is a no-op, so the wrapper never adds stray text).
|
||||
*/
|
||||
private fun handleDiv(closing: Boolean, body: String) {
|
||||
val wrapperCss = if (!closing && text.isEmpty() && baseStyle == null) extractStyleAttr(body) else null
|
||||
if (wrapperCss != null) {
|
||||
baseStyle = parseBaseStyle(wrapperCss)
|
||||
} else {
|
||||
newlineIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAlignedBlock(align: RichAlign?) {
|
||||
endAlignedBlock()
|
||||
blockStart = text.length
|
||||
blockAlign = align
|
||||
}
|
||||
|
||||
private fun endAlignedBlock() {
|
||||
val align = blockAlign
|
||||
if (align != null && blockStart >= 0 && blockStart < text.length) {
|
||||
alignments.add(RichAlignment(blockStart, text.length, align))
|
||||
}
|
||||
blockStart = -1
|
||||
blockAlign = null
|
||||
}
|
||||
|
||||
private fun handleText(chunk: String) {
|
||||
// Drop the insignificant whitespace a pretty-printer leaves between block tags (blank runs at
|
||||
// a line start, or any blank run with a newline), but keep a real space between inline runs.
|
||||
if (!(chunk.isBlank() && (atLineStart() || chunk.contains('\n')))) text.append(unescape(chunk))
|
||||
}
|
||||
|
||||
private fun finish(): RichTextContent {
|
||||
endAlignedBlock()
|
||||
while (openStyles.isNotEmpty()) recordSpans(openStyles.removeLast(), text.length)
|
||||
if (linkStart >= 0) links.add(RichLink(linkStart, text.length, linkUrl))
|
||||
val out = text.toString().trimEnd('\n')
|
||||
val len = out.length
|
||||
return RichTextContent(
|
||||
text = out,
|
||||
spans = mergeSameValueSpans(spans.mapNotNull { it.clampedTo(len) }),
|
||||
links = links.mapNotNull { it.clampedTo(len) },
|
||||
alignments = mergedAlignments(alignments.mapNotNull { it.clampedTo(len) }),
|
||||
images = images.filter { it.end <= len },
|
||||
baseStyle = baseStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RichSpan.clampedTo(len: Int): RichSpan? {
|
||||
val e = minOf(end, len)
|
||||
return if (start < e) copy(end = e) else null
|
||||
}
|
||||
|
||||
private fun RichLink.clampedTo(len: Int): RichLink? {
|
||||
val e = minOf(end, len)
|
||||
return if (start < e) copy(end = e) else null
|
||||
}
|
||||
|
||||
private fun RichAlignment.clampedTo(len: Int): RichAlignment? {
|
||||
val e = minOf(end, len)
|
||||
return if (start < e) copy(end = e) else null
|
||||
}
|
||||
|
||||
/** Merges same-alignment runs on adjacent lines (ranges separated by exactly the newline). */
|
||||
private fun mergedAlignments(alignments: List<RichAlignment>): List<RichAlignment> {
|
||||
val merged = ArrayList<RichAlignment>()
|
||||
for (alignment in alignments.sortedBy { it.start }) {
|
||||
val last = merged.lastOrNull()
|
||||
if (last != null && last.align == alignment.align && alignment.start <= last.end + 1) {
|
||||
merged[merged.size - 1] = last.copy(end = maxOf(last.end, alignment.end))
|
||||
} else {
|
||||
merged.add(alignment)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// --- tag-attribute and CSS helpers ---
|
||||
|
||||
private const val CID_PREFIX = "cid:"
|
||||
|
||||
private fun attrRegex(name: String) =
|
||||
Regex("(?:^|\\s)$name\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE)
|
||||
|
||||
private val HREF_ATTR = attrRegex("href")
|
||||
private val STYLE_ATTR = attrRegex("style")
|
||||
private val SRC_ATTR = attrRegex("src")
|
||||
private val ALT_ATTR = attrRegex("alt")
|
||||
|
||||
private fun extractAttr(tagBody: String, attr: Regex): String? {
|
||||
val match = attr.find(tagBody) ?: return null
|
||||
val (doubleQuoted, singleQuoted) = match.destructured
|
||||
return unescape(doubleQuoted.ifEmpty { singleQuoted })
|
||||
}
|
||||
|
||||
internal fun extractHref(tagBody: String): String = extractAttr(tagBody, HREF_ATTR) ?: ""
|
||||
|
||||
/** The tag's `style` attribute value, or null when absent (an empty attribute yields ""). */
|
||||
internal fun extractStyleAttr(tagBody: String): String? = extractAttr(tagBody, STYLE_ATTR)
|
||||
|
||||
private inline fun forEachCssDeclaration(css: String, action: (prop: String, value: String) -> Unit) {
|
||||
css.split(';').forEach { declaration ->
|
||||
val prop = declaration.substringBefore(':').trim().lowercase()
|
||||
val value = declaration.substringAfter(':', "").trim()
|
||||
if (prop.isNotEmpty() && value.isNotEmpty()) action(prop, value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps recognized inline CSS declarations to [RichStyle]s; unknown properties are skipped. */
|
||||
internal fun parseInlineStyles(css: String?): List<RichStyle> {
|
||||
if (css == null) return emptyList()
|
||||
val styles = ArrayList<RichStyle>()
|
||||
forEachCssDeclaration(css) { prop, value -> styleForCss(prop, value)?.let(styles::add) }
|
||||
return styles
|
||||
}
|
||||
|
||||
private fun styleForCss(prop: String, value: String): RichStyle? = when (prop) {
|
||||
"font-family" -> RichStyle.FontFamily(value)
|
||||
"font-size" -> parseFontSizePt(value)?.let { RichStyle.FontSize(it) }
|
||||
"color" -> parseCssColor(value)?.let { RichStyle.FontColor(it) }
|
||||
"background-color" -> parseCssColor(value)?.let { RichStyle.Highlight(it) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Reads the base-style wrapper's declarations; unknown properties are ignored. */
|
||||
internal fun parseBaseStyle(css: String): RichBaseStyle {
|
||||
var fontCss: String? = null
|
||||
var fontSizePt: Int? = null
|
||||
forEachCssDeclaration(css) { prop, value ->
|
||||
when (prop) {
|
||||
"font-family" -> fontCss = value
|
||||
"font-size" -> fontSizePt = parseFontSizePt(value)
|
||||
}
|
||||
}
|
||||
return RichBaseStyle(fontCss, fontSizePt)
|
||||
}
|
||||
|
||||
private val FONT_SIZE = Regex("^(\\d+(?:\\.\\d+)?)\\s*(pt|px)$", RegexOption.IGNORE_CASE)
|
||||
private const val PT_PER_PX = 3.0 / 4.0
|
||||
|
||||
/** A CSS font size in points; `px` values convert at 3/4 pt per px, rounded to the nearest int. */
|
||||
internal fun parseFontSizePt(value: String): Int? {
|
||||
val match = FONT_SIZE.find(value) ?: return null
|
||||
val (number, unit) = match.destructured
|
||||
val size = number.toDoubleOrNull() ?: return null
|
||||
val pt = if (unit.equals("px", ignoreCase = true)) size * PT_PER_PX else size
|
||||
return pt.roundToInt().takeIf { it > 0 }
|
||||
}
|
||||
|
||||
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
|
||||
private const val SHORT_HEX_LEN = 3
|
||||
private const val LONG_HEX_LEN = 6
|
||||
private const val COLOR_RADIX = 16
|
||||
|
||||
/** `#rgb` or `#rrggbb` as an opaque ARGB int, or null for anything else. */
|
||||
internal fun parseCssColor(value: String): Int? {
|
||||
if (!value.startsWith("#")) return null
|
||||
val hex = value.drop(1)
|
||||
val expanded = when (hex.length) {
|
||||
SHORT_HEX_LEN -> buildString { hex.forEach { append(it).append(it) } }
|
||||
LONG_HEX_LEN -> hex
|
||||
else -> return null
|
||||
}
|
||||
val rgb = expanded.toIntOrNull(COLOR_RADIX) ?: return null
|
||||
return OPAQUE_ALPHA or rgb
|
||||
}
|
||||
|
||||
/** The [RichAlign] from a style attribute's `text-align` declaration, or null. */
|
||||
internal fun parseTextAlign(css: String?): RichAlign? {
|
||||
if (css == null) return null
|
||||
var align: RichAlign? = null
|
||||
forEachCssDeclaration(css) { prop, value ->
|
||||
if (prop == "text-align") align = richAlignFor(value.lowercase())
|
||||
}
|
||||
return align
|
||||
}
|
||||
|
||||
private fun richAlignFor(value: String): RichAlign? = when (value) {
|
||||
"left", "start" -> RichAlign.START
|
||||
"center" -> RichAlign.CENTER
|
||||
"right", "end" -> RichAlign.END
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun unescape(s: String): String = s
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
@@ -55,6 +55,8 @@ fun LibreMailApp(
|
||||
startupViewModel: StartupReportViewModel = hiltViewModel(),
|
||||
pendingCompose: ComposePrefill? = null,
|
||||
onComposeHandled: () -> Unit = {},
|
||||
pendingOpenMessageId: String? = null,
|
||||
onOpenMessageHandled: () -> Unit = {},
|
||||
) {
|
||||
val startDestination by appViewModel.startDestination.collectAsStateWithLifecycle()
|
||||
// Hold (render nothing) until the account count is known, so a cold start never flashes the
|
||||
@@ -79,6 +81,15 @@ fun LibreMailApp(
|
||||
onComposeHandled()
|
||||
}
|
||||
|
||||
// A tapped new-mail notification opens that message's reader on top of the current stack, so back
|
||||
// lands where the user was (the mailbox on a cold start). If the account vanished in the meantime
|
||||
// (start = onboarding) the request is consumed without navigating.
|
||||
LaunchedEffect(pendingOpenMessageId) {
|
||||
val messageId = pendingOpenMessageId ?: return@LaunchedEffect
|
||||
if (start != Routes.ONBOARDING) navController.navigate(Routes.reader(messageId))
|
||||
onOpenMessageHandled()
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = start,
|
||||
|
||||
@@ -10,6 +10,9 @@ import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -28,6 +31,8 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -50,9 +55,12 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -152,24 +160,11 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
SuggestionList(state.suggestions, viewModel::pickSuggestion)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = state.cc,
|
||||
onValueChange = viewModel::onCcChange,
|
||||
label = { Text(stringResource(R.string.compose_cc)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = state.bcc,
|
||||
onValueChange = viewModel::onBccChange,
|
||||
label = { Text(stringResource(R.string.compose_bcc)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
CcBccFields(
|
||||
cc = state.cc,
|
||||
onCcChange = viewModel::onCcChange,
|
||||
bcc = state.bcc,
|
||||
onBccChange = viewModel::onBccChange,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
@@ -182,6 +177,8 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
)
|
||||
AttachmentsSection(
|
||||
attachments = state.attachments,
|
||||
highlight = state.highlightAttach,
|
||||
onHighlightShown = viewModel::consumeAttachHighlight,
|
||||
onAttach = { attachmentPicker.launch(arrayOf("*/*")) },
|
||||
onRemove = viewModel::removeAttachment,
|
||||
)
|
||||
@@ -207,6 +204,29 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showAttachmentPrompt) {
|
||||
AttachmentPromptDialog(
|
||||
onAttach = viewModel::attachInstead,
|
||||
onSendAnyway = viewModel::sendAnyway,
|
||||
onDismiss = viewModel::dismissAttachmentPrompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when Send is tapped on a message that mentions an attachment but carries none. "Yes"
|
||||
* returns to composing with the attach button highlighted; "No" sends the message as-is.
|
||||
*/
|
||||
@Composable
|
||||
private fun AttachmentPromptDialog(onAttach: () -> Unit, onSendAnyway: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.confirm_attachment_title)) },
|
||||
text = { Text(stringResource(R.string.confirm_attachment_text)) },
|
||||
confirmButton = { TextButton(onClick = onAttach) { Text(stringResource(R.string.action_yes)) } },
|
||||
dismissButton = { TextButton(onClick = onSendAnyway) { Text(stringResource(R.string.action_no)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -245,15 +265,111 @@ private fun FromRow(accounts: List<Account>, selectedId: String?, onSelect: (Str
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cc/Bcc start collapsed into link buttons under the To box so the body gets the vertical space.
|
||||
* A field expands when its link is tapped (taking focus), or by itself once it carries recipients
|
||||
* (reply-all/mailto prefill, a resumed draft). Once shown, a field never re-collapses — emptying
|
||||
* it mid-edit must not make it vanish.
|
||||
*/
|
||||
@Composable
|
||||
private fun CcBccFields(cc: String, onCcChange: (String) -> Unit, bcc: String, onBccChange: (String) -> Unit) {
|
||||
var ccShown by rememberSaveable { mutableStateOf(cc.isNotBlank()) }
|
||||
var bccShown by rememberSaveable { mutableStateOf(bcc.isNotBlank()) }
|
||||
LaunchedEffect(cc, bcc) {
|
||||
if (cc.isNotBlank()) ccShown = true
|
||||
if (bcc.isNotBlank()) bccShown = true
|
||||
}
|
||||
val ccFocus = remember { FocusRequester() }
|
||||
val bccFocus = remember { FocusRequester() }
|
||||
// Deliberately not saveable: only a link tap moves focus, never rotation or a loading draft.
|
||||
var pendingFocus by remember { mutableStateOf<FocusRequester?>(null) }
|
||||
LaunchedEffect(pendingFocus) {
|
||||
pendingFocus?.requestFocus()
|
||||
pendingFocus = null
|
||||
}
|
||||
|
||||
if (ccShown) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
RecipientField(cc, onCcChange, R.string.compose_cc, ccFocus)
|
||||
}
|
||||
if (bccShown) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
RecipientField(bcc, onBccChange, R.string.compose_bcc, bccFocus)
|
||||
}
|
||||
if (!ccShown || !bccShown) {
|
||||
Row {
|
||||
if (!ccShown) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
ccShown = true
|
||||
pendingFocus = ccFocus
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.compose_cc))
|
||||
}
|
||||
}
|
||||
if (!bccShown) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
bccShown = true
|
||||
pendingFocus = bccFocus
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.compose_bcc))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipientField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
@StringRes labelRes: Int,
|
||||
focusRequester: FocusRequester,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(stringResource(labelRes)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AttachmentsSection(
|
||||
attachments: List<OutgoingAttachment>,
|
||||
highlight: Boolean,
|
||||
onHighlightShown: () -> Unit,
|
||||
onAttach: () -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
) {
|
||||
// Answering "Yes" on the attachment prompt lands back here: pulse the attach button a few
|
||||
// times to draw the eye, then report the highlight as consumed.
|
||||
val pulse = remember { Animatable(0f) }
|
||||
LaunchedEffect(highlight) {
|
||||
if (highlight) {
|
||||
repeat(3) {
|
||||
pulse.animateTo(1f, tween(durationMillis = 300))
|
||||
pulse.animateTo(0f, tween(durationMillis = 300))
|
||||
}
|
||||
onHighlightShown()
|
||||
} else {
|
||||
pulse.snapTo(0f)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
TextButton(onClick = onAttach) {
|
||||
TextButton(
|
||||
onClick = onAttach,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = pulse.value),
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.compose_attach))
|
||||
|
||||
@@ -48,6 +48,10 @@ data class ComposeUiState(
|
||||
val contactsAllowed: Boolean = false,
|
||||
val sending: Boolean = false,
|
||||
val error: String? = null,
|
||||
/** Send was tapped while the text mentions an attachment but none is attached: ask first. */
|
||||
val showAttachmentPrompt: Boolean = false,
|
||||
/** Draw the eye to the attach button — the user answered the attachment prompt with "Yes". */
|
||||
val highlightAttach: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -90,6 +94,9 @@ class ComposeViewModel @Inject constructor(
|
||||
/** The signature block last appended to the body, so a From-change can swap it out cleanly. */
|
||||
private var appliedSignatureBlock = SignatureBlock.EMPTY
|
||||
|
||||
/** Word-bounded "attach" and variants (attached, attachment(s), attaching, attaches). */
|
||||
private val attachmentMention = Regex("""\battach(?:ed|ment|ments|ing|es)?\b""", RegexOption.IGNORE_CASE)
|
||||
|
||||
init {
|
||||
if (draftId != null) {
|
||||
viewModelScope.launch {
|
||||
@@ -98,6 +105,7 @@ class ComposeViewModel @Inject constructor(
|
||||
it.copy(
|
||||
to = draft.to,
|
||||
cc = draft.cc,
|
||||
bcc = draft.bcc,
|
||||
subject = draft.subject,
|
||||
body = draft.body,
|
||||
bodyHtml = draft.bodyHtml,
|
||||
@@ -260,7 +268,24 @@ class ComposeViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun send() {
|
||||
fun send() = trySend(checkAttachments = true)
|
||||
|
||||
/** "No" on the attachment prompt — the user confirmed nothing needs attaching, so send as-is. */
|
||||
fun sendAnyway() {
|
||||
_state.update { it.copy(showAttachmentPrompt = false) }
|
||||
trySend(checkAttachments = false)
|
||||
}
|
||||
|
||||
/** "Yes" on the attachment prompt — back to composing, with the attach button highlighted. */
|
||||
fun attachInstead() = _state.update { it.copy(showAttachmentPrompt = false, highlightAttach = true) }
|
||||
|
||||
/** The prompt was dismissed without choosing: stay composing, no send, no highlight. */
|
||||
fun dismissAttachmentPrompt() = _state.update { it.copy(showAttachmentPrompt = false) }
|
||||
|
||||
/** The attach-button highlight has finished animating. */
|
||||
fun consumeAttachHighlight() = _state.update { it.copy(highlightAttach = false) }
|
||||
|
||||
private fun trySend(checkAttachments: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val s = _state.value
|
||||
// Await the account list if it hasn't emitted yet, so an early tap doesn't wrongly
|
||||
@@ -270,37 +295,39 @@ class ComposeViewModel @Inject constructor(
|
||||
when {
|
||||
account == null -> _state.update { it.copy(error = "Add an account first") }
|
||||
s.to.isBlank() -> _state.update { it.copy(error = "Add a recipient") }
|
||||
else -> {
|
||||
_state.update { it.copy(sending = true, error = null) }
|
||||
mailRepository.sendMessage(
|
||||
OutgoingMessage(
|
||||
accountId = account.id,
|
||||
to = s.to,
|
||||
cc = s.cc,
|
||||
bcc = s.bcc,
|
||||
subject = s.subject,
|
||||
body = s.body,
|
||||
bodyHtml = s.bodyHtml,
|
||||
attachments = s.attachments,
|
||||
),
|
||||
).fold(
|
||||
onSuccess = {
|
||||
draftId?.let { mailRepository.deleteDraft(it) }
|
||||
_state.update { it.copy(sending = false) }
|
||||
finish()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
sending = false,
|
||||
error =
|
||||
e.message ?: "Could not send",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
checkAttachments && s.attachments.isEmpty() && mentionsAttachment(s) ->
|
||||
_state.update { it.copy(showAttachmentPrompt = true) }
|
||||
else -> performSend(account, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The classic forgotten-attachment guard: does the subject or body talk about attaching? */
|
||||
private fun mentionsAttachment(s: ComposeUiState): Boolean =
|
||||
attachmentMention.containsMatchIn(s.subject) || attachmentMention.containsMatchIn(s.body)
|
||||
|
||||
private suspend fun performSend(account: Account, s: ComposeUiState) {
|
||||
_state.update { it.copy(sending = true, error = null) }
|
||||
mailRepository.sendMessage(
|
||||
OutgoingMessage(
|
||||
accountId = account.id,
|
||||
to = s.to,
|
||||
cc = s.cc,
|
||||
bcc = s.bcc,
|
||||
subject = s.subject,
|
||||
body = s.body,
|
||||
bodyHtml = s.bodyHtml,
|
||||
attachments = s.attachments,
|
||||
),
|
||||
).fold(
|
||||
onSuccess = {
|
||||
draftId?.let { mailRepository.deleteDraft(it) }
|
||||
_state.update { it.copy(sending = false) }
|
||||
finish()
|
||||
},
|
||||
onFailure = { e ->
|
||||
_state.update { it.copy(sending = false, error = e.message ?: "Could not send") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,17 +30,26 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.ParagraphStyle
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.libremail.R
|
||||
import org.libremail.richtext.BlockMarker
|
||||
import org.libremail.richtext.RichAlign
|
||||
import org.libremail.richtext.RichAlignment
|
||||
import org.libremail.richtext.RichBaseStyle
|
||||
import org.libremail.richtext.RichImage
|
||||
import org.libremail.richtext.RichLink
|
||||
import org.libremail.richtext.RichSpan
|
||||
import org.libremail.richtext.RichStyle
|
||||
@@ -51,6 +60,16 @@ import org.libremail.richtext.RichTextHtml
|
||||
/** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */
|
||||
private const val URL_TAG = "libremail:url"
|
||||
|
||||
/**
|
||||
* String-annotation tag carrying a parameterized style's identity (see [encodeStyle]), so e.g. a
|
||||
* [RichStyle.FontColor] span can never be confused with the link-color paint or any other span that
|
||||
* happens to share its visual [SpanStyle].
|
||||
*/
|
||||
internal const val STYLE_TAG = "libremail:style"
|
||||
|
||||
/** String-annotation tag carrying an inline image's content id and display name over its token. */
|
||||
internal const val IMAGE_TAG = "libremail:image"
|
||||
|
||||
/**
|
||||
* A rich-text body editor: a formatting toolbar (bold / italic / underline, bulleted + numbered
|
||||
* lists, block quote, and link) above a rounded [OutlinedTextField]. It converts its
|
||||
@@ -60,6 +79,9 @@ private const val URL_TAG = "libremail:url"
|
||||
*
|
||||
* The field is a normal Compose text field, so TalkBack, text selection, and large system fonts all
|
||||
* work as usual; the toolbar buttons carry content descriptions and toggle state for accessibility.
|
||||
*
|
||||
* [resolveFont] maps a CSS font-family stack to a Compose [FontFamily] for display; the default
|
||||
* resolves nothing, leaving the system font (the model still round-trips the CSS value untouched).
|
||||
*/
|
||||
@Composable
|
||||
fun RichTextBodyField(
|
||||
@@ -68,21 +90,28 @@ fun RichTextBodyField(
|
||||
onBodyChange: (plain: String, html: String?) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
resolveFont: (String) -> FontFamily? = { null },
|
||||
) {
|
||||
val linkColor = MaterialTheme.colorScheme.primary
|
||||
var value by remember { mutableStateOf(seedValue(body, bodyHtml, linkColor)) }
|
||||
val seed = remember { seedContent(body, bodyHtml) }
|
||||
var value by remember { mutableStateOf(seed.toSeededValue(linkColor, resolveFont)) }
|
||||
// The message-wide base style is position-independent, so it lives outside the AnnotatedString
|
||||
// (which only carries positioned spans) and is recombined with the field's content on emit.
|
||||
var baseStyle by remember { mutableStateOf(seed.baseStyle) }
|
||||
// Tracks the (plain, html) we last pushed up, so an external change (draft load / signature swap)
|
||||
// re-seeds the field but our own emissions do not fight the user's cursor.
|
||||
var lastEmitted by remember { mutableStateOf(body to bodyHtml) }
|
||||
|
||||
if (body to bodyHtml != lastEmitted) {
|
||||
value = seedValue(body, bodyHtml, linkColor)
|
||||
val content = seedContent(body, bodyHtml)
|
||||
value = content.toSeededValue(linkColor, resolveFont)
|
||||
baseStyle = content.baseStyle
|
||||
lastEmitted = body to bodyHtml
|
||||
}
|
||||
|
||||
fun emit(newValue: TextFieldValue) {
|
||||
value = newValue
|
||||
val content = newValue.annotatedString.toRichContent()
|
||||
val content = newValue.annotatedString.toRichContent(baseStyle)
|
||||
val html = if (content.hasFormatting()) RichTextHtml.toHtml(content) else null
|
||||
lastEmitted = content.text to html
|
||||
onBodyChange(content.text, html)
|
||||
@@ -93,8 +122,8 @@ fun RichTextBodyField(
|
||||
Column(modifier) {
|
||||
FormattingToolbar(
|
||||
value = value,
|
||||
onToggleStyle = { style -> emit(applyStyle(value, style, linkColor)) },
|
||||
onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor)) },
|
||||
onToggleStyle = { style -> emit(applyStyle(value, style, linkColor, resolveFont)) },
|
||||
onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor, resolveFont)) },
|
||||
onLink = { showLinkDialog = true },
|
||||
)
|
||||
OutlinedTextField(
|
||||
@@ -102,6 +131,7 @@ fun RichTextBodyField(
|
||||
onValueChange = ::emit,
|
||||
label = { Text(label) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
textStyle = applyBaseStyle(LocalTextStyle.current, baseStyle, resolveFont),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
)
|
||||
@@ -113,7 +143,7 @@ fun RichTextBodyField(
|
||||
enabled = hasSelection,
|
||||
onDismiss = { showLinkDialog = false },
|
||||
onConfirm = { url ->
|
||||
emit(applyLink(value, url, linkColor))
|
||||
emit(applyLink(value, url, linkColor, resolveFont))
|
||||
showLinkDialog = false
|
||||
},
|
||||
)
|
||||
@@ -141,23 +171,23 @@ private fun FormattingToolbar(
|
||||
FormatButton(
|
||||
label = "B",
|
||||
description = stringResource(R.string.format_bold),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.BOLD),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.Bold),
|
||||
fontWeight = FontWeight.Bold,
|
||||
onClick = { onToggleStyle(RichStyle.BOLD) },
|
||||
onClick = { onToggleStyle(RichStyle.Bold) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "I",
|
||||
description = stringResource(R.string.format_italic),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.ITALIC),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.Italic),
|
||||
fontStyle = FontStyle.Italic,
|
||||
onClick = { onToggleStyle(RichStyle.ITALIC) },
|
||||
onClick = { onToggleStyle(RichStyle.Italic) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "U",
|
||||
description = stringResource(R.string.format_underline),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.UNDERLINE),
|
||||
active = RichTextEditing.isStyled(content, start, end, RichStyle.Underline),
|
||||
underline = true,
|
||||
onClick = { onToggleStyle(RichStyle.UNDERLINE) },
|
||||
onClick = { onToggleStyle(RichStyle.Underline) },
|
||||
)
|
||||
FormatButton(
|
||||
label = "•",
|
||||
@@ -250,17 +280,28 @@ private fun LinkDialog(enabled: Boolean, onDismiss: () -> Unit, onConfirm: (Stri
|
||||
|
||||
// --- editor-op plumbing (TextFieldValue <-> RichTextContent) ---
|
||||
|
||||
private fun applyStyle(value: TextFieldValue, style: RichStyle, linkColor: Color): TextFieldValue {
|
||||
/** Toggles [style] over the selection and rebuilds the field value (one-liner for toolbar wiring). */
|
||||
internal fun applyStyle(
|
||||
value: TextFieldValue,
|
||||
style: RichStyle,
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily? = { null },
|
||||
): TextFieldValue {
|
||||
val updated = RichTextEditing.toggleStyle(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
value.selection.max,
|
||||
style,
|
||||
)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
|
||||
}
|
||||
|
||||
private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Color): TextFieldValue {
|
||||
private fun applyBlock(
|
||||
value: TextFieldValue,
|
||||
marker: BlockMarker,
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily?,
|
||||
): TextFieldValue {
|
||||
val result = RichTextEditing.toggleBlock(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
@@ -268,55 +309,172 @@ private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Co
|
||||
marker,
|
||||
)
|
||||
return TextFieldValue(
|
||||
result.content.toAnnotatedString(linkColor),
|
||||
result.content.toAnnotatedString(linkColor, resolveFont),
|
||||
TextRange(result.selectionStart, result.selectionEnd),
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyLink(value: TextFieldValue, url: String, linkColor: Color): TextFieldValue {
|
||||
private fun applyLink(
|
||||
value: TextFieldValue,
|
||||
url: String,
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily?,
|
||||
): TextFieldValue {
|
||||
val updated = RichTextEditing.applyLink(
|
||||
value.annotatedString.toRichContent(),
|
||||
value.selection.min,
|
||||
value.selection.max,
|
||||
url,
|
||||
)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
|
||||
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
|
||||
}
|
||||
|
||||
private fun seedValue(body: String, bodyHtml: String?, linkColor: Color): TextFieldValue {
|
||||
val content = if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body)
|
||||
val annotated = content.toAnnotatedString(linkColor)
|
||||
private fun seedContent(body: String, bodyHtml: String?): RichTextContent =
|
||||
if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body)
|
||||
|
||||
private fun RichTextContent.toSeededValue(linkColor: Color, resolveFont: (String) -> FontFamily?): TextFieldValue {
|
||||
val annotated = toAnnotatedString(linkColor, resolveFont)
|
||||
return TextFieldValue(annotated, TextRange(annotated.length))
|
||||
}
|
||||
|
||||
/** Applies the message-wide base font family/size on top of the field's ambient text style. */
|
||||
internal fun applyBaseStyle(
|
||||
textStyle: TextStyle,
|
||||
base: RichBaseStyle?,
|
||||
resolveFont: (String) -> FontFamily?,
|
||||
): TextStyle {
|
||||
if (base == null) return textStyle
|
||||
return textStyle.copy(
|
||||
fontFamily = base.fontCss?.let(resolveFont) ?: textStyle.fontFamily,
|
||||
fontSize = base.fontSizePt?.let { it.sp } ?: textStyle.fontSize,
|
||||
)
|
||||
}
|
||||
|
||||
/** Maps the app rich-text model onto a Compose [AnnotatedString] for display/editing. */
|
||||
internal fun RichTextContent.toAnnotatedString(linkColor: Color): AnnotatedString = buildAnnotatedString {
|
||||
internal fun RichTextContent.toAnnotatedString(
|
||||
linkColor: Color,
|
||||
resolveFont: (String) -> FontFamily? = { null },
|
||||
): AnnotatedString = buildAnnotatedString {
|
||||
append(text)
|
||||
spans.forEach { span -> addStyle(spanStyleFor(span.style), span.start, span.end) }
|
||||
spans.forEach { span ->
|
||||
addStyle(spanStyleFor(span.style, resolveFont), span.start, span.end)
|
||||
encodeStyle(span.style)?.let { addStringAnnotation(STYLE_TAG, it, span.start, span.end) }
|
||||
}
|
||||
links.forEach { link ->
|
||||
addStyle(SpanStyle(color = linkColor), link.start, link.end)
|
||||
addStringAnnotation(URL_TAG, link.url, link.start, link.end)
|
||||
}
|
||||
images.forEach { image ->
|
||||
addStringAnnotation(IMAGE_TAG, encodeImage(image), image.start, image.end)
|
||||
}
|
||||
disjoint(alignments).forEach { alignment ->
|
||||
addStyle(ParagraphStyle(textAlign = alignment.align.toTextAlign()), alignment.start, alignment.end)
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a Compose [AnnotatedString] back to the app model, reading single-attribute span styles. */
|
||||
internal fun AnnotatedString.toRichContent(): RichTextContent {
|
||||
val richSpans = spanStyles.mapNotNull { range ->
|
||||
styleOf(range.item)?.let { RichSpan(range.start, range.end, it) }
|
||||
/**
|
||||
* Maps a Compose [AnnotatedString] back to the app model. Simple styles are recovered from their
|
||||
* single-attribute [SpanStyle]s; parameterized styles and images from their string annotations
|
||||
* (their visual paint is deliberately ignored, so the link color can never masquerade as a
|
||||
* [RichStyle.FontColor]). [baseStyle] is position-independent and rides alongside unchanged.
|
||||
*/
|
||||
internal fun AnnotatedString.toRichContent(baseStyle: RichBaseStyle? = null): RichTextContent {
|
||||
val simple = spanStyles.mapNotNull { range ->
|
||||
simpleStyleOf(range.item)?.let { RichSpan(range.start, range.end, it) }
|
||||
}
|
||||
val parameterized = getStringAnnotations(STYLE_TAG, 0, length).mapNotNull { range ->
|
||||
decodeStyle(range.item)?.let { RichSpan(range.start, range.end, it) }
|
||||
}
|
||||
val links = getStringAnnotations(URL_TAG, 0, length).map { RichLink(it.start, it.end, it.item) }
|
||||
return RichTextContent(text, richSpans, links)
|
||||
val images = getStringAnnotations(IMAGE_TAG, 0, length).mapNotNull(::decodeImage)
|
||||
val alignments = paragraphStyles.mapNotNull { range ->
|
||||
range.item.textAlign.toRichAlign()?.let { RichAlignment(range.start, range.end, it) }
|
||||
}
|
||||
return RichTextContent(
|
||||
text = text,
|
||||
spans = (simple + parameterized).sortedBy { it.start },
|
||||
links = links,
|
||||
alignments = alignments,
|
||||
images = images,
|
||||
baseStyle = baseStyle,
|
||||
)
|
||||
}
|
||||
|
||||
private fun spanStyleFor(style: RichStyle): SpanStyle = when (style) {
|
||||
RichStyle.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
RichStyle.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
RichStyle.UNDERLINE -> SpanStyle(textDecoration = TextDecoration.Underline)
|
||||
/** The visual paint for [style]; identity is carried separately (see [STYLE_TAG]). */
|
||||
private fun spanStyleFor(style: RichStyle, resolveFont: (String) -> FontFamily?): SpanStyle = when (style) {
|
||||
RichStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
RichStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
RichStyle.Underline -> SpanStyle(textDecoration = TextDecoration.Underline)
|
||||
RichStyle.Strikethrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
|
||||
is RichStyle.FontFamily -> SpanStyle(fontFamily = resolveFont(style.css))
|
||||
is RichStyle.FontSize -> SpanStyle(fontSize = style.pt.sp)
|
||||
is RichStyle.FontColor -> SpanStyle(color = Color(style.argb))
|
||||
is RichStyle.Highlight -> SpanStyle(background = Color(style.argb))
|
||||
}
|
||||
|
||||
private fun styleOf(span: SpanStyle): RichStyle? = when {
|
||||
span.fontWeight == FontWeight.Bold -> RichStyle.BOLD
|
||||
span.fontStyle == FontStyle.Italic -> RichStyle.ITALIC
|
||||
span.textDecoration == TextDecoration.Underline -> RichStyle.UNDERLINE
|
||||
else -> null // e.g. the link color span, which is carried by the URL annotation instead
|
||||
private fun simpleStyleOf(span: SpanStyle): RichStyle? = when {
|
||||
span.fontWeight == FontWeight.Bold -> RichStyle.Bold
|
||||
span.fontStyle == FontStyle.Italic -> RichStyle.Italic
|
||||
span.textDecoration == TextDecoration.Underline -> RichStyle.Underline
|
||||
span.textDecoration == TextDecoration.LineThrough -> RichStyle.Strikethrough
|
||||
else -> null // parameterized styles and the link paint are carried by string annotations instead
|
||||
}
|
||||
|
||||
private const val STYLE_FAMILY = "family"
|
||||
private const val STYLE_SIZE = "size"
|
||||
private const val STYLE_COLOR = "color"
|
||||
private const val STYLE_HIGHLIGHT = "highlight"
|
||||
|
||||
/** Compact identity payload for parameterized styles; simple styles need none. */
|
||||
private fun encodeStyle(style: RichStyle): String? = when (style) {
|
||||
RichStyle.Bold, RichStyle.Italic, RichStyle.Underline, RichStyle.Strikethrough -> null
|
||||
is RichStyle.FontFamily -> "$STYLE_FAMILY:${style.css}"
|
||||
is RichStyle.FontSize -> "$STYLE_SIZE:${style.pt}"
|
||||
is RichStyle.FontColor -> "$STYLE_COLOR:${style.argb}"
|
||||
is RichStyle.Highlight -> "$STYLE_HIGHLIGHT:${style.argb}"
|
||||
}
|
||||
|
||||
private fun decodeStyle(payload: String): RichStyle? {
|
||||
val value = payload.substringAfter(':', "")
|
||||
return when (payload.substringBefore(':')) {
|
||||
STYLE_FAMILY -> RichStyle.FontFamily(value)
|
||||
STYLE_SIZE -> value.toIntOrNull()?.let { RichStyle.FontSize(it) }
|
||||
STYLE_COLOR -> value.toIntOrNull()?.let { RichStyle.FontColor(it) }
|
||||
STYLE_HIGHLIGHT -> value.toIntOrNull()?.let { RichStyle.Highlight(it) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private const val IMAGE_SEPARATOR = '\n'
|
||||
|
||||
private fun encodeImage(image: RichImage): String = image.contentId + IMAGE_SEPARATOR + image.name
|
||||
|
||||
private fun decodeImage(range: AnnotatedString.Range<String>): RichImage? {
|
||||
val sep = range.item.indexOf(IMAGE_SEPARATOR)
|
||||
if (sep < 0) return null
|
||||
return RichImage(range.start, range.end, range.item.take(sep), range.item.substring(sep + 1))
|
||||
}
|
||||
|
||||
private fun RichAlign.toTextAlign(): TextAlign = when (this) {
|
||||
RichAlign.START -> TextAlign.Start
|
||||
RichAlign.CENTER -> TextAlign.Center
|
||||
RichAlign.END -> TextAlign.End
|
||||
}
|
||||
|
||||
private fun TextAlign.toRichAlign(): RichAlign? = when (this) {
|
||||
TextAlign.Start, TextAlign.Left -> RichAlign.START
|
||||
TextAlign.Center -> RichAlign.CENTER
|
||||
TextAlign.End, TextAlign.Right -> RichAlign.END
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Paragraph styles must not overlap inside an [AnnotatedString]; sorts and drops any that would. */
|
||||
private fun disjoint(alignments: List<RichAlignment>): List<RichAlignment> {
|
||||
val result = ArrayList<RichAlignment>(alignments.size)
|
||||
for (alignment in alignments.sortedBy { it.start }) {
|
||||
if (alignment.start < alignment.end && (result.isEmpty() || alignment.start >= result.last().end)) {
|
||||
result.add(alignment)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose.format
|
||||
|
||||
/** One selectable swatch for [ColorSwatchRow]: an opaque ARGB color and its accessible label. */
|
||||
data class ColorSwatch(val argb: Int, val label: String)
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose.format
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.libremail.R
|
||||
|
||||
/**
|
||||
* A horizontal row of color swatches with a leading "no color" entry, shared by the font-color and
|
||||
* highlight pickers. The selected swatch (or the "no color" entry when [selectedArgb] is null)
|
||||
* shows a primary-colored ring; every swatch is a labeled button for TalkBack.
|
||||
*/
|
||||
@Composable
|
||||
fun ColorSwatchRow(
|
||||
swatches: List<ColorSwatch>,
|
||||
selectedArgb: Int?,
|
||||
onSelect: (Int?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
noneLabel: String = stringResource(R.string.format_color_none),
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Swatch(color = null, label = noneLabel, isSelected = selectedArgb == null, onClick = { onSelect(null) })
|
||||
swatches.forEach { swatch ->
|
||||
Swatch(
|
||||
color = Color(swatch.argb),
|
||||
label = swatch.label,
|
||||
isSelected = selectedArgb == swatch.argb,
|
||||
onClick = { onSelect(swatch.argb) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Swatch(color: Color?, label: String, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val ring = if (isSelected) colors.primary else colors.outlineVariant
|
||||
val slash = colors.outline
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color ?: Color.Transparent)
|
||||
.border(if (isSelected) 3.dp else 1.dp, ring, CircleShape)
|
||||
.clickable(onClick = onClick, role = Role.Button)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
selected = isSelected
|
||||
},
|
||||
) {
|
||||
if (color == null) {
|
||||
Canvas(Modifier.matchParentSize()) {
|
||||
drawLine(
|
||||
color = slash,
|
||||
start = Offset(size.width * 0.25f, size.height * 0.75f),
|
||||
end = Offset(size.width * 0.75f, size.height * 0.25f),
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,13 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -530,9 +532,12 @@ private fun SelectedAvatar() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The contextual action bar shown while messages are selected. Archive/Delete are the common actions;
|
||||
* the overflow holds the rest. Reply/Reply All/Forward appear only for a single selected message, and
|
||||
* Archive/Spam are hidden while already viewing that role's folder.
|
||||
* The contextual action bar shown while messages are selected. The common actions — Archive, Spam,
|
||||
* Delete — are direct icon buttons (matching the reader's icons-not-menus app bar); Archive/Spam are
|
||||
* hidden while already viewing that role's folder. The overflow keeps only the long tail: Move (no
|
||||
* usable glyph in material-icons-core) and Select all, plus Reply/Reply All/Forward for a single
|
||||
* selected message. At most four 48dp actions plus the close button fit a 320dp-wide bar; the
|
||||
* count title just truncates earlier on such screens.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -558,6 +563,18 @@ private fun SelectionTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (folderRole != FolderRole.ARCHIVE) {
|
||||
IconButton(onClick = onArchive) {
|
||||
// material-icons-core ships no archive glyph; the checkmark leans on the
|
||||
// "done with it = archive it" mail idiom (Google Inbox's sweep).
|
||||
Icon(Icons.Filled.Done, contentDescription = stringResource(R.string.action_archive))
|
||||
}
|
||||
}
|
||||
if (folderRole != FolderRole.SPAM) {
|
||||
IconButton(onClick = onSpam) {
|
||||
Icon(Icons.Filled.Warning, contentDescription = stringResource(R.string.action_spam))
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
}
|
||||
@@ -566,24 +583,6 @@ private fun SelectionTopBar(
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more))
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
if (folderRole != FolderRole.ARCHIVE) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_archive)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onArchive()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (folderRole != FolderRole.SPAM) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_spam)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSpam()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canMove) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_move)) },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -399,8 +399,73 @@ class MailRepositoryImplTest {
|
||||
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("14"), "Archive") }
|
||||
}
|
||||
|
||||
private fun folderEntity(fullName: String, role: String) =
|
||||
FolderEntity("acct", fullName, fullName.substringAfterLast('/'), role, selectable = true, sortOrder = 0)
|
||||
@Test
|
||||
fun `reportSpam prefers the special-use spam folder when the user folder is listed first`() = runTest {
|
||||
val id = "acct:INBOX:16"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
// A user label "Spam" (role from its name) is LISTed before Gmail's built-in \Junk folder.
|
||||
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
|
||||
folderEntity("Spam", "SPAM"),
|
||||
folderEntity("[Gmail]/Spam", "SPAM", specialUse = true),
|
||||
)
|
||||
|
||||
val result = repository.reportSpam(listOf(id))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("16"), "[Gmail]/Spam") }
|
||||
coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), "Spam") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reportSpam prefers the special-use spam folder when it is listed first`() = runTest {
|
||||
val id = "acct:INBOX:18"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
|
||||
folderEntity("[Gmail]/Spam", "SPAM", specialUse = true),
|
||||
folderEntity("Spam", "SPAM"),
|
||||
)
|
||||
|
||||
val result = repository.reportSpam(listOf(id))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("18"), "[Gmail]/Spam") }
|
||||
coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), "Spam") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reportSpam keeps the first listed folder when no special-use folder holds the role`() = runTest {
|
||||
val id = "acct:INBOX:20"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
// No SPECIAL-USE advertised (common outside the big providers): LIST order still decides.
|
||||
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
|
||||
folderEntity("Junk", "SPAM"),
|
||||
folderEntity("Spam", "SPAM"),
|
||||
)
|
||||
|
||||
val result = repository.reportSpam(listOf(id))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("20"), "Junk") }
|
||||
}
|
||||
|
||||
private fun folderEntity(fullName: String, role: String, specialUse: Boolean = false) = FolderEntity(
|
||||
accountId = "acct",
|
||||
fullName = fullName,
|
||||
displayName = fullName.substringAfterLast('/'),
|
||||
role = role,
|
||||
selectable = true,
|
||||
sortOrder = 0,
|
||||
specialUse = specialUse,
|
||||
)
|
||||
|
||||
private fun attachmentEntity(messageId: String, partIndex: Int, filename: String) =
|
||||
AttachmentEntity(messageId, partIndex, filename, "application/octet-stream", 10L)
|
||||
|
||||
@@ -4,6 +4,7 @@ package org.libremail.richtext
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RichTextEditingTest {
|
||||
@@ -11,23 +12,76 @@ class RichTextEditingTest {
|
||||
@Test
|
||||
fun `toggleStyle adds then removes a style over the selection`() {
|
||||
val base = RichTextContent("hello")
|
||||
val bold = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.BOLD)
|
||||
assertEquals(listOf(RichSpan(0, 5, RichStyle.BOLD)), bold.spans)
|
||||
val bold = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.Bold)
|
||||
assertEquals(listOf(RichSpan(0, 5, RichStyle.Bold)), bold.spans)
|
||||
|
||||
val plain = RichTextEditing.toggleStyle(bold, 0, 5, RichStyle.BOLD)
|
||||
val plain = RichTextEditing.toggleStyle(bold, 0, 5, RichStyle.Bold)
|
||||
assertTrue(plain.spans.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleStyle over a fully styled sub-range removes just that part`() {
|
||||
val bold = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.BOLD)))
|
||||
val result = RichTextEditing.toggleStyle(bold, 1, 3, RichStyle.BOLD)
|
||||
val bold = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.Bold)))
|
||||
val result = RichTextEditing.toggleStyle(bold, 1, 3, RichStyle.Bold)
|
||||
assertEquals(
|
||||
listOf(RichSpan(0, 1, RichStyle.BOLD), RichSpan(3, 5, RichStyle.BOLD)),
|
||||
listOf(RichSpan(0, 1, RichStyle.Bold), RichSpan(3, 5, RichStyle.Bold)),
|
||||
result.spans.sortedBy { it.start },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleStyle with a different value of the same kind replaces it`() {
|
||||
val base = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.FontSize(12))))
|
||||
val resized = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.FontSize(18))
|
||||
assertEquals(listOf(RichSpan(0, 5, RichStyle.FontSize(18))), resized.spans)
|
||||
|
||||
val removed = RichTextEditing.toggleStyle(resized, 0, 5, RichStyle.FontSize(18))
|
||||
assertTrue(removed.spans.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleStyle replaces only the selected part of an old value`() {
|
||||
val base = RichTextContent("abcdef", spans = listOf(RichSpan(0, 6, RichStyle.FontColor(1))))
|
||||
val result = RichTextEditing.toggleStyle(base, 2, 4, RichStyle.FontColor(2))
|
||||
assertEquals(
|
||||
listOf(
|
||||
RichSpan(0, 2, RichStyle.FontColor(1)),
|
||||
RichSpan(2, 4, RichStyle.FontColor(2)),
|
||||
RichSpan(4, 6, RichStyle.FontColor(1)),
|
||||
),
|
||||
result.spans,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `distinct style kinds toggle independently`() {
|
||||
val base = RichTextContent("hello")
|
||||
val styled = RichTextEditing.toggleStyle(
|
||||
RichTextEditing.toggleStyle(base, 0, 5, RichStyle.Bold),
|
||||
0,
|
||||
5,
|
||||
RichStyle.Strikethrough,
|
||||
)
|
||||
assertEquals(
|
||||
setOf(RichSpan(0, 5, RichStyle.Bold), RichSpan(0, 5, RichStyle.Strikethrough)),
|
||||
styled.spans.toSet(),
|
||||
)
|
||||
assertTrue(RichTextEditing.isStyled(styled, 0, 5, RichStyle.Strikethrough))
|
||||
assertTrue(RichTextEditing.isStyled(styled, 0, 5, RichStyle.Bold))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `styleAt reports the selection's single value or null when mixed`() {
|
||||
val content = RichTextContent(
|
||||
"abcd",
|
||||
spans = listOf(RichSpan(0, 2, RichStyle.FontSize(10)), RichSpan(2, 4, RichStyle.FontSize(14))),
|
||||
)
|
||||
assertEquals(RichStyle.FontSize(10), RichTextEditing.styleAt<RichStyle.FontSize>(content, 0, 2))
|
||||
assertNull(RichTextEditing.styleAt<RichStyle.FontSize>(content, 0, 4))
|
||||
assertEquals(RichStyle.FontSize(14), RichTextEditing.styleAt<RichStyle.FontSize>(content, 3, 3))
|
||||
assertNull(RichTextEditing.styleAt<RichStyle.FontColor>(content, 0, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyLink links the selection and removes overlapping links`() {
|
||||
val base = RichTextContent("see here")
|
||||
@@ -40,10 +94,10 @@ class RichTextEditingTest {
|
||||
|
||||
@Test
|
||||
fun `toggleBlock adds a bullet marker to the caret's line and shifts spans`() {
|
||||
val base = RichTextContent("ab", spans = listOf(RichSpan(0, 2, RichStyle.BOLD)))
|
||||
val base = RichTextContent("ab", spans = listOf(RichSpan(0, 2, RichStyle.Bold)))
|
||||
val result = RichTextEditing.toggleBlock(base, 0, 0, BlockMarker.BULLET)
|
||||
assertEquals("• ab", result.content.text)
|
||||
assertEquals(listOf(RichSpan(2, 4, RichStyle.BOLD)), result.content.spans)
|
||||
assertEquals(listOf(RichSpan(2, 4, RichStyle.Bold)), result.content.spans)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -67,4 +121,19 @@ class RichTextEditingTest {
|
||||
val result = RichTextEditing.toggleBlock(base, 0, 3, BlockMarker.QUOTE)
|
||||
assertEquals("> a", result.content.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleBlock keeps and remaps the alignment image and base-style channels`() {
|
||||
val base = RichTextContent(
|
||||
text = "hi [image: x]",
|
||||
alignments = listOf(RichAlignment(0, 13, RichAlign.CENTER)),
|
||||
images = listOf(RichImage(3, 13, "c1", "x")),
|
||||
baseStyle = RichBaseStyle(fontSizePt = 12),
|
||||
)
|
||||
val result = RichTextEditing.toggleBlock(base, 0, 0, BlockMarker.BULLET)
|
||||
assertEquals("• hi [image: x]", result.content.text)
|
||||
assertEquals(listOf(RichAlignment(2, 15, RichAlign.CENTER)), result.content.alignments)
|
||||
assertEquals(listOf(RichImage(5, 15, "c1", "x")), result.content.images)
|
||||
assertEquals(base.baseStyle, result.content.baseStyle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ class RichTextHtmlTest {
|
||||
val content = RichTextContent(
|
||||
text = "bold italic under",
|
||||
spans = listOf(
|
||||
RichSpan(0, 4, RichStyle.BOLD),
|
||||
RichSpan(5, 11, RichStyle.ITALIC),
|
||||
RichSpan(12, 17, RichStyle.UNDERLINE),
|
||||
RichSpan(0, 4, RichStyle.Bold),
|
||||
RichSpan(5, 11, RichStyle.Italic),
|
||||
RichSpan(12, 17, RichStyle.Underline),
|
||||
),
|
||||
)
|
||||
assertEquals("<p><b>bold</b> <i>italic</i> <u>under</u></p>", RichTextHtml.toHtml(content))
|
||||
@@ -31,7 +31,7 @@ class RichTextHtmlTest {
|
||||
fun `overlapping styles stay valid html`() {
|
||||
val content = RichTextContent(
|
||||
text = "abcd",
|
||||
spans = listOf(RichSpan(0, 3, RichStyle.BOLD), RichSpan(1, 4, RichStyle.ITALIC)),
|
||||
spans = listOf(RichSpan(0, 3, RichStyle.Bold), RichSpan(1, 4, RichStyle.Italic)),
|
||||
)
|
||||
// b over [0,3), i over [1,4): every run fully closes its tags, so nesting is always valid.
|
||||
assertEquals("<p><b>a</b><b><i>bc</i></b><i>d</i></p>", RichTextHtml.toHtml(content))
|
||||
@@ -61,14 +61,22 @@ class RichTextHtmlTest {
|
||||
fun `hasFormatting is false for unstyled markerless text`() {
|
||||
assertFalse(RichTextContent("just words\nmore words").hasFormatting())
|
||||
assertTrue(RichTextContent("• bullet").hasFormatting())
|
||||
assertTrue(RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.BOLD))).hasFormatting())
|
||||
assertTrue(RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.Bold))).hasFormatting())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasFormatting covers the alignment image and base style channels`() {
|
||||
assertTrue(RichTextContent("x", alignments = listOf(RichAlignment(0, 1, RichAlign.CENTER))).hasFormatting())
|
||||
assertTrue(RichTextContent("[image: a]", images = listOf(RichImage(0, 10, "cid1", "a"))).hasFormatting())
|
||||
assertTrue(RichTextContent("x", baseStyle = RichBaseStyle()).hasFormatting())
|
||||
assertFalse(RichTextContent("x").hasFormatting())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromHtml round-trips paragraphs styles lists quotes and links`() {
|
||||
listOf(
|
||||
RichTextContent("Hello\nWorld"),
|
||||
RichTextContent("bold", spans = listOf(RichSpan(0, 4, RichStyle.BOLD))),
|
||||
RichTextContent("bold", spans = listOf(RichSpan(0, 4, RichStyle.Bold))),
|
||||
RichTextContent("• Milk\n• Eggs"),
|
||||
RichTextContent("1. One\n2. Two"),
|
||||
RichTextContent("> a\n> b"),
|
||||
@@ -95,11 +103,187 @@ class RichTextHtmlTest {
|
||||
val restored = RichTextHtml.fromHtml("<ul>\n <li>One</li>\n <li><strong>Two</strong></li>\n</ul>")
|
||||
assertEquals("• One\n• Two", restored.text)
|
||||
// "Two" occupies [8,11) of "• One\n• Two".
|
||||
assertEquals(listOf(RichSpan(8, 11, RichStyle.BOLD)), restored.spans)
|
||||
assertEquals(listOf(RichSpan(8, 11, RichStyle.Bold)), restored.spans)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty content produces empty html`() {
|
||||
assertEquals("", RichTextHtml.toHtml(RichTextContent("")))
|
||||
}
|
||||
|
||||
// --- parameterized styles, alignment, images, base style (foundation for the formatting epic) ---
|
||||
|
||||
@Test
|
||||
fun `strikethrough renders as s and parses del and strike too`() {
|
||||
val content = RichTextContent("gone", spans = listOf(RichSpan(0, 4, RichStyle.Strikethrough)))
|
||||
assertEquals("<p><s>gone</s></p>", RichTextHtml.toHtml(content))
|
||||
listOf("<p><s>gone</s></p>", "<p><del>gone</del></p>", "<p><strike>gone</strike></p>").forEach { html ->
|
||||
assertEquals(content, RichTextHtml.fromHtml(html), html)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parameterized styles on one run merge into a single span tag`() {
|
||||
val content = RichTextContent(
|
||||
text = "ab",
|
||||
spans = listOf(
|
||||
RichSpan(0, 2, RichStyle.FontSize(14)),
|
||||
RichSpan(0, 2, RichStyle.FontColor(0xFFFF0000.toInt())),
|
||||
RichSpan(0, 2, RichStyle.Highlight(0xFFFFFF00.toInt())),
|
||||
RichSpan(0, 2, RichStyle.FontFamily("Georgia, serif")),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"<p><span style=\"font-family:Georgia, serif;font-size:14pt;color:#ff0000;" +
|
||||
"background-color:#ffff00\">ab</span></p>",
|
||||
RichTextHtml.toHtml(content),
|
||||
)
|
||||
assertRoundTrips(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every new style and channel round-trips and keeps hasFormatting true`() {
|
||||
listOf(
|
||||
RichTextContent("struck", spans = listOf(RichSpan(0, 6, RichStyle.Strikethrough))),
|
||||
RichTextContent("serif", spans = listOf(RichSpan(0, 5, RichStyle.FontFamily("Georgia, serif")))),
|
||||
RichTextContent("sized", spans = listOf(RichSpan(0, 5, RichStyle.FontSize(18)))),
|
||||
RichTextContent("red", spans = listOf(RichSpan(0, 3, RichStyle.FontColor(0xFFCC0000.toInt())))),
|
||||
RichTextContent("hi", spans = listOf(RichSpan(0, 2, RichStyle.Highlight(0xFFFFFF00.toInt())))),
|
||||
RichTextContent(
|
||||
text = "mixed run",
|
||||
spans = listOf(
|
||||
RichSpan(0, 5, RichStyle.Bold),
|
||||
RichSpan(2, 9, RichStyle.FontSize(12)),
|
||||
RichSpan(2, 5, RichStyle.FontColor(0xFF336699.toInt())),
|
||||
),
|
||||
),
|
||||
RichTextContent("left\ncentered", alignments = listOf(RichAlignment(5, 13, RichAlign.CENTER))),
|
||||
RichTextContent("a\nb\nc", alignments = listOf(RichAlignment(0, 3, RichAlign.END))),
|
||||
RichTextContent("• Milk\n• Eggs", alignments = listOf(RichAlignment(0, 13, RichAlign.CENTER))),
|
||||
RichTextContent(
|
||||
text = "see [image: cat.png] here",
|
||||
images = listOf(RichImage(4, 20, "img1@libremail", "cat.png")),
|
||||
),
|
||||
RichTextContent("plain", baseStyle = RichBaseStyle(fontCss = "Arial, sans-serif", fontSizePt = 12)),
|
||||
RichTextContent("plain", baseStyle = RichBaseStyle(fontSizePt = 11)),
|
||||
RichTextContent(
|
||||
text = "• item\nnote [image: dog.png]",
|
||||
spans = listOf(RichSpan(2, 6, RichStyle.Bold), RichSpan(7, 11, RichStyle.FontSize(10))),
|
||||
alignments = listOf(RichAlignment(0, 6, RichAlign.CENTER)),
|
||||
images = listOf(RichImage(12, 28, "dog@mail", "dog.png")),
|
||||
baseStyle = RichBaseStyle("Georgia, serif", 12),
|
||||
),
|
||||
).forEach(::assertRoundTrips)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alignment splits merged paragraphs and marks list items`() {
|
||||
val paragraphs = RichTextContent("a\nb", alignments = listOf(RichAlignment(2, 3, RichAlign.CENTER)))
|
||||
assertEquals("<p>a</p><p style=\"text-align:center\">b</p>", RichTextHtml.toHtml(paragraphs))
|
||||
val list = RichTextContent("• Milk\n• Eggs", alignments = listOf(RichAlignment(0, 13, RichAlign.CENTER)))
|
||||
assertEquals(
|
||||
"<ul><li style=\"text-align:center\">Milk</li><li style=\"text-align:center\">Eggs</li></ul>",
|
||||
RichTextHtml.toHtml(list),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank line between differently aligned paragraphs survives round-trip`() {
|
||||
val content = RichTextContent(
|
||||
text = "a\n\nb",
|
||||
alignments = listOf(RichAlignment(0, 1, RichAlign.CENTER), RichAlignment(3, 4, RichAlign.CENTER)),
|
||||
)
|
||||
// The empty middle group must emit an explicit <br> or the blank line vanishes on parse.
|
||||
assertEquals(
|
||||
"<p style=\"text-align:center\">a</p><p><br></p><p style=\"text-align:center\">b</p>",
|
||||
RichTextHtml.toHtml(content),
|
||||
)
|
||||
assertRoundTrips(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser accepts start and end alignment synonyms`() {
|
||||
val restored = RichTextHtml.fromHtml("<p style=\"text-align:start\">a</p><p style=\"text-align:end\">b</p>")
|
||||
assertEquals("a\nb", restored.text)
|
||||
assertEquals(
|
||||
listOf(RichAlignment(0, 1, RichAlign.START), RichAlignment(2, 3, RichAlign.END)),
|
||||
restored.alignments,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `images replace their token and keep surrounding style wrappers`() {
|
||||
val content = RichTextContent(
|
||||
text = "[image: pic]",
|
||||
spans = listOf(RichSpan(0, 12, RichStyle.Bold)),
|
||||
links = listOf(RichLink(0, 12, "http://x")),
|
||||
images = listOf(RichImage(0, 12, "c1", "pic")),
|
||||
)
|
||||
assertEquals(
|
||||
"<p><a href=\"http://x\"><b><img src=\"cid:c1\" alt=\"pic\"></b></a></p>",
|
||||
RichTextHtml.toHtml(content),
|
||||
)
|
||||
assertRoundTrips(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser converts px sizes and short hex colors`() {
|
||||
val restored = RichTextHtml.fromHtml("<p><span style=\"font-size:16px;color:#f00\">x</span></p>")
|
||||
assertEquals(
|
||||
setOf(
|
||||
RichSpan(0, 1, RichStyle.FontSize(12)),
|
||||
RichSpan(0, 1, RichStyle.FontColor(0xFFFF0000.toInt())),
|
||||
),
|
||||
restored.spans.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown css properties are ignored without dropping the run`() {
|
||||
val restored = RichTextHtml.fromHtml("<p><span style=\"mso-spacerun:yes;letter-spacing:2px\">kept</span></p>")
|
||||
assertEquals("kept", restored.text)
|
||||
assertTrue(restored.spans.isEmpty(), restored.spans.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `font families with quotes round-trip through attribute escaping`() {
|
||||
val family = RichStyle.FontFamily("\"Open Sans\", sans-serif")
|
||||
val content = RichTextContent("x", spans = listOf(RichSpan(0, 1, family)))
|
||||
val html = RichTextHtml.toHtml(content)
|
||||
assertTrue(html.contains(""Open Sans""), html)
|
||||
assertRoundTrips(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base style wrapper adds no stray text or newlines`() {
|
||||
val restored = RichTextHtml.fromHtml("<div style=\"font-size:12pt\"><p>a<br>b</p></div>")
|
||||
assertEquals("a\nb", restored.text)
|
||||
assertEquals(RichBaseStyle(fontSizePt = 12), restored.baseStyle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base style survives empty text`() {
|
||||
val content = RichTextContent("", baseStyle = RichBaseStyle(fontCss = "Georgia, serif"))
|
||||
val html = RichTextHtml.toHtml(content)
|
||||
assertEquals("<div style=\"font-family:Georgia, serif\"></div>", html)
|
||||
assertEquals(content, RichTextHtml.fromHtml(html))
|
||||
}
|
||||
|
||||
/**
|
||||
* The strict guarantee every channel needs: serialize, parse back, get the same model, and stay
|
||||
* "formatted" — ComposeViewModel.normalizedHtml() silently drops any HTML that parses back as
|
||||
* unformatted, so a violation here means user formatting is destroyed on From-account switches.
|
||||
*/
|
||||
private fun assertRoundTrips(original: RichTextContent) {
|
||||
assertTrue(original.hasFormatting(), "hasFormatting must be true for: $original")
|
||||
val html = RichTextHtml.toHtml(original)
|
||||
val restored = RichTextHtml.fromHtml(html)
|
||||
assertEquals(original.text, restored.text, "text of: $html")
|
||||
assertEquals(original.spans.toSet(), restored.spans.toSet(), "spans of: $html")
|
||||
assertEquals(original.links.toSet(), restored.links.toSet(), "links of: $html")
|
||||
assertEquals(original.alignments, restored.alignments, "alignments of: $html")
|
||||
assertEquals(original.images, restored.images, "images of: $html")
|
||||
assertEquals(original.baseStyle, restored.baseStyle, "baseStyle of: $html")
|
||||
assertTrue(restored.hasFormatting(), "formatting must survive reparsing: $html")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.OutgoingAttachment
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.domain.model.Signature
|
||||
@@ -31,6 +32,7 @@ import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -153,6 +155,27 @@ class ComposeViewModelTest {
|
||||
assertEquals("<p>Draft <b>body</b></p>", vm.state.value.bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resuming a draft restores the bcc recipients`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.getDraft("d1") } returns Draft(
|
||||
id = "d1",
|
||||
accountId = "imap:a",
|
||||
to = "x@example.org",
|
||||
cc = "",
|
||||
bcc = "hidden@example.org",
|
||||
subject = "Hi",
|
||||
body = "Draft body",
|
||||
updatedAt = 0L,
|
||||
)
|
||||
val vm = viewModel(
|
||||
savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")),
|
||||
mailRepository = mailRepository,
|
||||
)
|
||||
|
||||
assertEquals("hidden@example.org", vm.state.value.bcc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send carries the HTML body through to the outgoing message`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
@@ -211,4 +234,119 @@ class ComposeViewModelTest {
|
||||
coVerify { mailRepository.sendMessage(capture(sent)) }
|
||||
assertEquals("secret@example.org", sent.captured.bcc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asks about attachments when the body mentions one but none is attached`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("I attached the report.", null)
|
||||
vm.send()
|
||||
|
||||
assertTrue(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asks about attachments when only the subject mentions one`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onSubjectChange("Contract attachment")
|
||||
vm.send()
|
||||
|
||||
assertTrue(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches attachment variants but not lookalike words`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
|
||||
listOf("Attached is the file", "attaching it now", "see the ATTACHMENTS", "can you attach it").forEach {
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange(it, null)
|
||||
vm.send()
|
||||
assertTrue(vm.state.value.showAttachmentPrompt, "should prompt for: $it")
|
||||
}
|
||||
|
||||
listOf("planning an attack", "the base is attachable", "no keyword here").forEach {
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange(it, null)
|
||||
vm.send()
|
||||
assertFalse(vm.state.value.showAttachmentPrompt, "should not prompt for: $it")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sends without asking when an attachment is present`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("The report is attached.", null)
|
||||
vm.addAttachments(listOf(OutgoingAttachment("content://docs/report.pdf", "report.pdf")))
|
||||
vm.send()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 1) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendAnyway sends the message the prompt held back`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { mailRepository.sendMessage(any()) } returns Result.success(Unit)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("I attached the report.", null)
|
||||
vm.send()
|
||||
assertTrue(vm.state.value.showAttachmentPrompt)
|
||||
|
||||
vm.sendAnyway()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
coVerify(exactly = 1) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachInstead returns to composing and highlights the attach button`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("Attachment coming.", null)
|
||||
vm.send()
|
||||
|
||||
vm.attachInstead()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
assertTrue(vm.state.value.highlightAttach)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
|
||||
vm.consumeAttachHighlight()
|
||||
assertFalse(vm.state.value.highlightAttach)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissing the prompt cancels the send without highlighting`() = runTest(testDispatcher) {
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
val vm = viewModel(mailRepository = mailRepository)
|
||||
|
||||
vm.onToChange("bob@example.org")
|
||||
vm.onBodyChange("See attached.", null)
|
||||
vm.send()
|
||||
|
||||
vm.dismissAttachmentPrompt()
|
||||
|
||||
assertFalse(vm.state.value.showAttachmentPrompt)
|
||||
assertFalse(vm.state.value.highlightAttach)
|
||||
coVerify(exactly = 0) { mailRepository.sendMessage(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# F-Droid compliance
|
||||
|
||||
Audit for issue #16, performed 2026-07-01 against `main` (versionName 0.1.0 /
|
||||
versionCode 1). **Verdict: LibreMail meets F-Droid's inclusion criteria with no
|
||||
anti-features to declare.** Every runtime dependency is FOSS-licensed and
|
||||
GPL-3.0-or-later-compatible, there are no Google Play Services / Firebase /
|
||||
proprietary artifacts, no non-free Gradle plugins, and a clean-room build (no
|
||||
`secrets.properties`, no proprietary keys) produces an installable APK.
|
||||
|
||||
Companion deliverables:
|
||||
|
||||
- `fastlane/metadata/android/en-US/` — the store listing F-Droid reads from this repo.
|
||||
- `docs/fdroid/org.libremail.app.yml` — template + instructions for the build recipe
|
||||
that goes into [fdroiddata](https://gitlab.com/fdroid/fdroiddata).
|
||||
|
||||
## 1. Dependency license audit
|
||||
|
||||
### 1.1 Runtime dependencies (what ships in the APK)
|
||||
|
||||
Enumerated with `./gradlew :app:dependencies --configuration releaseRuntimeClasspath`.
|
||||
Direct dependencies, with the resolved versions at audit time:
|
||||
|
||||
| Dependency | Version | License | Role |
|
||||
|---|---|---|---|
|
||||
| `androidx.core:core-ktx` | 1.17.0 | Apache-2.0 | AndroidX core |
|
||||
| `androidx.lifecycle:lifecycle-runtime-ktx` / `-runtime-compose` / `-viewmodel-compose` | 2.9.4 | Apache-2.0 | Lifecycle/MVVM |
|
||||
| `androidx.activity:activity-compose` | 1.12.4 | Apache-2.0 | Compose host activity |
|
||||
| `androidx.navigation:navigation-compose` | 2.9.8 | Apache-2.0 | Navigation |
|
||||
| `androidx.compose.*` (BOM 2026.06.00: ui, ui-graphics, material3, material-icons-core, …) | 1.11.3 / m3 1.4.0 | Apache-2.0 | UI toolkit |
|
||||
| `androidx.webkit:webkit` | 1.12.1 | Apache-2.0 | Hardened WebView compat |
|
||||
| `androidx.work:work-runtime-ktx` | 2.11.2 | Apache-2.0 | Background sync/outbox |
|
||||
| `androidx.datastore:datastore-preferences` | 1.2.1 | Apache-2.0 | Settings store |
|
||||
| `androidx.room:room-runtime` / `room-ktx` | 2.8.4 | Apache-2.0 | Local mail cache |
|
||||
| `androidx.hilt:hilt-navigation-compose` / `hilt-work` | 1.3.0 | Apache-2.0 | Hilt integrations |
|
||||
| `org.jetbrains.kotlin:kotlin-stdlib` | 2.4.0 | Apache-2.0 | Kotlin runtime |
|
||||
| `org.jetbrains.kotlinx:kotlinx-coroutines-android` | 1.10.2 | Apache-2.0 | Coroutines |
|
||||
| `com.google.dagger:hilt-android` (Dagger/Hilt) | 2.60 | Apache-2.0 | Dependency injection |
|
||||
| `org.eclipse.angus:angus-mail` (+ `angus-activation`) | 2.0.5 / 2.0.3 | EPL-2.0 OR GPL-2.0 w/ Classpath-exception OR EDL-1.0 (BSD-3-Clause) | IMAP/SMTP transport |
|
||||
| `net.openid:appauth` | 0.11.1 | Apache-2.0 | OAuth 2.0 + PKCE (Outlook) |
|
||||
| `net.zetetic:sqlcipher-android` | 4.16.0 | BSD-3-Clause-style (SQLCipher Community Edition) | Opt-in cache encryption |
|
||||
|
||||
Transitive dependencies, grouped (full tree available from the Gradle command above):
|
||||
|
||||
| Group | License | Notes |
|
||||
|---|---|---|
|
||||
| `androidx.*` (~60 artifacts: appcompat, browser, collection, emoji2, fragment, savedstate, sqlite, startup, tracing, window, …) | Apache-2.0 | AndroidX |
|
||||
| `org.jetbrains.*` (kotlin-stdlib, kotlinx-coroutines, kotlinx-serialization, annotations) | Apache-2.0 | JetBrains |
|
||||
| `com.google.dagger:*` (dagger, hilt-core, dagger-lint-aar) | Apache-2.0 | via Hilt |
|
||||
| `com.google.code.findbugs:jsr305` 3.0.2 | Apache-2.0 | annotations only |
|
||||
| `com.google.guava:listenablefuture` 1.0 | Apache-2.0 | empty stub artifact (not Guava) |
|
||||
| `com.squareup.okio:okio` 3.9.1 | Apache-2.0 | via DataStore |
|
||||
| `jakarta.mail:jakarta.mail-api` 2.1.5 | EPL-2.0 OR GPL-2.0 w/ CPE OR EDL-1.0 | via Angus Mail |
|
||||
| `jakarta.activation:jakarta.activation-api` 2.1.4 | EDL-1.0 (BSD-3-Clause) | via Angus Mail |
|
||||
| `jakarta.inject:jakarta.inject-api` 2.0.1, `javax.inject:javax.inject` 1 | Apache-2.0 | DI annotations |
|
||||
| `org.jspecify:jspecify` 1.0.0 | Apache-2.0 | nullness annotations |
|
||||
|
||||
**GPL compatibility.** Everything is Apache-2.0 or BSD-3-Clause except the
|
||||
Jakarta/Angus mail stack, which is tri-licensed; LibreMail uses it under the
|
||||
EDL-1.0 (BSD-3-Clause) / GPL-2.0-with-Classpath-exception options, both of which are
|
||||
GPL-3.0-or-later-compatible. **No proprietary, source-unavailable, or
|
||||
"free for open source use only" artifact appears anywhere in the tree.** In
|
||||
particular there is **no** `com.google.android.gms:*` (Play Services), **no**
|
||||
`com.google.firebase:*`, no Play Billing/Install Referrer, and no analytics or
|
||||
crash-reporting SDK.
|
||||
|
||||
Native libraries in the release APK — all from the audited dependencies above:
|
||||
`libsqlcipher.so` (SQLCipher), `libdatastore_shared_counter.so` (AndroidX DataStore),
|
||||
`libandroidx.graphics.path.so` (AndroidX, via Compose).
|
||||
|
||||
### 1.2 Build-time dependencies (never ship in the APK)
|
||||
|
||||
Enumerated with `./gradlew buildEnvironment :app:buildEnvironment`:
|
||||
|
||||
| Plugin / tool | License |
|
||||
|---|---|
|
||||
| Android Gradle Plugin 9.2.x (`com.android.tools.*`) | Apache-2.0 |
|
||||
| Kotlin Gradle plugin + Compose compiler 2.4.0 | Apache-2.0 |
|
||||
| KSP 2.3.9 | Apache-2.0 |
|
||||
| Hilt Gradle plugin 2.60 | Apache-2.0 |
|
||||
| ktlint-gradle 14.2.0 (`org.jlleitschuh.gradle`) | MIT |
|
||||
| detekt 2.0.0-alpha.5 (`dev.detekt`) | Apache-2.0 |
|
||||
|
||||
Their transitive tooling deps (protobuf, Tink, flatbuffers, bouncycastle,
|
||||
juniversalchardet, jose4j, …) are Apache-2.0/MIT/MPL — all FOSS. **No non-free
|
||||
Gradle plugin is used** (no Play Publisher, no Crashlytics/Google Services plugin,
|
||||
no proprietary obfuscator; R8 ships with AGP and is Apache-2.0).
|
||||
|
||||
Artifacts resolve exclusively from open repositories: `google()` and
|
||||
`mavenCentral()` (plus `gradlePluginPortal()` for the lint/format plugins).
|
||||
`gradle-wrapper.jar` is the standard Gradle 9.6 wrapper (Apache-2.0), verifiable
|
||||
against the official distribution.
|
||||
|
||||
### 1.3 APK payload hygiene
|
||||
|
||||
AGP by default embeds a *dependency info block* in the APK signing block: a list of
|
||||
every dependency **encrypted with a Google Play public key**, readable only by
|
||||
Google. That opaque blob is a known F-Droid blocker (it cannot be verified from
|
||||
source and breaks reproducible-build verification), so this repo disables it in
|
||||
`app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Anti-feature review
|
||||
|
||||
Reviewed against the [F-Droid anti-feature list](https://f-droid.org/docs/Anti-Features/),
|
||||
based on the manifest and source at audit time. **Declared anti-features: none.**
|
||||
|
||||
| Anti-feature | Verdict | Reasoning |
|
||||
|---|---|---|
|
||||
| `Ads` | Clear | No advertising of any kind. |
|
||||
| `Tracking` | Clear | No analytics/telemetry SDK; no identifiers are collected. Debug reporting is off by default, local-only, user-reviewed, and user-submitted (§2.1). |
|
||||
| `NonFreeNet` | Clear | Generic IMAP/SMTP client, fully functional against free-software mail servers; the Microsoft integration is optional and user-chosen (§2.2). |
|
||||
| `NonFreeAdd` | Clear | No add-ons; nothing is upsold. |
|
||||
| `NonFreeDep` | Clear | Dependency audit in §1: every dependency is FOSS. |
|
||||
| `NonFreeAssets` | Clear | All assets are first-party vector drawables carrying the repo's GPL SPDX headers; no bundled proprietary art, fonts, or blobs. |
|
||||
| `NSFW` | Clear | N/A. |
|
||||
| `UpstreamNonFree` | Clear | This repo is the upstream and is wholly GPL-3.0-or-later. |
|
||||
| `KnownVuln` | Clear | No dependency with a known security vulnerability is pinned at audit time (all on current stable lines). |
|
||||
| `ApplicationDebuggable` | Clear | Release builds are non-debuggable (AGP default) and R8-minified. |
|
||||
| `TetheredNet` | Clear | No tethered/proprietary backend; the app talks to the user's own mail servers. |
|
||||
| `NoSourceSince` | Clear | N/A — source is published. |
|
||||
|
||||
### 2.1 Debug reporting is not `Tracking`
|
||||
|
||||
The crash/debug-report pipeline (`org.libremail.reporting.*`) is designed to stay on
|
||||
the right side of F-Droid's Tracking definition ("reports user activity ... without
|
||||
consent"):
|
||||
|
||||
- **Off by default.** Nothing is captured until the user enables debug reporting.
|
||||
- **Local capture only.** Reports (app/OS version, device model, stack trace, a
|
||||
non-PII settings summary, recent in-app log lines) are stored on-device.
|
||||
`DiagnosticsCollector` deliberately collects no account emails, server names,
|
||||
message content, or hardware/advertising identifiers.
|
||||
- **User-initiated, reviewed submission.** A report leaves the device only when the
|
||||
user opens it, sees the full contents plus a PII disclaimer, and taps Submit
|
||||
(`ReportSubmitter` is the single egress seam).
|
||||
- **No endpoint in F-Droid builds.** The ingest URL comes from
|
||||
`BuildConfig.DEBUG_REPORT_ENDPOINT`, default **empty** (settable only via the
|
||||
git-ignored `secrets.properties`). An F-Droid build therefore *cannot* transmit a
|
||||
report anywhere; the UI steers users to copy/save the report instead.
|
||||
|
||||
### 2.2 Outlook OAuth / Microsoft Graph is not `NonFreeNet`
|
||||
|
||||
`NonFreeNet` applies to apps that *promote or depend entirely on* a non-free network
|
||||
service. LibreMail is a general-purpose email client: it works fully against any
|
||||
IMAP/SMTP server, including self-hosted free-software stacks (Dovecot/Postfix, …),
|
||||
and no Microsoft endpoint is ever contacted unless the user adds an
|
||||
Outlook/Microsoft account. For transparency:
|
||||
|
||||
- Adding an Outlook account uses OAuth 2.0 + PKCE against
|
||||
`login.microsoftonline.com` and sends via Microsoft Graph
|
||||
(`graph.microsoft.com`), with SMTP/XOAUTH2 fallback — proprietary services, but
|
||||
the *user's own mailbox provider*, exactly like connecting to any other mail host.
|
||||
- The build bundles a default Azure **public client id** (`OUTLOOK_OAUTH_CLIENT_ID`
|
||||
in `app/build.gradle.kts`). A public-client id is an identifier, not a secret or a
|
||||
key, and is overridable via `secrets.properties`. This mirrors what established
|
||||
F-Droid mail clients (K-9 Mail / Thunderbird) ship for Gmail/Outlook OAuth without
|
||||
a `NonFreeNet` flag.
|
||||
- Gmail/Yahoo/iCloud accounts use plain app-password IMAP/SMTP — no proprietary
|
||||
SDK. Onboarding links to each vendor's app-password page open in the system
|
||||
browser only on an explicit tap.
|
||||
|
||||
Should F-Droid reviewers read the built-in Outlook convenience differently,
|
||||
declaring `NonFreeNet` on the fdroiddata side is the documented fallback; nothing
|
||||
in the app needs to change.
|
||||
|
||||
### 2.3 Android Backup (Google transport) is opt-in
|
||||
|
||||
`android:allowBackup="true"` is required at the manifest level, but
|
||||
`LibreMailBackupAgent` enforces the runtime preference: **backup is off by
|
||||
default**, and with it disabled the agent ships nothing. When the user opts in, the
|
||||
allowlist in `res/xml/data_extraction_rules.xml` / `backup_rules.xml` backs up
|
||||
*only* the settings DataStore — never credentials, the mail cache, or the
|
||||
Keystore-sealed cache passphrase. Because Android Auto Backup can route through
|
||||
Google's transport, the feature stays disabled unless explicitly chosen; F-Droid
|
||||
has no anti-feature for opt-in platform backup.
|
||||
|
||||
### 2.4 Complete network surface
|
||||
|
||||
| Destination | When | Consent |
|
||||
|---|---|---|
|
||||
| User-configured IMAP/SMTP servers | Mail sync/send | Inherent (user adds the account) |
|
||||
| `login.microsoftonline.com` | Outlook sign-in / token refresh | Only if an Outlook account is added |
|
||||
| `graph.microsoft.com` (`sendMail`) | Outlook send | Only if an Outlook account is added |
|
||||
| Vendor app-password help pages (Google/Yahoo/Apple) | Opened in the system browser | Explicit tap during setup |
|
||||
| Remote images in HTML mail | Blocked by default | Per-user opt-in (tracking-pixel protection) |
|
||||
| `DEBUG_REPORT_ENDPOINT` | Debug-report submission | Empty by default → impossible; otherwise explicit Submit tap |
|
||||
|
||||
No other endpoint exists in the code; there is no update checker, no push relay
|
||||
(new-mail notifications are generated on-device; instant push is a direct IMAP IDLE
|
||||
connection to the user's server), and no font/asset CDN.
|
||||
|
||||
## 3. Clean-room build verification
|
||||
|
||||
Verified 2026-07-01 on this branch, in a checkout containing **no
|
||||
`secrets.properties`** (and no other proprietary keys — the file is optional by
|
||||
design; `OUTLOOK_OAUTH_CLIENT_ID` has an in-tree default and
|
||||
`DEBUG_REPORT_ENDPOINT` defaults to empty):
|
||||
|
||||
```
|
||||
$ JAVA_HOME=<JDK 21> ./gradlew :app:assembleRelease
|
||||
BUILD SUCCESSFUL
|
||||
app/build/outputs/apk/release/app-release.apk (~12.6 MiB)
|
||||
```
|
||||
|
||||
The APK is installable: with no release keystore configured, release builds are
|
||||
signed with the debug key (see `signingConfigs` in `app/build.gradle.kts`) — fine
|
||||
for local testing and irrelevant to F-Droid, which builds from source and signs
|
||||
with its own key. APK contents were inspected: single `classes.dex`, resources,
|
||||
and the three native libraries listed in §1.1 — no bundled binaries of unknown
|
||||
origin. `./gradlew :app:assembleDebug`, the unit tests, static analysis
|
||||
(ktlint/detekt), and the emulator E2E suites run on every PR in CI, likewise
|
||||
without any secrets configured.
|
||||
|
||||
## 4. Publishing checklist (for the maintainer)
|
||||
|
||||
1. Tag releases `v<versionName>` **and bump `versionCode`** in
|
||||
`app/build.gradle.kts` in the same commit. (At audit time tags `v0.1.0` and
|
||||
`v0.2.0` both point at versionCode 1 / versionName 0.1.0 — F-Droid's
|
||||
`UpdateCheckMode: Tags` needs the code to increase per release tag.)
|
||||
2. Copy `docs/fdroid/org.libremail.app.yml` into a fork of fdroiddata as
|
||||
`metadata/org.libremail.app.yml`, run `fdroid lint org.libremail.app` and a test
|
||||
`fdroid build`, then open the merge request.
|
||||
3. The store listing (title/short/full description, per-release changelogs) is read
|
||||
from `fastlane/metadata/android/en-US/` in this repo — add a
|
||||
`changelogs/<versionCode>.txt` for each release, and optionally
|
||||
`images/phoneScreenshots/`.
|
||||
4. Keep this document current when dependencies or network behaviors change; if a
|
||||
future feature genuinely trips an anti-feature, declare it in the fdroiddata
|
||||
metadata rather than hiding it.
|
||||
@@ -0,0 +1,49 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# TEMPLATE for LibreMail's F-Droid build recipe ("app metadata").
|
||||
#
|
||||
# The real recipe does NOT live in this repository: F-Droid builds every app from a
|
||||
# metadata file kept in the fdroiddata repo (https://gitlab.com/fdroid/fdroiddata) at
|
||||
# metadata/org.libremail.app.yml. To submit LibreMail, fork fdroiddata, copy this file
|
||||
# there (dropping this comment block — `fdroid rewritemeta` strips comments anyway),
|
||||
# validate it, and open a merge request:
|
||||
#
|
||||
# fdroid readmeta # parse check
|
||||
# fdroid lint org.libremail.app # style/policy check
|
||||
# fdroid build -v -l org.libremail.app # test build (needs the Android SDK)
|
||||
#
|
||||
# Notes for the submitter:
|
||||
# - Summary/Description/changelogs are deliberately NOT set here: F-Droid pulls the
|
||||
# localized store listing from this repo's fastlane/metadata/android/ tree.
|
||||
# - Each release must be git-tagged (v<versionName>) AND bump versionCode in
|
||||
# app/build.gradle.kts; UpdateCheckMode: Tags matches tags against versionCode.
|
||||
# - The build needs no secrets.properties: the Outlook OAuth client id (a public,
|
||||
# non-secret GUID) has an in-tree default, and the debug-report endpoint defaults
|
||||
# to empty (reports then cannot be submitted anywhere). See docs/fdroid-compliance.md.
|
||||
# - JDK: 17-21 (AGP 9.x does not support JDK 25).
|
||||
|
||||
Categories:
|
||||
- Internet
|
||||
License: GPL-3.0-or-later
|
||||
AuthorName: Jason Ross
|
||||
SourceCode: https://github.com/JMR-dev/LibreMail
|
||||
IssueTracker: https://github.com/JMR-dev/LibreMail/issues
|
||||
Changelog: https://github.com/JMR-dev/LibreMail/releases
|
||||
|
||||
AutoName: LibreMail
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/JMR-dev/LibreMail.git
|
||||
|
||||
Builds:
|
||||
- versionName: 0.1.0
|
||||
versionCode: 1
|
||||
commit: v0.1.0
|
||||
subdir: app
|
||||
gradle:
|
||||
- yes
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags ^v[0-9]+\.[0-9]+\.[0-9]+$
|
||||
CurrentVersion: 0.1.0
|
||||
CurrentVersionCode: 1
|
||||
@@ -0,0 +1,152 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# Google Play technical compliance & console checklist (issue #17)
|
||||
|
||||
Verified 2026-07-01 against this repository (commit on `main` at time of writing). Companion
|
||||
docs: [`PRIVACY.md`](../PRIVACY.md), [`play-data-safety.md`](play-data-safety.md),
|
||||
[`play-permissions.md`](play-permissions.md).
|
||||
|
||||
## 1. Target API level — PASS
|
||||
|
||||
| Fact | Value | Source |
|
||||
|---|---|---|
|
||||
| `targetSdk` | **37** | `app/build.gradle.kts:52` |
|
||||
| `compileSdk` | 37 | `app/build.gradle.kts:46` |
|
||||
| `minSdk` | 29 (Android 10) | `app/build.gradle.kts:51` |
|
||||
| Play requirement (new apps & updates, phones/tablets) | target API **35** (Android 15)+ since 2025-08-31 | [Play target-API policy](https://support.google.com/googleplay/android-developer/answer/11926878) |
|
||||
|
||||
Target 37 exceeds the requirement with two versions of headroom; no action needed. When Google
|
||||
announces the 2026 deadline (expected: API 36 for the Aug 2026 window), 37 still passes.
|
||||
|
||||
## 2. 16 KB page-size support — PASS (verified empirically)
|
||||
|
||||
Play requires new apps and updates targeting Android 15+ to support 16 KB memory page sizes on
|
||||
64-bit devices since 2025-11-01 ([Android developers blog](https://android-developers.googleblog.com/2025/05/prepare-play-apps-for-devices-with-16kb-page-size.html)).
|
||||
Compliance = every `PT_LOAD` segment of every packaged 64-bit `.so` aligned to ≥ 0x4000 (16384).
|
||||
|
||||
The release AAB packages exactly three native libraries. All were extracted from
|
||||
`app-release.aab` and their ELF program headers checked (same check as AOSP's
|
||||
`check_elf_alignment.sh`); **every one reports `p_align = 0x4000` on every ABI**:
|
||||
|
||||
| Library | From dependency | arm64-v8a | x86_64 | armeabi-v7a / x86 (32-bit, not gated) |
|
||||
|---|---|---|---|---|
|
||||
| `libsqlcipher.so` | `net.zetetic:sqlcipher-android:4.16.0` | 0x4000 OK | 0x4000 OK | 0x4000 OK |
|
||||
| `libandroidx.graphics.path.so` | Compose (BOM `2026.06.00`) | 0x4000 OK | 0x4000 OK | 0x4000 OK |
|
||||
| `libdatastore_shared_counter.so` | `androidx.datastore:1.2.1` | 0x4000 OK | 0x4000 OK | 0x4000 OK |
|
||||
|
||||
SQLCipher — the dependency called out in issue #17 — has shipped 16 KB-aligned binaries since
|
||||
well before 4.16.0, and the pinned version is confirmed aligned above. AGP 9.2 also emits 16
|
||||
KB-zip-aligned uncompressed libraries by default (AGP ≥ 8.5.1 behavior), and Play regenerates
|
||||
delivery APKs from the AAB anyway. Re-verify after any bump of `sqlcipher`, `datastore`, or
|
||||
`composeBom` in `gradle/libs.versions.toml`: Play Console → **App bundle explorer** shows a
|
||||
16 KB compliance verdict per upload.
|
||||
|
||||
## 3. App Bundle (AAB) — PASS, signing is a human step
|
||||
|
||||
- `./gradlew :app:bundleRelease` succeeds and produces
|
||||
`app/build/outputs/bundle/release/app-release.aab` (~10.4 MB, R8-minified). Verified
|
||||
2026-07-01 with JDK 21.
|
||||
- **Signing:** without `secrets.properties` the release build intentionally falls back to the
|
||||
**debug** keystore (`app/build.gradle.kts:86` — installable locally, not publishable). For
|
||||
Play the maintainer must create an upload keystore, set `RELEASE_STORE_FILE` /
|
||||
`RELEASE_STORE_PASSWORD` / `RELEASE_KEY_ALIAS` / `RELEASE_KEY_PASSWORD` in
|
||||
`secrets.properties`, rebuild, and enroll in **Play App Signing** on first upload (Play holds
|
||||
the app signing key; the local key becomes the upload key).
|
||||
- `versionCode 1` / `versionName "0.1.0"` (`app/build.gradle.kts:53`) — bump per release.
|
||||
|
||||
## 4. OAuth / CASA — no Google verification applies
|
||||
|
||||
Verified in source, 2026-07-01:
|
||||
|
||||
- **Gmail onboarding uses an app password over IMAP/SMTP, not OAuth.** The Gmail preset
|
||||
(`domain/model/MailProvider.kt:37`) is plain `imap.gmail.com:993` / `smtp.gmail.com:587`
|
||||
authenticating with a user-created app password; the only Google URL in the app is the
|
||||
`myaccount.google.com/apppasswords` help link opened in the browser. There is **no Google
|
||||
OAuth client, no Google sign-in flow, and no Gmail API scope anywhere in the code** — so the
|
||||
Google restricted-scope verification and **CASA security assessment do not apply** to
|
||||
LibreMail. (This is deliberate — issue #9; do not "fix" Gmail back to OAuth.)
|
||||
- **Outlook OAuth is Microsoft-side only.** `auth/OutlookAuthManager.kt` uses AppAuth (PKCE,
|
||||
public client) against `login.microsoftonline.com` with Microsoft Graph
|
||||
(`Mail.Send`) and Exchange Online (`IMAP.AccessAsUser.All`, `SMTP.Send`) scopes. Verification
|
||||
of that client is governed by **Microsoft's** app-registration/publisher rules in Azure —
|
||||
nothing on the Google side. Google Play itself imposes no OAuth review; only the data-safety
|
||||
and permissions declarations above cover it.
|
||||
- README follow-up: tracked as issue **#20** (a fuller README pass); `README.md`'s privacy
|
||||
section now links `PRIVACY.md`.
|
||||
|
||||
## 5. Console checklist (human steps, in order)
|
||||
|
||||
Everything below happens in Play Console and cannot be done from the repo. Drafted answers are
|
||||
ready to paste.
|
||||
|
||||
1. **Developer account** — one-time registration + identity verification.
|
||||
2. **Create app** — name *LibreMail*, default language, **App** (not game), **Free**.
|
||||
Free-to-paid can never be toggled later; LibreMail is GPL and free.
|
||||
3. **Store listing** (assets required):
|
||||
- App icon **512×512 PNG** (≤1 MB); feature graphic **1024×500**; **2–8 phone screenshots**
|
||||
(16:9 or 9:16, 320–3840 px; onboarding, inbox, reader, compose, settings are good
|
||||
candidates); optional 7"/10" tablet screenshots.
|
||||
- Short description (≤80 chars), draft:
|
||||
> Open-source email for Outlook, Gmail, Yahoo, iCloud and any IMAP provider.
|
||||
- Full description (≤4000 chars), draft:
|
||||
> LibreMail is a free and open-source (GPL-3.0) email client with a friendly Material You
|
||||
> design. Add Outlook/Hotmail (OAuth sign-in), Gmail, Yahoo, iCloud (app password), or any
|
||||
> IMAP/SMTP provider; read, search, and manage your mail offline-first; compose with rich
|
||||
> text, signatures, attachments, and contact autocomplete; get instant new-mail
|
||||
> notifications via IMAP IDLE push — no tracking, no ads, no analytics, and your mail
|
||||
> never touches our servers because we don't have any. Optional extras: encrypted local
|
||||
> cache (SQLCipher), unified inbox for multiple accounts, and full mail-history backfill
|
||||
> with a retention cap.
|
||||
- Category **Communication**; contact email (maintainer's); privacy policy URL
|
||||
`https://github.com/JMR-dev/LibreMail/blob/main/PRIVACY.md`.
|
||||
4. **App content declarations:**
|
||||
- **Privacy policy** — URL above.
|
||||
- **Ads** — *No, my app does not contain ads* (no ad SDK; see dependency audit in
|
||||
[`play-data-safety.md`](play-data-safety.md)).
|
||||
- **App access** — reviewers need a mail account to exercise the app. Provide either
|
||||
"All functionality is available without special access" plus a note that any IMAP account
|
||||
works, or (safer) supply a disposable test account (e.g. a throwaway IMAP mailbox) under
|
||||
*Special access instructions*. Do **not** hand over a personal account.
|
||||
- **Content rating (IARC questionnaire)** — draft answers: email/communication app; category
|
||||
**Utility / Communication**; violence/sex/language/drugs/gambling: **No** to all;
|
||||
user interaction: **Yes** (users exchange email — expect an "Interactive elements: Users
|
||||
Interact" notice); shares user-provided location: **No**; digital purchases: **No**.
|
||||
Expected rating: **Everyone / PEGI 3** with the Users-Interact disclosure.
|
||||
- **Target audience** — **13 and over** (requires an email account; not directed at
|
||||
children — do not select under-13, which triggers Families policy).
|
||||
- **News app** — No. **COVID-19 app** — No. **Government app** — No.
|
||||
- **Financial features** — None. **Health apps** — Not a health app.
|
||||
- **Data safety** — answers and evidence in [`play-data-safety.md`](play-data-safety.md).
|
||||
- **Foreground service permissions** (`FOREGROUND_SERVICE_DATA_SYNC`) — declaration text and
|
||||
demo-video script in [`play-permissions.md`](play-permissions.md).
|
||||
- **Account deletion** — Play's deletion-URL requirement applies to apps that let users
|
||||
*create an account with the developer*. LibreMail creates no such accounts (users connect
|
||||
their own third-party mailboxes), so answer the "App access/account creation" question
|
||||
with **no account creation** and the deletion section does not apply. In-app truth, if a
|
||||
free-text answer is wanted:
|
||||
> LibreMail has no user accounts of its own and stores data only on the device. Removing
|
||||
> an account inside the app deletes its saved credentials and its locally cached
|
||||
> messages, folders, and settings (`AccountRepositoryImpl.deleteAccount`); uninstalling
|
||||
> the app removes all app data. The user's mailbox at their email provider is unaffected.
|
||||
5. **Upload** the properly signed release AAB to **Internal testing** first; check **App bundle
|
||||
explorer** (16 KB verdict) and the **pre-launch report** (automated crawl on real devices;
|
||||
supply the test-account credentials so it can get past onboarding).
|
||||
6. **Countries/regions**, pricing (Free), then promote Internal → Closed/Open testing →
|
||||
Production. Note: new personal developer accounts must run a closed test (12 testers /
|
||||
14 days) before production access.
|
||||
|
||||
## 6. Findings for the maintainer (repo-side, discovered during verification)
|
||||
|
||||
1. **"Push mail" is on by default, not opt-in.** `SettingsRepository.kt:41` defaults
|
||||
`pushIdle = true`, so the dataSync foreground service starts as soon as the first account is
|
||||
added. The manifest comment (`AndroidManifest.xml:88` — "opt-in via Advanced Settings") and
|
||||
README wording say opt-in. Either flip the default to `false` or fix the comments; the Play
|
||||
FGS declaration drafted here describes the **actual** behavior (default-on, user-visible
|
||||
toggle, persistent notification), which is acceptable to declare but must stay truthful.
|
||||
2. **README tech-stack table says min SDK 33**; the build uses `minSdk 29`
|
||||
(`app/build.gradle.kts:51`). Fix with the #20 README pass.
|
||||
3. **README advertises an "app lock" (biometric/device-credential)** that does not exist in the
|
||||
code yet (no biometric API usage anywhere in `app/src/main`). `PRIVACY.md` deliberately does
|
||||
not claim it; remove or de-scope the README claim until implemented (#20), and update
|
||||
`PRIVACY.md` when it ships.
|
||||
4. **Release signing falls back to the debug key** without `secrets.properties` — fine for CI,
|
||||
but the Play upload must be built with the real upload keystore (section 3).
|
||||
@@ -0,0 +1,110 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# Google Play Data safety form — mapping (issue #17)
|
||||
|
||||
Fill-in guide for Play Console → **App content → Data safety**. Every answer below is grounded
|
||||
in this repository's code; re-verify against source if the data flows change. Companion docs:
|
||||
[`PRIVACY.md`](../PRIVACY.md) (the policy to link in the form),
|
||||
[`play-permissions.md`](play-permissions.md), [`play-compliance.md`](play-compliance.md).
|
||||
|
||||
## How Play defines "collection", and why LibreMail declares none
|
||||
|
||||
Play's definition ([Play Console Help — Provide information for Google Play's Data safety
|
||||
section](https://support.google.com/googleplay/android-developer/answer/10787469)): *"Collect"
|
||||
means transmitting data from your app off a user's device*, with exemptions that do **not** need
|
||||
to be disclosed:
|
||||
|
||||
1. **On-device access/processing** — data "only processed locally on the user's device and not
|
||||
sent off device".
|
||||
2. **End-to-end encryption** — data unreadable by anyone other than sender and recipient.
|
||||
3. **Ephemeral processing** — data held in memory and "retained for no longer than necessary to
|
||||
service a specific request in real time".
|
||||
|
||||
LibreMail's data flows fall under exemptions 1 and 3:
|
||||
|
||||
- The developer **operates no servers and receives no user data**. There is no analytics,
|
||||
crash-reporting, or ad SDK in the dependency tree (see audit below), and the only
|
||||
developer-directed channel that exists in code — opt-in debug-report upload
|
||||
(`app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt`) — is dead in shipped
|
||||
builds because `DEBUG_REPORT_ENDPOINT` defaults to `""` (`app/build.gradle.kts`), which makes
|
||||
the upload worker fail without transmitting.
|
||||
- All other traffic is the app doing its job as the user's mail agent against **servers the
|
||||
user chose** (their own IMAP/SMTP provider; Microsoft's OAuth/Graph endpoints for the Outlook
|
||||
account type). Each transfer services a specific user request in real time (sign-in, sync,
|
||||
send, server search); the app retains nothing off-device and the developer can never access
|
||||
any of it.
|
||||
|
||||
The same reasoning is the established practice of comparable open-source mail clients on Play
|
||||
that declare no collection. If a Play reviewer pushes back, use the conservative alternative at
|
||||
the bottom of this page — it is also truthful.
|
||||
|
||||
## Form answers
|
||||
|
||||
| Form question | Answer |
|
||||
|---|---|
|
||||
| Does your app collect or share any of the required user data types? | **No** |
|
||||
| Is all of the user data collected by your app encrypted in transit? | Not asked when "No" above; for the record: **yes**, all connections are TLS (`ImapClient.kt`, `SmtpSender.kt` set `ssl.checkserveridentity=true`; `MailSecurity.NONE` is not offered in the UI — `ManualSetupScreen.kt:211`) |
|
||||
| Do you provide a way for users to request that their data is deleted? | Not asked when "No" above; see account-deletion notes in [`play-compliance.md`](play-compliance.md) |
|
||||
| Privacy policy URL | `https://github.com/JMR-dev/LibreMail/blob/main/PRIVACY.md` |
|
||||
|
||||
Result shown on the store listing: **"No data collected"** / **"No data shared with third
|
||||
parties"**.
|
||||
|
||||
## Category-by-category evidence
|
||||
|
||||
Every Play data-safety category, the truthful answer, and where the code proves it:
|
||||
|
||||
| Play category | Collected? | Shared? | Evidence in code |
|
||||
|---|---|---|---|
|
||||
| Personal info → Name | No | No | Account display name stored in local Room DB only (`data/local/entity/AccountEntity` via `AccountRepositoryImpl.kt`); appears off-device only inside mail the user sends |
|
||||
| Personal info → Email address | No | No | The user's own address is their mail login, sent only to their chosen provider to authenticate/send (`ImapClient.kt`, `SmtpSender.kt`, `GraphSender.kt`) — user-initiated, real-time, never to the developer |
|
||||
| Personal info → User IDs | No | No | No developer-side accounts or IDs exist; OAuth tokens go only between the device and `login.microsoftonline.com` (`auth/OutlookAuthManager.kt`) |
|
||||
| Financial info / Health / Location | No | No | No such APIs or permissions anywhere in the merged manifest (see [`play-permissions.md`](play-permissions.md)) |
|
||||
| Messages → Emails | No | No | Mail syncs from the user's server *to* the device (`MailSyncer`), is cached locally (Room, optional SQLCipher — `di/DatabaseModule.kt`), and is transmitted only when the user sends a message to their own SMTP/Graph endpoint |
|
||||
| Photos and videos / Audio files / Files and docs | No | No | Attachments are chosen via the system document picker (`ComposeScreen.kt` `OpenMultipleDocuments`, no storage permission), stored under `cacheDir` (`MailRepositoryImpl.kt:361`), and leave the device only inside mail the user sends |
|
||||
| Calendar | No | No | No calendar API usage |
|
||||
| Contacts | **No** | No | `contacts/ContactsRepository.kt` queries `ContactsContract` **on-device** for ≤8 autocomplete matches; results are held in memory for the compose screen. Nothing is uploaded — Play's on-device exemption applies |
|
||||
| App activity (interactions, search history, installed apps) | No | No | No analytics SDK; server search sends the query string to the *user's own* IMAP server as an IMAP `SEARCH` command (user-initiated, ephemeral) |
|
||||
| Web browsing | No | No | The reader WebView has JavaScript disabled and network loads blocked unless the user enables remote images (`ui/reader/HtmlBody.kt:62,98`) — and even then requests go to hosts referenced by the email, not to the developer |
|
||||
| App info and performance (crash logs, diagnostics) | **No** | No | Crash/debug reports are written to local app storage only (`reporting/ReportStore.kt` → `filesDir/debug_reports`); upload requires an explicit user tap **and** a configured endpoint, and the endpoint is empty in this repo (`ReportSubmitter.isEnabled` → false) |
|
||||
| Device or other IDs | No | No | No advertising ID (no `AD_ID` permission in the merged manifest), no device-ID reads; debug reports include only `Build.MANUFACTURER`/`MODEL`/OS version, and stay on device (`reporting/DiagnosticsCollector.kt`) |
|
||||
|
||||
### Dependency audit (no ads / analytics / tracking SDKs)
|
||||
|
||||
The complete runtime dependency list (`app/build.gradle.kts` + `gradle/libs.versions.toml`) is:
|
||||
AndroidX (core, lifecycle, activity, navigation, webkit, Compose BOM, Room, DataStore,
|
||||
WorkManager, Hilt-androidx), Dagger Hilt, kotlinx-coroutines, Eclipse Angus Mail (IMAP/SMTP),
|
||||
AppAuth-Android (OAuth), and Zetetic SQLCipher. There is **no** Google Play Services, Firebase,
|
||||
ad, analytics, or crash-reporting dependency, and the merged release manifest contains no
|
||||
`com.google.android.gms.permission.AD_ID` permission (verified in
|
||||
`app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml`).
|
||||
|
||||
## Security-practices section of the form
|
||||
|
||||
- **Encrypted in transit:** yes — TLS everywhere, hostname verification pinned on
|
||||
(`mail.<proto>.ssl.checkserveridentity=true` in `ImapClient.kt:480` / `SmtpSender.kt:50`).
|
||||
- **Encryption at rest (optional extra credit, not a form field):** credentials are always
|
||||
encrypted with an Android Keystore key (`data/security/KeystoreCrypto.kt`,
|
||||
`CredentialStore.kt`); the mail cache can be SQLCipher-encrypted with a Keystore-sealed random
|
||||
key (`data/security/DatabaseKeyStore.kt`, opt-in, default off —
|
||||
`SettingsRepository.kt:44`).
|
||||
- **Independent security review badge:** not requested (optional program).
|
||||
|
||||
## If anything changes, this form must change
|
||||
|
||||
| Future change | Data-safety impact |
|
||||
|---|---|
|
||||
| Configuring a real `DEBUG_REPORT_ENDPOINT` (issue #34) | Declare **App info and performance → Crash logs / Diagnostics**: collected, optional (user-initiated), not shared, encrypted in transit, user can delete (reports are deletable pre-submit) |
|
||||
| Any opt-in telemetry from issues #10/#11 | Declare the specific types as collected + optional; backlog decision requires it stay strictly opt-in (F-Droid constraint) |
|
||||
| Any new SDK with network access | Re-run this audit; SDKs count toward the form ("data transmitted by libraries/SDKs") |
|
||||
|
||||
## Conservative alternative declaration (only if Google rejects "no collection")
|
||||
|
||||
Declare the following, all with *Collected: yes · Optional: no · Shared: no · Processed
|
||||
ephemerally: yes · Purpose: App functionality · Encrypted in transit: yes · Deletion: user can
|
||||
delete data in-app (remove account)*:
|
||||
|
||||
- Personal info → Email address (account sign-in)
|
||||
- Messages → Emails (sending/syncing the user's own mail with their provider)
|
||||
|
||||
Contacts, crash logs, and diagnostics remain **not collected** under any reading — they
|
||||
demonstrably never leave the device in this codebase.
|
||||
@@ -0,0 +1,112 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# Permissions justification — merged manifest audit (issue #17)
|
||||
|
||||
Every permission in the **merged release manifest** (source of truth:
|
||||
`app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml`
|
||||
after `./gradlew :app:bundleRelease`; attribution from
|
||||
`app/build/outputs/logs/manifest-merger-release-report.txt`), why it exists, where it is used,
|
||||
and the text to paste into Play Console where a declaration is required.
|
||||
|
||||
## Complete merged-manifest permission list
|
||||
|
||||
| Permission | Declared by | Runtime prompt? | Purpose |
|
||||
|---|---|---|---|
|
||||
| `INTERNET` | app manifest | No | IMAP/SMTP/OAuth/Graph connections to the user's mail provider |
|
||||
| `ACCESS_NETWORK_STATE` | app manifest | No | Connectivity checks so sync/WorkManager runs only when online |
|
||||
| `READ_CONTACTS` | app manifest | **Yes** | On-device recipient autocomplete in the compose screen |
|
||||
| `POST_NOTIFICATIONS` | app manifest | **Yes** (API 33+) | New-mail notifications + mandatory foreground-service status notification |
|
||||
| `FOREGROUND_SERVICE` | app manifest | No | Prerequisite for running any foreground service (API 28+) |
|
||||
| `FOREGROUND_SERVICE_DATA_SYNC` | app manifest | No | Type-specific permission for the IMAP IDLE push service (API 34+) |
|
||||
| `WAKE_LOCK` | `androidx.work:work-runtime:2.11.2` | No | WorkManager keeps the CPU awake while a scheduled job (mail sync, outbox send) runs |
|
||||
| `RECEIVE_BOOT_COMPLETED` | `androidx.work:work-runtime:2.11.2` | No | WorkManager reschedules pending jobs (periodic sync, queued outbox mail) after reboot |
|
||||
| `org.libremail.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` | `androidx.core:core:1.17.0` | No | Auto-generated app-signature permission guarding non-exported runtime receivers; not user-facing |
|
||||
|
||||
Nothing else. Notably **absent** (worth stating in any review exchange):
|
||||
|
||||
- **No `AD_ID`** — no ads or analytics SDKs at all.
|
||||
- **No storage/media permissions** — attachments use the Storage Access Framework
|
||||
(`OpenMultipleDocuments` in `ui/compose/ComposeScreen.kt:88`) and a `FileProvider` for viewing
|
||||
(`AndroidManifest.xml:95`).
|
||||
- **No `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`** — deliberately avoided because Play restricts
|
||||
it; the app deep-links to the system app-details screen instead
|
||||
(`push/BatteryOptimizationManager.kt`, comment cites this issue).
|
||||
- No location, camera, microphone, SMS, call-log, accessibility, or `QUERY_ALL_PACKAGES`.
|
||||
|
||||
## `READ_CONTACTS` (Play "sensitive" permission — scrutinized, no declaration form)
|
||||
|
||||
- **Feature:** recipient autocomplete while composing. `contacts/ContactsRepository.kt` queries
|
||||
`ContactsContract.CommonDataKinds.Email` for at most 8 name/email matches of the typed text.
|
||||
- **Data handling:** query and results are entirely **on-device** (results live in memory for
|
||||
the suggestion dropdown). Nothing from the contacts provider is stored, logged, or
|
||||
transmitted; an address reaches the network only if the user puts it on an email they send.
|
||||
- **Request flow:** first composition of the compose screen (`ui/compose/ComposeScreen.kt:101`);
|
||||
denial is handled gracefully — `ContactsRepository.search` returns empty and composing works
|
||||
normally (manual address entry).
|
||||
- **Play-Console justification text (if asked in review):**
|
||||
> LibreMail is an email client. READ_CONTACTS powers recipient autocomplete on the compose
|
||||
> screen only: the app queries the on-device contacts provider for names/email addresses
|
||||
> matching what the user typed and shows up to 8 suggestions. Contact data is processed
|
||||
> entirely on the device — it is never uploaded, stored outside the suggestion list, or shared.
|
||||
> The permission is requested in context (first open of the compose screen) and the feature
|
||||
> degrades gracefully if denied.
|
||||
|
||||
## `POST_NOTIFICATIONS`
|
||||
|
||||
- **Features:** (1) per-account new-mail notifications, generated on-device from synced mail —
|
||||
`notifications/MailNotifier.kt` (no push/cloud-messaging service; lock-screen content
|
||||
redacted via `VISIBILITY_PRIVATE`); (2) the persistent low-importance status notification
|
||||
Android requires while the IMAP IDLE foreground service runs (`push/IdleService.kt:120`).
|
||||
- **Request flow:** once at first launch, API 33+ only (`MainActivity.kt`
|
||||
`NotificationPermissionEffect`). If denied, `MailNotifier.notifyNewMail` no-ops (permission
|
||||
re-checked before every post, `MailNotifier.kt:134`); mail sync itself is unaffected.
|
||||
- **Play-Console justification text (if asked):**
|
||||
> Notifies the user of newly received email (per-account channels, generated on the device
|
||||
> from the user's own mailbox — no push service) and shows the persistent status notification
|
||||
> Android requires for the optional foreground IMAP IDLE connection. Requested once at first
|
||||
> launch; all app functions except notifications work if declined.
|
||||
|
||||
## `FOREGROUND_SERVICE_DATA_SYNC` (requires the Play Console FGS declaration)
|
||||
|
||||
Play Console → App content → **Foreground service permissions** asks for the type's use case
|
||||
and a demo video. Facts to declare, all verifiable in `push/IdleService.kt`:
|
||||
|
||||
- **What runs:** one foreground service (`.push.IdleService`, manifest
|
||||
`foregroundServiceType="dataSync"`, `AndroidManifest.xml:89`) holding a long-lived IMAP IDLE
|
||||
(RFC 2177) connection per configured account so the user's own mail server can push new mail
|
||||
instantly. On server activity it triggers a normal sync into the local cache and a new-mail
|
||||
notification.
|
||||
- **Why a foreground service:** IMAP IDLE requires a continuously open TCP connection that
|
||||
survives while the app is backgrounded; it cannot be modeled as deferrable work. WorkManager
|
||||
**is** used for everything deferrable (periodic sync, outbox sending) — the service exists
|
||||
only for the always-connected push case. There is no push-notification alternative (FCM)
|
||||
because plain IMAP servers cannot address one, and the app deliberately uses no Google cloud
|
||||
services.
|
||||
- **User control / lifecycle:** starts only when at least one account exists **and** the "push
|
||||
mail" setting is enabled (`LibreMailApplication.kt:70`); the setting is a visible toggle in
|
||||
Settings (`ui/settings/SettingsScreen.kt:147`); the service stops reactively when the toggle
|
||||
turns off or the last account is removed, and shows a persistent low-importance status
|
||||
notification while running (`IdleService.startAsForeground`).
|
||||
- **Declaration text to paste:**
|
||||
> LibreMail is an email client. The dataSync foreground service maintains a long-lived IMAP
|
||||
> IDLE (RFC 2177) connection to the user's own mail server so new mail arrives instantly.
|
||||
> IMAP has no out-of-band push channel (such as FCM), so real-time delivery requires keeping
|
||||
> this user-visible connection open; all deferrable transfers (periodic sync, sending queued
|
||||
> mail) already use WorkManager instead. The service runs only while the user has an account
|
||||
> configured and the "push mail" setting enabled, displays a persistent status notification,
|
||||
> and stops immediately when the user disables the setting or removes their last account.
|
||||
- **Demo video (human step):** screen-record: Settings → toggle "push mail" on → the status
|
||||
notification appears → send the account a mail from elsewhere → the new-mail notification
|
||||
arrives with the app backgrounded → toggle off → status notification disappears.
|
||||
- **Note:** the app targets SDK 37, so the API-34 requirement to declare a type for every FGS
|
||||
is in force; `FOREGROUND_SERVICE` plus the typed permission are both declared and the service
|
||||
calls `ServiceCompat.startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC)`
|
||||
(`IdleService.kt:136`).
|
||||
|
||||
## Library-injected permissions (`WAKE_LOCK`, `RECEIVE_BOOT_COMPLETED`)
|
||||
|
||||
Injected by `androidx.work:work-runtime:2.11.2` for its own machinery: holding a partial wake
|
||||
lock while an enqueued job executes, and re-registering scheduled jobs after a reboot. LibreMail
|
||||
uses WorkManager for periodic mail sync (`data/sync/` workers), reliable outbox sending
|
||||
(`SendWorker.kt`), and the opt-in debug-report upload (`ReportUploadWorker.kt`, dormant —
|
||||
endpoint unconfigured). Neither permission needs a Play declaration; keep this attribution handy
|
||||
for review questions.
|
||||
@@ -0,0 +1,8 @@
|
||||
First release of LibreMail.
|
||||
|
||||
* Outlook/Microsoft (OAuth 2.0), Gmail/Yahoo/iCloud (app password), and generic IMAP/SMTP accounts
|
||||
* Offline-first mail cache with full-history backfill and background sync
|
||||
* Rich-text compose, drafts, signatures, attachments, and a reliable outbox
|
||||
* Unified inbox across multiple accounts, local + server search
|
||||
* New-mail notifications and optional IMAP IDLE instant push
|
||||
* Hardened HTML reader: JavaScript off, remote images blocked by default
|
||||
@@ -0,0 +1,23 @@
|
||||
LibreMail is a free and open-source email client for Android, built with Kotlin, Jetpack Compose and Material 3 (Material You). It aims for a friendly default experience with power-user features tucked under an Advanced Settings group.
|
||||
|
||||
Features:
|
||||
|
||||
* Send and receive email with Outlook/Microsoft (OAuth 2.0), Gmail, Yahoo and iCloud (app password), and any other IMAP/SMTP provider
|
||||
* Guided first-run onboarding: welcome, vendor picker, per-vendor setup, then straight to your inbox
|
||||
* Offline-first: a local mail cache with full-history backfill (resumable) and an optional device-only retention limit
|
||||
* Multiple accounts with a unified inbox and per-account filtering
|
||||
* Rich-text compose with a formatting toolbar, per-account signatures, drafts, and a reliable background outbox
|
||||
* Message bodies rendered in a hardened WebView: JavaScript off, remote images blocked by default (tracking-pixel protection)
|
||||
* On-device new-mail notifications (no push service), with optional instant push via IMAP IDLE
|
||||
* Search across cached mail and the server (IMAP SEARCH)
|
||||
* Attachments: download on demand, open in a system viewer, and attach files when composing
|
||||
* mailto: link handling and share-to-email
|
||||
* Material You dynamic theming, light/dark, edge-to-edge
|
||||
|
||||
Privacy and security:
|
||||
|
||||
* No ads, no analytics SDK, no tracking. By default network traffic goes only to your mail providers.
|
||||
* Credentials are encrypted with the Android Keystore; OAuth uses Authorization Code + PKCE.
|
||||
* Optional (opt-in) extras: SQLCipher cache encryption, a biometric/device-credential app lock, Android Backup of settings only, and local-only debug reports that are sent nowhere unless you explicitly submit one.
|
||||
|
||||
LibreMail is licensed under the GNU General Public License v3.0 or later.
|
||||
@@ -0,0 +1 @@
|
||||
Privacy-respecting, offline-first email for any IMAP/SMTP provider
|
||||
@@ -0,0 +1 @@
|
||||
LibreMail
|
||||
Reference in New Issue
Block a user