Fix dark-mode reader readability + make API 37 E2E required #6
@@ -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
|
||||
|
||||
@@ -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