From 8e29aebc2ec2a3b4d32235d150805f0b2baad309 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 30 Jun 2026 18:17:06 -0500 Subject: [PATCH 1/2] fix: render reader emails readably in dark mode The reader rendered HTML emails as black-on-black in dark mode: the WebView background was transparent (so the near-black app surface showed through) and the injected CSS set no text or background color, so the WebView fell back to its default black text. Wrap each email with explicit, theme-derived background, text, and link colors (surface / onSurface / primary) plus a matching color-scheme, set the WebView background to the surface color, and allow WebView algorithmic darkening where supported as a backstop for emails that hardcode their own foreground colors. Add JVM contrast guards: HtmlBodyTest pins the wrapper's readability contract (explicit colors meeting WCAG AA), and ColorSchemeContrastTest audits the fallback light/dark Material schemes' role pairs. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 1 + .../org/libremail/ui/reader/HtmlBody.kt | 72 ++++++++++++++++--- .../kotlin/org/libremail/ui/theme/Theme.kt | 5 +- .../test/kotlin/org/libremail/ui/Contrast.kt | 32 +++++++++ .../org/libremail/ui/reader/HtmlBodyTest.kt | 57 +++++++++++++++ .../ui/theme/ColorSchemeContrastTest.kt | 43 +++++++++++ gradle/libs.versions.toml | 2 + 7 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 app/src/test/kotlin/org/libremail/ui/Contrast.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/reader/HtmlBodyTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/theme/ColorSchemeContrastTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d7c08f6..10e0618 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.webkit) implementation(libs.kotlinx.coroutines.android) // Email transport (IMAP/SMTP) + OAuth diff --git a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt index 7f5a993..494f2c2 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt @@ -3,22 +3,33 @@ package org.libremail.ui.reader import android.annotation.SuppressLint import android.content.Intent -import android.graphics.Color import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature /** * Renders an HTML email body in a hardened WebView: JavaScript and file/content access are * disabled, links open in the system browser, and remote content is blocked until the user * opts in (tracking-pixel protection). + * + * The email is wrapped with an explicit background/text/link color drawn from the active Material + * theme so it is always readable — in dark mode the previous transparent WebView showed the + * near-black app surface through emails whose own CSS left the text at the browser default of + * black, rendering them black-on-black. Where the platform supports it, algorithmic darkening is + * enabled as a backstop for emails that hardcode their own foreground colors. */ @SuppressLint("SetJavaScriptEnabled") @Composable @@ -28,8 +39,24 @@ fun HtmlBody( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val colorScheme = MaterialTheme.colorScheme + val surface = colorScheme.surface + val onSurface = colorScheme.onSurface + val primary = colorScheme.primary + val isDark = surface.luminance() < 0.5f + val surfaceArgb = surface.toArgb() + val document = remember(html, surface, onSurface, primary) { + wrapHtml( + body = html, + backgroundHex = surface.toCssHex(), + textHex = onSurface.toCssHex(), + linkHex = primary.toCssHex(), + dark = isDark, + ) + } // Tracks the content actually loaded so recompositions (star/attachment state changes) don't - // reload the page and throw away the user's scroll position. + // reload the page and throw away the user's scroll position. Keyed on the fully wrapped + // document so a theme (light/dark) change still re-renders with the new colors. val lastLoaded = remember { mutableStateOf?>(null) } AndroidView( modifier = modifier, @@ -45,7 +72,8 @@ fun HtmlBody( builtInZoomControls = true displayZoomControls = false } - setBackgroundColor(Color.TRANSPARENT) + setBackgroundColor(surfaceArgb) + applyAlgorithmicDarkening(isDark) isVerticalScrollBarEnabled = true webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { @@ -67,28 +95,56 @@ fun HtmlBody( } }, update = { webView -> + // Re-apply theme-dependent state so toggling light/dark while the reader is open updates + // the chrome behind the (padding of the) page as well as the content. + webView.setBackgroundColor(surfaceArgb) + webView.applyAlgorithmicDarkening(isDark) webView.settings.blockNetworkLoads = !loadRemoteImages - val key = html to loadRemoteImages + val key = document to loadRemoteImages if (lastLoaded.value != key) { lastLoaded.value = key - webView.loadDataWithBaseURL(null, wrapHtml(html), "text/html", "UTF-8", null) + webView.loadDataWithBaseURL(null, document, "text/html", "UTF-8", null) } }, ) } -private fun wrapHtml(body: String): String = - """ +/** + * Lets the WebView algorithmically darken email content that does not declare its own dark support, + * but only in dark mode and only where the installed WebView supports the feature. This is a + * best-effort backstop; readability is already guaranteed by the explicit colors in [wrapHtml]. + */ +private fun WebView.applyAlgorithmicDarkening(dark: Boolean) { + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, dark) + } +} + +/** The color as a CSS `#RRGGBB` string (alpha dropped — email backgrounds/text are opaque). */ +internal fun Color.toCssHex(): String = "#%06X".format(toArgb() and 0xFFFFFF) + +internal fun wrapHtml( + body: String, + backgroundHex: String, + textHex: String, + linkHex: String, + dark: Boolean, +): String { + val scheme = if (dark) "dark" else "light" + return """ $body """.trimIndent() +} diff --git a/app/src/main/kotlin/org/libremail/ui/theme/Theme.kt b/app/src/main/kotlin/org/libremail/ui/theme/Theme.kt index f3b323e..891f66f 100644 --- a/app/src/main/kotlin/org/libremail/ui/theme/Theme.kt +++ b/app/src/main/kotlin/org/libremail/ui/theme/Theme.kt @@ -11,12 +11,13 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -private val LightColors = lightColorScheme( +// Exposed as internal (not private) so the color-scheme contrast test can audit the role pairs. +internal val LightColors = lightColorScheme( primary = BrandBlue, secondary = BrandTeal, ) -private val DarkColors = darkColorScheme( +internal val DarkColors = darkColorScheme( primary = BrandBlueLight, secondary = BrandTealLight, ) diff --git a/app/src/test/kotlin/org/libremail/ui/Contrast.kt b/app/src/test/kotlin/org/libremail/ui/Contrast.kt new file mode 100644 index 0000000..466f9c3 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/Contrast.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui + +import kotlin.math.pow + +/** + * WCAG 2.x relative luminance of an opaque sRGB color packed as ARGB (alpha ignored). + * See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance. + */ +fun relativeLuminance(argb: Int): Double { + val r = linearize((argb shr 16) and 0xFF) + val g = linearize((argb shr 8) and 0xFF) + val b = linearize(argb and 0xFF) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +private fun linearize(channel: Int): Double { + val c = channel / 255.0 + return if (c <= 0.03928) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4) +} + +/** + * WCAG 2.x contrast ratio in [1.0, 21.0] between two opaque colors. + * See https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio. + */ +fun contrastRatio(foreground: Int, background: Int): Double { + val l1 = relativeLuminance(foreground) + val l2 = relativeLuminance(background) + val lighter = maxOf(l1, l2) + val darker = minOf(l1, l2) + return (lighter + 0.05) / (darker + 0.05) +} diff --git a/app/src/test/kotlin/org/libremail/ui/reader/HtmlBodyTest.kt b/app/src/test/kotlin/org/libremail/ui/reader/HtmlBodyTest.kt new file mode 100644 index 0000000..a573bc8 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reader/HtmlBodyTest.kt @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reader + +import androidx.compose.ui.graphics.Color +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Test +import org.libremail.ui.contrastRatio + +/** + * Pins the readability contract of the reader's HTML wrapper: every email is rendered with an + * explicit, theme-derived background, text, and link color that meet WCAG AA contrast. This is the + * deterministic guard against the dark-mode "black on black" bug, where a transparent WebView over + * the near-black app surface left color-less emails showing the browser-default black text. + */ +class HtmlBodyTest { + + private fun rgb(hex: String): Int = hex.removePrefix("#").toInt(16) + + @Test + fun `dark theme email is wrapped with readable, explicit colors`() { + val bg = "#121212" + val text = "#E6E1E5" + val link = "#A8C7FA" + val html = wrapHtml(body = "

hello

", backgroundHex = bg, textHex = text, linkHex = link, dark = true) + + assertTrue(html.contains("background-color: $bg"), "missing explicit background:\n$html") + assertTrue(html.contains("color: $text"), "missing explicit text color:\n$html") + assertTrue(html.contains("a { color: $link; }"), "missing explicit link color:\n$html") + assertTrue(html.contains("color-scheme: dark"), "missing dark color-scheme:\n$html") + assertTrue(html.contains("

hello

"), "email body dropped:\n$html") + + assertTrue(contrastRatio(rgb(text), rgb(bg)) >= 4.5, "text/background contrast below AA") + assertTrue(contrastRatio(rgb(link), rgb(bg)) >= 4.5, "link/background contrast below AA") + } + + @Test + fun `light theme email is wrapped with readable, explicit colors`() { + val bg = "#FFFBFE" + val text = "#1C1B1F" + val link = "#0B57D0" + val html = wrapHtml(body = "

hi

", backgroundHex = bg, textHex = text, linkHex = link, dark = false) + + assertTrue(html.contains("background-color: $bg"), "missing explicit background:\n$html") + assertTrue(html.contains("color: $text"), "missing explicit text color:\n$html") + assertTrue(html.contains("color-scheme: light"), "missing light color-scheme:\n$html") + + assertTrue(contrastRatio(rgb(text), rgb(bg)) >= 4.5, "text/background contrast below AA") + assertTrue(contrastRatio(rgb(link), rgb(bg)) >= 4.5, "link/background contrast below AA") + } + + @Test + fun `toCssHex formats opaque colors as #RRGGBB`() { + assertEquals("#121212", Color(0xFF121212).toCssHex()) + assertEquals("#A8C7FA", Color(0xFFA8C7FA).toCssHex()) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/theme/ColorSchemeContrastTest.kt b/app/src/test/kotlin/org/libremail/ui/theme/ColorSchemeContrastTest.kt new file mode 100644 index 0000000..b48c09c --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/theme/ColorSchemeContrastTest.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import kotlin.test.assertTrue +import org.junit.Test +import org.libremail.ui.contrastRatio + +/** + * Guards the fallback (non-dynamic) color schemes — the ones LibreMail ships for pre-Android-12 + * devices and when Material You is disabled — so a low-contrast palette change can't make UI text + * unreadable, in dark mode in particular. Dynamic (wallpaper-derived) schemes are generated and + * contrast-managed by the platform (and need a Context), so they are out of scope here. + */ +class ColorSchemeContrastTest { + + private fun assertReadable(label: String, foreground: Color, background: Color) { + val ratio = contrastRatio(foreground.toArgb(), background.toArgb()) + assertTrue(ratio >= 4.5, "$label contrast ${"%.2f".format(ratio)} is below WCAG AA (4.5:1)") + } + + private fun ColorScheme.assertTextPairsReadable(label: String) { + assertReadable("$label onSurface/surface", onSurface, surface) + assertReadable("$label onSurfaceVariant/surface", onSurfaceVariant, surface) + assertReadable("$label onBackground/background", onBackground, background) + assertReadable("$label onPrimary/primary", onPrimary, primary) + assertReadable("$label onPrimaryContainer/primaryContainer", onPrimaryContainer, primaryContainer) + assertReadable("$label onSecondary/secondary", onSecondary, secondary) + assertReadable("$label onSecondaryContainer/secondaryContainer", onSecondaryContainer, secondaryContainer) + } + + @Test + fun `dark fallback scheme keeps text readable`() { + DarkColors.assertTextPairsReadable("dark") + } + + @Test + fun `light fallback scheme keeps text readable`() { + LightColors.assertTextPairsReadable("light") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e099d3..4e8809b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ coreKtx = "1.17.0" lifecycle = "2.9.4" activityCompose = "1.12.4" navigationCompose = "2.9.8" +webkit = "1.12.1" composeBom = "2026.06.00" room = "2.8.4" sqlcipher = "4.16.0" @@ -32,6 +33,7 @@ androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lif androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } # Compose (versions managed by the BOM) androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -- 2.47.3 From 7987333149779c7cba79f7461317b88e146c4cb8 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 30 Jun 2026 18:17:15 -0500 Subject: [PATCH 2/2] ci: require the API 37 preview E2E job to merge The custom-provisioned API 37 preview emulator has been stable, so fold its E2E job into the aggregating "CI passed" gate's needs. Because branch protection requires only that single check, no settings change is needed. Drop the now-inaccurate "non-blocking" wording from the job name and comments. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9564f4..f5203f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: # new Android ships, add it and drop the oldest level that has aged out of ~7 years. API 37 # (preview) is NOT in this matrix because emulator-runner can't provision its nonstandard # android-37.0 / google_apis_ps16k image (it would wedge the gate) — it's covered separately - # by the custom-provisioned, non-blocking `e2e-preview` job below. Keep in sync with + # by the custom-provisioned `e2e-preview` job below. Keep in sync with # testOptions.managedDevices in app/build.gradle.kts. api-level: [29, 30, 31, 32, 33, 34, 35, 36] steps: @@ -175,12 +175,12 @@ jobs: # API 37 (Android 17, preview) E2E. Its only system image is the nonstandard # android-37.0 / google_apis_ps16k (16 KB page size), which reactivecircus/android-emulator-runner # can't provision (it builds android-37 / google_apis, neither of which exists), so this job - # CUSTOM-PROVISIONS the emulator with sdkmanager/avdmanager/emulator directly. Deliberately - # NON-BLOCKING: it is NOT in the "CI passed" gate's needs and not a required status check, so a - # flaky preview can never block merges — it's signal only. When a stable, emulator-runner-friendly - # API 37 image ships, fold 37 into the main `e2e` matrix and delete this job. + # CUSTOM-PROVISIONS the emulator with sdkmanager/avdmanager/emulator directly. It is REQUIRED: + # part of the "CI passed" gate's needs (the preview emulator has proven stable in practice), so a + # genuine failure blocks merges. When a stable, emulator-runner-friendly API 37 image ships, fold + # 37 into the main `e2e` matrix and delete this job. e2e-preview: - name: E2E (API 37 preview, non-blocking) + name: E2E (API 37 preview) runs-on: ubuntu-latest timeout-minutes: 35 env: @@ -291,7 +291,7 @@ jobs: ci-passed: name: CI passed if: always() - needs: [debug-build, unit-tests, e2e] + needs: [debug-build, unit-tests, e2e, e2e-preview] runs-on: ubuntu-latest steps: - name: Verify every required job succeeded @@ -301,4 +301,5 @@ jobs: echo " debug-build: ${{ needs.debug-build.result }}" echo " unit-tests: ${{ needs.unit-tests.result }}" echo " e2e: ${{ needs.e2e.result }}" + echo " e2e-preview: ${{ needs.e2e-preview.result }}" exit 1 -- 2.47.3