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 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" }