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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Pair<String, Boolean>?>(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 """
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src http: https: data: cid:; style-src 'unsafe-inline'; font-src data:">
|
||||
<style>
|
||||
:root { color-scheme: $scheme; }
|
||||
html, body { background-color: $backgroundHex; color: $textHex; }
|
||||
body { font-family: sans-serif; line-height: 1.5; padding: 16px; word-wrap: break-word; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #0B57D0; }
|
||||
a { color: $linkHex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>$body</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = "<p>hello</p>", 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("<p>hello</p>"), "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 = "<p>hi</p>", 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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user