From f353bdc5ec27eeff528694b0fdc4bfb1c87d4c44 Mon Sep 17 00:00:00 2001
From: Jason Ross <51939451+JMR-dev@users.noreply.github.com>
Date: Thu, 2 Jul 2026 18:09:40 -0500
Subject: [PATCH 1/2] test(compose): add RichTextEditor unit + ComposeScreen UI
coverage (#176)
Closes the #36 test-coverage gap left after the rich-text editor shipped:
- RichTextEditorTest.kt (new JVM unit test, 20 cases): exercises the
Compose-editor glue in RichTextEditor.kt that had no direct coverage -
applyStyle/applyBlock/applyLink, the AnnotatedString.toRichContent() <->
RichTextContent.toAnnotatedString() round trip across every span/link/
alignment/image/baseStyle channel, and the isStyled/hasBlock predicate
FormattingToolbar uses to light up its buttons. All plain TextFieldValue/
AnnotatedString/Color types, so it runs on the JVM with no emulator.
applyBlock/applyLink go from private to internal so the test can reach
them directly, mirroring applyStyle's existing internal visibility.
- ComposeScreenTest.kt (androidTest, following this file's existing
createAndroidComposeRule + fake-repository harness): one case taps the
bullet-list toolbar button and asserts the sent message carries the
- HTML (block markers apply to the caret's line, so no fragile
on-device range selection is needed); another asserts every toolbar
button's click-action label matches its string resource, verifying the
accessibility claim (the labels are onClickLabel, not contentDescription).
Headings remain deliberately out of scope per the ticket - no model/
toolbar changes here.
Closes #36
Co-authored-by: Claude Opus 4.8
---
.../libremail/ui/compose/ComposeScreenTest.kt | 54 ++++
.../libremail/ui/compose/RichTextEditor.kt | 4 +-
.../ui/compose/RichTextEditorTest.kt | 281 ++++++++++++++++++
3 files changed, 337 insertions(+), 2 deletions(-)
create mode 100644 app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt
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 662f1a8..bc014dd 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
@@ -169,6 +170,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/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
index 942af0b..4f30d49 100644
--- a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
+++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
@@ -297,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,
@@ -315,7 +315,7 @@ private fun applyBlock(
)
}
-private fun applyLink(
+internal fun applyLink(
value: TextFieldValue,
url: String,
linkColor: Color,
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,
+ )
+}
From 99f6ef19e46a06461835fbd310c15b400be6a2a3 Mon Sep 17 00:00:00 2001
From: Jason Ross <51939451+JMR-dev@users.noreply.github.com>
Date: Thu, 2 Jul 2026 18:23:30 -0500
Subject: [PATCH 2/2] fix(onboarding): request notification permission after
welcome screen renders (#168)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(onboarding): request notification permission after welcome screen renders
The POST_NOTIFICATIONS request fired from a MainActivity-root
NotificationPermissionEffect whose LaunchedEffect(Unit) ran on the very
first composition, so the system dialog could pop the instant the icon
was tapped — overlapping cold start/splash before any onboarding context
was on screen.
Move the effect into OnboardingWelcomeScreen so it fires once that screen
(the onboarding start destination) is composed and visible, with the
welcome content behind the dialog. Already-onboarded users launch
straight into the mailbox and never compose the welcome screen, so they
are unaffected; the API 33+ gate and the already-granted no-op are
preserved unchanged.
Co-Authored-By: Claude Opus 4.8
* test(onboarding): grant POST_NOTIFICATIONS in onboarding E2E to fix API 33+ flow
Moving the notification-permission request into OnboardingWelcomeScreen
(#151) means the system POST_NOTIFICATIONS dialog now pops when that
screen composes. On API 33+ (where it became a runtime permission) the
dialog backgrounded the activity mid-flow, so OnboardingFlowTest failed
with "No compose hierarchies found" on API 33/34/35/36/37 while API
29–32 stayed green.
Pre-grant the permission via a GrantPermissionRule so the dialog never
appears during the flow, guarded for API 33+ (the permission does not
exist below TIRAMISU, so grant nothing there to avoid erroring on older
devices). Adds the androidx.test:rules dependency that provides the rule.
Co-Authored-By: Claude Opus 4.8
---------
Co-authored-by: Claude Opus 4.8
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
app/build.gradle.kts | 1 +
.../ui/onboarding/OnboardingFlowTest.kt | 16 +++++++++++
.../main/kotlin/org/libremail/MainActivity.kt | 25 -----------------
.../ui/onboarding/OnboardingWelcomeScreen.kt | 28 +++++++++++++++++++
docs/play-permissions.md | 10 ++++---
gradle/libs.versions.toml | 2 ++
6 files changed, 53 insertions(+), 29 deletions(-)
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/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt
index 5308266..0b32584 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
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/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/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" }