diff --git a/.github/workflows/autoupdate.yml b/.github/workflows/autoupdate.yml
index 855f3ee..c570043 100644
--- a/.github/workflows/autoupdate.yml
+++ b/.github/workflows/autoupdate.yml
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4f62af4..5c0e84e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
index 37e746b..13e83f4 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
@@ -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
/- structure.
+ assertEquals("• Buy milk", sent.body)
+ assertTrue(
+ "expected bullet-list html, got ${sent.bodyHtml}",
+ sent.bodyHtml?.contains("") == 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())
}
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
index 5308266..121a218 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
@@ -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()
+ // 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()
}
}
diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt
index 2f706b7..e480786 100644
--- a/app/src/main/kotlin/org/libremail/MainActivity.kt
+++ b/app/src/main/kotlin/org/libremail/MainActivity.kt
@@ -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)
- }
-}
diff --git a/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt b/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt
index 9d9ff26..5f93cc2 100644
--- a/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt
+++ b/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt
@@ -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.
diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt
index 1a640ba..2482252 100644
--- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt
@@ -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"
diff --git a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
index 18f4804..4f30d49 100644
--- a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
+++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
@@ -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,
diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt
index 22e3631..1eb6d5b 100644
--- a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt
index 559bf64..d14c76d 100644
--- a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt
@@ -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))
+ }
+ },
+ )
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ce0c4bb..05ca2ec 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -184,6 +184,7 @@
Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device.
Create an app password for %1$s
How to turn on 2-Step Verification
+ How to turn on Two-Factor Authentication
Couldn\'t open your browser
Email address
App password
@@ -339,7 +340,10 @@
Copy
Save to file
Submitting…
- Report sent. Thank you!
+
+ 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.
+ OK
Couldn\'t send — it will be retried.
Online submission isn\'t available in this build. Use Copy or Save to share the report.
Copied to clipboard
diff --git a/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt
index 5ddfa07..69d16de 100644
--- a/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt
+++ b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt
@@ -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
diff --git a/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt
new file mode 100644
index 0000000..38414a9
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt
@@ -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,
+ )
+}
diff --git a/docs/play-permissions.md b/docs/play-permissions.md
index 833d2d9..3d806d5 100644
--- a/docs/play-permissions.md
+++ b/docs/play-permissions.md
@@ -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)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ac5b242..20e92fc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }