Merge main into feat-163-default-account

This commit is contained in:
Jason Ross
2026-07-02 19:09:04 -05:00
committed by GitHub
15 changed files with 519 additions and 64 deletions
+1
View File
@@ -30,6 +30,7 @@ jobs:
autoupdate:
name: Auto-update armed PRs
runs-on: ubuntu-latest
environment: CI_CD
steps:
- name: Update behind PRs that have auto-merge enabled
uses: chinthakagodawita/autoupdate@0707656cd062a3b0cf8fa9b2cda1d1404d74437e # v1.7.0
+1
View File
@@ -227,6 +227,7 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.espresso.intents)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.room.testing)
@@ -3,6 +3,7 @@ package org.libremail.ui.compose
import android.Manifest
import androidx.activity.ComponentActivity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
@@ -171,6 +172,59 @@ class ComposeScreenTest {
assertEquals("bcc@example.com", sent.bcc)
}
@Test
fun formattingToolbar_bulletButtonMarksTheLineAndSendsItAsHtml() {
val mailRepository = FakeMailRepository()
setContent(mailRepository)
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Buy milk")
// The bullet-list button is deliberately chosen over the inline styles (bold/italic): block
// markers apply to the caret's whole line, so the end-of-text caret that performTextInput
// leaves is enough - no on-device range selection (which is unreliable in instrumented tests)
// is needed to prove that a toolbar tap flows real formatting into the sent message's HTML.
// "•" is the bullet button's own (untranslated) glyph label - see FormattingToolbar.
composeTestRule.onNodeWithText("•").performClick()
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
val sent = mailRepository.sentMessages.single()
// Plaintext keeps the readable "• " marker; the HTML part carries the real <ul>/<li> structure.
assertEquals("• Buy milk", sent.body)
assertTrue(
"expected bullet-list html, got ${sent.bodyHtml}",
sent.bodyHtml?.contains("<ul><li>Buy milk</li></ul>") == true,
)
}
@Test
fun formattingToolbar_buttonsCarryOnClickLabelsForAccessibility() {
setContent()
// Every toolbar button (see FormattingToolbar in RichTextEditor.kt) is a plain clickable Box with
// a bare glyph Text as its only visible content, so TalkBack relies entirely on the click action's
// label (there is no separate contentDescription) to announce what the button does.
val buttons = listOf(
"B" to R.string.format_bold,
"I" to R.string.format_italic,
"U" to R.string.format_underline,
"•" to R.string.format_bullet_list,
"1." to R.string.format_numbered_list,
"❝" to R.string.format_quote,
"🔗" to R.string.format_link,
)
buttons.forEach { (glyph, descriptionRes) ->
val config = composeTestRule.onNodeWithText(glyph).fetchSemanticsNode().config
val clickLabel = if (config.contains(SemanticsActions.OnClick)) {
config[SemanticsActions.OnClick].label
} else {
null
}
val message = "toolbar button \"$glyph\" is missing its accessibility label"
assertEquals(message, string(descriptionRes), clickLabel)
}
}
/** 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())
}
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import android.Manifest
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@@ -18,6 +20,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -54,6 +57,19 @@ class OnboardingFlowTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
// OnboardingWelcomeScreen requests POST_NOTIFICATIONS when it first composes (#151). On API 33+
// that runtime dialog would pop over the test, backgrounding the activity and leaving the compose
// rule with "No compose hierarchies found". Pre-grant it so the flow runs uninterrupted; the
// permission only exists on API 33+, so below TIRAMISU grant nothing (granting a nonexistent
// permission errors on older devices).
@get:Rule
val notificationPermission: GrantPermissionRule =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
} else {
GrantPermissionRule.grant()
}
private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args)
// Generous cap for the slow, animation-disabled CI matrix emulators; waitUntil returns as soon
@@ -208,8 +224,10 @@ class OnboardingFlowTest {
// add" button sit below the fold of this scrolling screen, and a positional click on an
// off-screen button is a silent no-op (which is why this passed only on API 37's taller AVD).
waitForText(string(R.string.app_password_email))
// Gmail requires 2-Step Verification before app passwords, so its screen (and only its
// screen — see yahooSetup_hasNoTwoFactorHelpLink) links Google's setup article (issue #98).
// Gmail requires 2-Step Verification before app passwords, so its screen links Google's
// setup article (issue #98). iCloud gets the same kind of link, in Apple's own terminology
// (see icloudSetup_hasTwoFactorHelpLink); Yahoo does not (see
// yahooSetup_hasNoTwoFactorHelpLink).
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help))
.performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_email))
@@ -238,7 +256,27 @@ class OnboardingFlowTest {
// Yahoo's setup screen keeps its app-password link…
waitForText(string(R.string.app_password_open_page, "Yahoo Mail"))
// …but gains no 2-Step Verification link: that prerequisite is Gmail-specific (issue #98).
// …but gains no two-factor help link: unlike Gmail and iCloud, Yahoo gates nothing on it
// (issue #98, #153).
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help_icloud)).assertDoesNotExist()
}
@Test
fun icloudSetup_hasTwoFactorHelpLink() {
setOnboardingContent(FakeAccountRepository(), FakeMailRepository())
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
waitForText("iCloud Mail")
composeTestRule.onNodeWithText("iCloud Mail").performClick()
// Apple also won't issue an app-specific password until two-factor authentication is on,
// so iCloud's screen links Apple's own setup article too — using Apple's terminology for
// the button ("Two-Factor Authentication"), not Google's "2-Step Verification" (issue #153).
waitForText(string(R.string.app_password_2fa_help_icloud))
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help_icloud))
.performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_open_page, "iCloud Mail"))
.assertIsDisplayed()
}
}
@@ -1,22 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
@@ -84,7 +75,6 @@ class MainActivity : FragmentActivity() {
setContent {
val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
LibreMailTheme(dynamicColor = dynamicColor) {
NotificationPermissionEffect()
// Gate the whole app behind the screen-lock when app-lock is enabled. When it is off
// the gate resolves straight to the content, so this is a no-op for most users.
AppLockGateHost {
@@ -106,18 +96,3 @@ class MainActivity : FragmentActivity() {
NotificationIntents.messageId(intent)?.let { pendingOpenMessageId.value = it }
}
}
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
@Composable
private fun NotificationPermissionEffect() {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
LaunchedEffect(Unit) {
// POST_NOTIFICATIONS is a runtime permission only on Android 13 (API 33)+. On older
// versions notifications are enabled by default, so there's nothing to request.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -31,8 +31,8 @@ enum class MailProvider(
val appPasswordHelpUrl: String,
/**
* Setup instructions for the provider's two-factor prerequisite, or null when there isn't one.
* Only Gmail refuses to create app passwords until 2-Step Verification is on, so only Gmail
* links its setup article; Yahoo and iCloud gate nothing on it.
* Gmail and iCloud both refuse to issue app passwords until two-factor auth is on, so both
* link their own setup article; Yahoo gates nothing on it.
*/
val twoFactorHelpUrl: String? = null,
private val imapHost: String,
@@ -74,7 +74,12 @@ enum class MailProvider(
ICLOUD(
key = "icloud",
displayName = "iCloud Mail",
appPasswordHelpUrl = "https://appleid.apple.com",
// Apple's own app-specific-password instructions, not just the generic Apple ID sign-in
// page — this article also states the two-factor prerequisite below.
appPasswordHelpUrl = "https://support.apple.com/en-us/102654",
// Like Gmail, Apple won't issue an app-specific password until two-factor authentication
// is on, so link Apple's dedicated setup article too (issue #153).
twoFactorHelpUrl = "https://support.apple.com/en-us/102660",
imapHost = "imap.mail.me.com",
smtpHost = "smtp.mail.me.com",
// Apple documents smtp.mail.me.com:587 with STARTTLS for iCloud Mail.
@@ -151,24 +151,25 @@ fun AppPasswordSetupScreen(
)
Spacer(Modifier.height(12.dp))
// Gmail and iCloud both have a two-factor prerequisite (see
// MailProvider.twoFactorHelpUrl): neither will issue an app password until it's on, so
// point users there first instead of sending them to the app-passwords page's dead
// end. Yahoo has no twoFactorHelpUrl, so this renders nothing for it.
provider.twoFactorHelpUrl?.let { twoFactorHelpUrl ->
OutlinedButton(
onClick = { openUrl(twoFactorHelpUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(twoFactorHelpLabel(provider)))
}
Spacer(Modifier.height(8.dp))
}
OutlinedButton(
onClick = { openUrl(provider.appPasswordHelpUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.app_password_open_page, provider.displayName))
}
// Only Gmail has a two-factor prerequisite (see MailProvider.twoFactorHelpUrl): its
// app-passwords page rejects accounts without 2-Step Verification, so give those users
// a way to set it up instead of a dead end.
provider.twoFactorHelpUrl?.let { twoFactorHelpUrl ->
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { openUrl(twoFactorHelpUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.app_password_2fa_help))
}
}
Spacer(Modifier.height(20.dp))
OutlinedTextField(
@@ -269,6 +270,16 @@ private fun providerIntro(provider: MailProvider): Int = when (provider) {
MailProvider.ICLOUD -> R.string.app_password_intro_icloud
}
/**
* Button copy for the two-factor prerequisite link, in each provider's own terminology — Google
* calls it "2-Step Verification", Apple "Two-Factor Authentication". Only reached for providers
* that expose [MailProvider.twoFactorHelpUrl]; Yahoo has none, so its branch here is unused.
*/
private fun twoFactorHelpLabel(provider: MailProvider): Int = when (provider) {
MailProvider.ICLOUD -> R.string.app_password_2fa_help_icloud
MailProvider.GMAIL, MailProvider.YAHOO -> R.string.app_password_2fa_help
}
private fun MailSecurity.label(): String = when (this) {
MailSecurity.SSL_TLS -> "SSL/TLS"
MailSecurity.STARTTLS -> "STARTTLS"
@@ -78,7 +78,8 @@ internal const val IMAGE_TAG = "libremail:image"
* feels exactly like the old editor.
*
* 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.
* work as usual; each toolbar button exposes its accessible action label via `onClickLabel` on its
* [Modifier.clickable] (not a `contentDescription`), and still carries 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).
@@ -296,7 +297,7 @@ internal fun applyStyle(
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
}
private fun applyBlock(
internal fun applyBlock(
value: TextFieldValue,
marker: BlockMarker,
linkColor: Color,
@@ -314,7 +315,7 @@ private fun applyBlock(
)
}
private fun applyLink(
internal fun applyLink(
value: TextFieldValue,
url: String,
linkColor: Color,
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -18,11 +23,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import org.libremail.R
/**
@@ -31,6 +39,11 @@ import org.libremail.R
*/
@Composable
fun OnboardingWelcomeScreen(onAddAccount: () -> Unit) {
// Requested from here, rather than the Activity root, so the system permission dialog appears
// once this screen (with onboarding context behind it) is actually visible instead of racing
// the cold-start/splash transition (#151). Already-onboarded users skip onboarding entirely, so
// this composable — and the request — never runs for them.
NotificationPermissionEffect()
Scaffold { padding ->
WelcomeContent(
onAddAccount = onAddAccount,
@@ -83,3 +96,18 @@ fun WelcomeContent(onAddAccount: () -> Unit, modifier: Modifier = Modifier) {
}
}
}
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
@Composable
private fun NotificationPermissionEffect() {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
LaunchedEffect(Unit) {
// POST_NOTIFICATIONS is a runtime permission only on Android 13 (API 33)+. On older
// versions notifications are enabled by default, so there's nothing to request.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -17,6 +17,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -33,8 +34,10 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -57,6 +60,9 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi
val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// Latches once the post-submit confirmation dialog is acknowledged, so it can't reappear
// during this screen's exit transition (state.submit stays SUCCEEDED after that point).
var reportSubmittedAcknowledged by remember { mutableStateOf(false) }
val savedMessage = stringResource(R.string.report_saved)
val copiedMessage = stringResource(R.string.report_copied)
@@ -79,9 +85,16 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi
}
}
// Once the report has been submitted (deleted by the worker) or discarded, leave the screen.
LaunchedEffect(state.loaded, state.exists) {
if (state.loaded && !state.exists) onDone()
// Leave once the report is gone — but a successful submit deletes the row from
// ReportUploadWorker as soon as the upload finishes, which can race ahead of `state.submit`
// itself reporting SUCCEEDED. So while a submit is in flight or has just succeeded, this
// effect defers to ReportSubmittedDialog below: its acknowledgement calls onDone() instead,
// guaranteeing the confirmation is seen. Plain discard (or a submit that never enqueued
// anything, e.g. FAILED/UNAVAILABLE) is unaffected and still auto-navigates immediately.
LaunchedEffect(state.loaded, state.exists, state.submit) {
val awaitingSubmitOutcome =
state.submit == SubmitUiState.SUBMITTING || state.submit == SubmitUiState.SUCCEEDED
if (state.loaded && !state.exists && !awaitingSubmitOutcome) onDone()
}
Scaffold(
@@ -164,6 +177,17 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi
}
}
}
// Gates leaving the screen on the success path (see the LaunchedEffect above) so the message
// is guaranteed to be seen, not just present for an instant before an auto-navigate.
if (state.submit == SubmitUiState.SUCCEEDED && !reportSubmittedAcknowledged) {
ReportSubmittedDialog(
onAcknowledge = {
reportSubmittedAcknowledged = true
onDone()
},
)
}
}
@Composable
@@ -212,19 +236,38 @@ private fun PayloadBox(payload: String) {
@Composable
private fun SubmitStatusText(state: SubmitUiState) {
if (state == SubmitUiState.IDLE) return
// SUCCEEDED is surfaced via ReportSubmittedDialog instead: a modal is what guarantees the
// message survives the screen's auto-navigate-on-delete race (see ReportReviewScreen above).
val text = when (state) {
SubmitUiState.SUBMITTING -> stringResource(R.string.report_submitting)
SubmitUiState.SUCCEEDED -> stringResource(R.string.report_submitted)
SubmitUiState.FAILED -> stringResource(R.string.report_submit_failed)
SubmitUiState.UNAVAILABLE -> stringResource(R.string.report_submit_unavailable)
SubmitUiState.IDLE -> ""
SubmitUiState.IDLE, SubmitUiState.SUCCEEDED -> return
}
val color = when (state) {
SubmitUiState.SUCCEEDED -> MaterialTheme.colorScheme.primary
SubmitUiState.FAILED, SubmitUiState.UNAVAILABLE -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
val color = if (state == SubmitUiState.FAILED || state == SubmitUiState.UNAVAILABLE) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Spacer(Modifier.height(8.dp))
Text(text, color = color, style = MaterialTheme.typography.bodyMedium)
}
/**
* Confirmation shown after a successful submission (#161). It — not the deleted-row auto-navigate
* — is what leaves the screen for that path, so the fuller thank-you message is guaranteed to be
* seen even though the report row (and therefore `state.exists`) can flip to gone moments after
* `SubmitUiState.SUCCEEDED`, once `ReportUploadWorker` finishes.
*/
@Composable
private fun ReportSubmittedDialog(onAcknowledge: () -> Unit) {
AlertDialog(
onDismissRequest = onAcknowledge,
text = { Text(stringResource(R.string.report_submitted)) },
confirmButton = {
TextButton(onClick = onAcknowledge) {
Text(stringResource(R.string.report_submitted_dismiss))
}
},
)
}
+5 -1
View File
@@ -184,6 +184,7 @@
<string name="app_password_warning">Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device.</string>
<string name="app_password_open_page">Create an app password for %1$s</string>
<string name="app_password_2fa_help">How to turn on 2-Step Verification</string>
<string name="app_password_2fa_help_icloud">How to turn on Two-Factor Authentication</string>
<string name="app_password_open_failed">Couldn\'t open your browser</string>
<string name="app_password_email">Email address</string>
<string name="app_password_field">App password</string>
@@ -339,7 +340,10 @@
<string name="report_copy">Copy</string>
<string name="report_save">Save to file</string>
<string name="report_submitting">Submitting…</string>
<string name="report_submitted">Report sent. Thank you!</string>
<!-- Shown in a confirmation dialog (not just inline) so it can't be missed if the report row
is deleted by ReportUploadWorker moments after the submit succeeds (#161). -->
<string name="report_submitted">Thank you for submitting a problem report! It\'s people like you that help make LibreMail better! Please note that submission of a problem does not guarantee a response or a fix, as this is a volunteer led project.</string>
<string name="report_submitted_dismiss">OK</string>
<string name="report_submit_failed">Couldn\'t send — it will be retried.</string>
<string name="report_submit_unavailable">Online submission isn\'t available in this build. Use Copy or Save to share the report.</string>
<string name="report_copied">Copied to clipboard</string>
@@ -82,7 +82,7 @@ class MailProviderTest {
}
@Test
fun `only gmail links two-factor setup help, over https`() {
fun `gmail and icloud link two-factor setup help over https, yahoo does not`() {
// Gmail's app-passwords page rejects accounts without 2-Step Verification, so its setup
// screen must offer the setup article as a way out (issue #98).
val gmailUrl = assertNotNull(
@@ -91,9 +91,18 @@ class MailProviderTest {
)
assertTrue(gmailUrl.startsWith("https://"), "gmail 2FA help URL must be https")
// Yahoo and iCloud gate nothing on two-factor, so they must not grow the extra link.
// Apple also won't issue an app-specific password until two-factor authentication is on,
// so iCloud needs the same escape hatch — pointed at Apple's dedicated article, not a
// generic Apple ID sign-in page (issue #153).
assertEquals("https://support.apple.com/en-us/102660", MailProvider.ICLOUD.twoFactorHelpUrl)
// Yahoo gates nothing on two-factor, so it must not grow the extra link.
assertNull(MailProvider.YAHOO.twoFactorHelpUrl)
assertNull(MailProvider.ICLOUD.twoFactorHelpUrl)
}
@Test
fun `icloud app-password help points at Apple's specific instructions, not the generic sign-in page`() {
assertEquals("https://support.apple.com/en-us/102654", MailProvider.ICLOUD.appPasswordHelpUrl)
}
@Test
@@ -0,0 +1,281 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.sp
import org.junit.Test
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
import org.libremail.richtext.RichTextContent
import org.libremail.richtext.RichTextEditing
import org.libremail.richtext.imageToken
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Unit tests for the Compose-editor glue in `RichTextEditor.kt`: the [TextFieldValue] <->
* [RichTextContent] conversions, the toolbar's editing ops ([applyStyle]/[applyBlock]/[applyLink]),
* and the active/inactive predicate `FormattingToolbar` uses to light up its buttons. Everything
* exercised here operates on plain [TextFieldValue]/[androidx.compose.ui.text.AnnotatedString]/[Color]
* values, so it all runs on the JVM - no emulator, no Compose runtime needed.
*/
class RichTextEditorTest {
private val linkColor = Color(0xFF3355FF)
private val noFont: (String) -> FontFamily? = { null }
private fun field(text: String, selection: TextRange = TextRange(text.length)) = TextFieldValue(text, selection)
// --- RichTextContent.toAnnotatedString() <-> AnnotatedString.toRichContent() round trip ---
@Test
fun `simple styles round-trip through spanStyles`() {
val content = RichTextContent(
text = "bold ital under struck",
spans = listOf(
RichSpan(0, 4, RichStyle.Bold),
RichSpan(5, 9, RichStyle.Italic),
RichSpan(10, 15, RichStyle.Underline),
RichSpan(16, 23, RichStyle.Strikethrough),
),
)
val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
assertEquals(content.text, restored.text)
assertEquals(content.spans.toSet(), restored.spans.toSet())
}
@Test
fun `parameterized styles on the same run round-trip through the STYLE_TAG annotation`() {
val content = RichTextContent(
text = "styled",
spans = listOf(
RichSpan(0, 6, RichStyle.FontFamily("Georgia, serif")),
RichSpan(0, 6, RichStyle.FontSize(18)),
RichSpan(0, 6, RichStyle.FontColor(0xFFCC0000.toInt())),
RichSpan(0, 6, RichStyle.Highlight(0xFFFFFF00.toInt())),
),
)
val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
assertEquals(content.spans.toSet(), restored.spans.toSet())
}
@Test
fun `links round-trip and their paint never masquerades as a font color span`() {
val content = RichTextContent("see here", links = listOf(RichLink(4, 8, "http://example.com")))
val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
assertEquals(content.links, restored.links)
// The link's own SpanStyle(color = linkColor) must not decode back as a RichStyle.FontColor span
// (simpleStyleOf only recognizes the simple toggle styles; parameterized styles need STYLE_TAG).
assertTrue(restored.spans.isEmpty(), restored.spans.toString())
}
@Test
fun `images round-trip through the IMAGE_TAG annotation`() {
val token = imageToken("cat.png")
val content = RichTextContent(
text = token,
images = listOf(RichImage(0, token.length, "img1@libremail", "cat.png")),
)
val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
assertEquals(content.images, restored.images)
}
@Test
fun `alignments round-trip through paragraph styles`() {
val content = RichTextContent("left\ncentered", alignments = listOf(RichAlignment(5, 13, RichAlign.CENTER)))
val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
assertEquals(content.alignments, restored.alignments)
}
@Test
fun `baseStyle is position-independent so it must be threaded back in explicitly`() {
val base = RichBaseStyle(fontCss = "Arial, sans-serif", fontSizePt = 12)
val annotated = RichTextContent("plain", baseStyle = base).toAnnotatedString(linkColor, noFont)
// toAnnotatedString never encodes baseStyle into the AnnotatedString itself...
assertEquals(RichTextContent("plain"), annotated.toRichContent(baseStyle = null))
// ...toRichContent only carries it because the caller (RichTextBodyField) passes it back in.
assertEquals(base, annotated.toRichContent(baseStyle = base).baseStyle)
}
@Test
fun `resolveFont maps a FontFamily span's css to a display font without losing the css on replay`() {
val cursive = FontFamily.Cursive
val content = RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.FontFamily("cursive-css"))))
val annotated = content.toAnnotatedString(linkColor) { css -> cursive.takeIf { css == "cursive-css" } }
assertEquals(cursive, annotated.spanStyles.single().item.fontFamily)
// Resolution is display-only: the model's css string survives regardless of whether it resolved.
assertEquals(content.spans, annotated.toRichContent().spans)
}
@Test
fun `a mixed run keeps every channel (style, link, alignment, image) distinct on the round trip`() {
val content = RichTextContent(
text = "• item\nnote [image: dog.png]",
spans = listOf(RichSpan(2, 6, RichStyle.Bold), RichSpan(7, 11, RichStyle.FontSize(10))),
links = listOf(RichLink(7, 11, "http://example.com")),
alignments = listOf(RichAlignment(0, 6, RichAlign.CENTER)),
images = listOf(RichImage(12, 28, "dog@mail", "dog.png")),
)
val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
assertEquals(content.text, restored.text)
assertEquals(content.spans.toSet(), restored.spans.toSet())
assertEquals(content.links, restored.links)
assertEquals(content.alignments, restored.alignments)
assertEquals(content.images, restored.images)
}
// --- applyStyle ---
@Test
fun `applyStyle toggles bold over the selection and keeps the selection unchanged`() {
val value = field("hello", TextRange(0, 5))
val bolded = applyStyle(value, RichStyle.Bold, linkColor)
assertEquals(TextRange(0, 5), bolded.selection)
assertEquals(listOf(RichSpan(0, 5, RichStyle.Bold)), bolded.annotatedString.toRichContent().spans)
val plain = applyStyle(bolded, RichStyle.Bold, linkColor)
assertTrue(plain.annotatedString.toRichContent().spans.isEmpty())
}
@Test
fun `applyStyle replaces a different value of the same parameterized kind`() {
val value = field("sized", TextRange(0, 5))
val small = applyStyle(value, RichStyle.FontSize(12), linkColor)
val big = applyStyle(small, RichStyle.FontSize(24), linkColor)
assertEquals(listOf(RichSpan(0, 5, RichStyle.FontSize(24))), big.annotatedString.toRichContent().spans)
}
@Test
fun `applyStyle is a no-op with a collapsed selection`() {
val value = field("hello", TextRange(2))
val result = applyStyle(value, RichStyle.Bold, linkColor)
assertTrue(result.annotatedString.toRichContent().spans.isEmpty())
}
// --- applyBlock ---
@Test
fun `applyBlock adds a bullet at the caret and shifts the selection past it`() {
val value = field("ab", TextRange(0))
val result = applyBlock(value, BlockMarker.BULLET, linkColor, noFont)
assertEquals("• ab", result.annotatedString.text)
assertEquals(TextRange(2), result.selection)
}
@Test
fun `applyBlock removes the marker on a second toggle`() {
val value = field("ab", TextRange(0))
val bulleted = applyBlock(value, BlockMarker.BULLET, linkColor, noFont)
val plain = applyBlock(bulleted.copy(selection = TextRange(0)), BlockMarker.BULLET, linkColor, noFont)
assertEquals("ab", plain.annotatedString.text)
}
@Test
fun `applyBlock replaces a different marker across a multi-line selection`() {
val value = field("• a\n• b", TextRange(0, 7))
val result = applyBlock(value, BlockMarker.ORDERED, linkColor, noFont)
assertEquals("1. a\n2. b", result.annotatedString.text)
}
// --- applyLink ---
@Test
fun `applyLink annotates the selection with the url and paints it in the link color`() {
val value = field("see here", TextRange(4, 8))
val linked = applyLink(value, "http://example.com", linkColor, noFont)
val content = linked.annotatedString.toRichContent()
assertEquals(listOf(RichLink(4, 8, "http://example.com")), content.links)
assertEquals(SpanStyle(color = linkColor), linked.annotatedString.spanStyles.single().item)
}
@Test
fun `applyLink is a no-op without a selection`() {
val value = field("see here", TextRange(4))
val result = applyLink(value, "http://example.com", linkColor, noFont)
assertTrue(result.annotatedString.toRichContent().links.isEmpty())
}
// --- applyBaseStyle ---
@Test
fun `applyBaseStyle overlays the message-wide font family and size`() {
val cursive = FontFamily.Cursive
val base = RichBaseStyle(fontCss = "cursive-css", fontSizePt = 18)
val result = applyBaseStyle(TextStyle(fontSize = 14.sp), base) { css ->
cursive.takeIf { css == "cursive-css" }
}
assertEquals(cursive, result.fontFamily)
assertEquals(18.sp, result.fontSize)
}
@Test
fun `applyBaseStyle is a no-op when there is no base style`() {
val original = TextStyle(fontSize = 14.sp)
assertEquals(original, applyBaseStyle(original, null, noFont))
}
// --- FormattingToolbar's active/inactive toggle state ---
// FormattingToolbar lights up a button with exactly `RichTextEditing.isStyled`/`hasBlock` over the
// field's current selection (see RichTextEditor.kt); these tests drive that same call through the
// TextFieldValue produced by the toolbar's own editing ops, so they pin the toggle behavior a user
// actually sees without needing to compose the toolbar itself.
@Test
fun `bold toggle state flips as the toolbar would read it after each tap`() {
var value = field("hello world", TextRange(0, 5))
assertFalse(isBoldActive(value))
value = applyStyle(value, RichStyle.Bold, linkColor)
assertTrue(isBoldActive(value))
// A selection that spans past the bold run is a mixed selection - inactive, like isStyled reports.
assertFalse(isBoldActive(value.copy(selection = TextRange(0, 11))))
value = applyStyle(value, RichStyle.Bold, linkColor)
assertFalse(isBoldActive(value))
}
@Test
fun `bullet toggle state is active only once every touched line carries the marker`() {
val start = field("a\nb", TextRange(0))
assertFalse(isBulletActive(start.copy(selection = TextRange(0, 3))))
val firstLineOnly = applyBlock(start, BlockMarker.BULLET, linkColor, noFont)
assertEquals("• a\nb", firstLineOnly.annotatedString.text)
val wholeText = TextRange(0, firstLineOnly.annotatedString.length)
assertFalse(isBulletActive(firstLineOnly.copy(selection = wholeText)))
val bothLines = applyBlock(firstLineOnly.copy(selection = wholeText), BlockMarker.BULLET, linkColor, noFont)
assertEquals("• a\n• b", bothLines.annotatedString.text)
val fullText = TextRange(0, bothLines.annotatedString.length)
assertTrue(isBulletActive(bothLines.copy(selection = fullText)))
}
/** Mirrors exactly what FormattingToolbar reads to decide a style button's active/inactive tint. */
private fun isBoldActive(value: TextFieldValue): Boolean = RichTextEditing.isStyled(
value.annotatedString.toRichContent(),
value.selection.min,
value.selection.max,
RichStyle.Bold,
)
/** Mirrors exactly what FormattingToolbar reads to decide a block button's active/inactive tint. */
private fun isBulletActive(value: TextFieldValue): Boolean = RichTextEditing.hasBlock(
value.annotatedString.toRichContent(),
value.selection.min,
value.selection.max,
BlockMarker.BULLET,
)
}
+6 -4
View File
@@ -62,14 +62,16 @@ Nothing else. Notably **absent** (worth stating in any review exchange):
`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
- **Request flow:** once, when the onboarding welcome screen appears, API 33+ only
(`ui/onboarding/OnboardingWelcomeScreen.kt` `NotificationPermissionEffect`, scoped to that
screen's composition so the system dialog shows onboarding context instead of racing the
cold-start/splash transition — #151). 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.
> Android requires for the optional foreground IMAP IDLE connection. Requested once, when the
> onboarding welcome screen appears; all app functions except notifications work if declined.
## `FOREGROUND_SERVICE_DATA_SYNC` (requires the Play Console FGS declaration)
+2
View File
@@ -26,6 +26,7 @@ angusMail = "2.0.5"
junit = "4.13.2"
androidxJunit = "1.2.1"
espresso = "3.7.0"
androidxTestRules = "1.7.0"
turbine = "1.2.1"
mockk = "1.14.11"
greenmail = "2.1.9"
@@ -110,6 +111,7 @@ greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenma
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }