Merge branch 'main' into feat-161-report-confirmation
This commit is contained in:
@@ -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
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user