Fix dark-mode reader readability + make API 37 E2E required #6

Merged
JMR-dev merged 3 commits from fix-dark-mode-colors into main 2026-06-30 23:35:08 +00:00
8 changed files with 210 additions and 17 deletions
+8 -7
View File
@@ -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
+1
View File
@@ -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")
}
}
+2
View File
@@ -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" }