Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,36 +48,42 @@ class DashboardThemePaintTest {
val releaseStylesheet = AtomicReference(CountDownLatch(1))
val stylesheetMissing = AtomicBoolean(false)
val server = ServerSocket(0)
// Concurrent handlers: a held /dark.css from the previous document
// must not stall GET /. Serial accept timed out the /early → /
// wait on API 29 CI (run 40334359221).
val responder = thread(isDaemon = true) {
try {
while (true) {
server.accept().use { socket ->
socket.soTimeout = 5000
val input = socket.getInputStream().bufferedReader()
val path = input.readLine()?.split(" ")?.getOrNull(1) ?: "/"
while (!input.readLine().isNullOrEmpty()) { }
if (path == "/dark.css" && stylesheetMissing.get()) {
socket.getOutputStream().write(
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".toByteArray()
)
return@use
}
val (type, body) = when (path) {
"/" -> "text/html" to PAGE
"/early" -> "text/html" to PAGE_EARLY
"/settings" -> "application/json" to "{\"theme\":\"dark\"}"
"/dark.css" -> {
val release = releaseStylesheet.get()
stylesheetRequests.incrementAndGet()
release.await(15, TimeUnit.SECONDS)
"text/css" to "body { background: rgb(15, 19, 26); }"
val socket = server.accept()
thread(isDaemon = true) {
socket.use { conn ->
conn.soTimeout = 5000
val input = conn.getInputStream().bufferedReader()
val path = input.readLine()?.split(" ")?.getOrNull(1) ?: "/"
while (!input.readLine().isNullOrEmpty()) { }
if (path == "/dark.css" && stylesheetMissing.get()) {
conn.getOutputStream().write(
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".toByteArray()
)
return@use
}
val (type, body) = when (path) {
"/" -> "text/html" to PAGE
"/early" -> "text/html" to PAGE_EARLY
"/settings" -> "application/json" to "{\"theme\":\"dark\"}"
"/dark.css" -> {
val release = releaseStylesheet.get()
stylesheetRequests.incrementAndGet()
release.await(15, TimeUnit.SECONDS)
"text/css" to "body { background: rgb(15, 19, 26); }"
}
else -> "text/plain" to ""
}
else -> "text/plain" to ""
val response = "HTTP/1.1 200 OK\r\nContent-Type: $type\r\n" +
"Content-Length: ${body.toByteArray().size}\r\n" +
"Cache-Control: no-store\r\nConnection: close\r\n\r\n$body"
conn.getOutputStream().write(response.toByteArray())
}
val response = "HTTP/1.1 200 OK\r\nContent-Type: $type\r\n" +
"Content-Length: ${body.toByteArray().size}\r\n" +
"Cache-Control: no-store\r\nConnection: close\r\n\r\n$body"
socket.getOutputStream().write(response.toByteArray())
}
}
} catch (_: SocketException) {
Expand Down Expand Up @@ -184,10 +190,39 @@ class DashboardThemePaintTest {
stylesheetMissing.set(false)
themeSettleFailsafeMs = 1_000
releaseStylesheet.set(CountDownLatch(1))
evaluate(webView, "location.href = '/'")
await { stylesheetRequests.get() == 3 }
await { evaluate(webView, "document.readyState") == "\"complete\"" }
await { evaluate(webView, "window.__awAndroidThemeState()") == "\"pending\"" }
evaluate(webView, "location.href = '$url'")
// Wait until the WebView has actually navigated to `/` before
// polling the theme state. Evaluating on the unloading /early
// document returns undefined for __awAndroidThemeState, which
// would cause the final await to time out spuriously.
// Pathname, not full href: Chromium may omit the trailing slash
// that `$url` includes, which would fail an exact href match
// after a successful navigation.
var lastHref = "unset"
var lastPath = "unset"
try {
await("dashboard pathname /") {
lastHref = evaluate(webView, "location.href")
lastPath = evaluate(webView, "location.pathname")
lastPath == "\"/\""
}
} catch (e: AssertionError) {
throw AssertionError(
"Timed out waiting for dashboard pathname / " +
"(href=$lastHref pathname=$lastPath " +
"requests=${stylesheetRequests.get()})",
e
)
}
await("held dark.css after dashboard navigation") {
stylesheetRequests.get() >= 3
}
await("dashboard document complete") {
evaluate(webView, "document.readyState") == "\"complete\""
}
await("theme pending on held stylesheet") {
evaluate(webView, "window.__awAndroidThemeState()") == "\"pending\""
}
// Past every polling attempt plus scheduling margin, not just
// the first extension. The old cap revealed this light page.
Thread.sleep(themeSettleFailsafeMs * (THEME_SETTLE_MAX_EXTENSIONS + 1) + 1_500)
Expand Down Expand Up @@ -241,13 +276,13 @@ class DashboardThemePaintTest {
return result
}

private fun await(condition: () -> Boolean) {
private fun await(what: String = "WebView state", condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(25)
}
throw AssertionError("Timed out waiting for WebView state")
throw AssertionError("Timed out waiting for $what")
}

private companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package net.activitywatch.android

import android.webkit.WebView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.net.ServerSocket
import java.net.SocketException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread

/** Runs the actual hook in WebView with link creation explicitly before injection. */
@RunWith(AndroidJUnit4::class)
class WebUiThemeHookTest {
@Test
fun preInstallInFlightStylesheetWaitsForItsResponse() {
val requested = CountDownLatch(1)
val release = CountDownLatch(1)
val server = ServerSocket(0)
val responder = thread(isDaemon = true) {
try {
while (!server.isClosed) {
server.accept().use { socket ->
val input = socket.getInputStream().bufferedReader()
while (!input.readLine().isNullOrEmpty()) { }
requested.countDown()
check(release.await(15, TimeUnit.SECONDS))
val css = "body { background: rgb(15, 19, 26); }"
socket.getOutputStream().write((
"HTTP/1.1 200 OK\r\nContent-Type: text/css\r\n" +
"Content-Length: ${css.length}\r\nConnection: close\r\n\r\n$css"
).toByteArray())
}
}
} catch (_: SocketException) {
// Server closed by cleanup.
}
}
try {
withPage(baseUrl = "http://127.0.0.1:${server.localPort}/") { view ->
evaluate(view, """
var link = document.createElement('link');
link.rel = 'stylesheet'; link.href = 'http://127.0.0.1:${server.localPort}/dark.css';
document.head.appendChild(link);
""".trimIndent())
assertTrue(requested.await(5, TimeUnit.SECONDS))
evaluate(view, ANDROID_THEME_HOOK_JS)
assertEquals("\"pending\"", evaluate(view, "window.__awAndroidThemeState()"))
assertEquals("0", evaluate(view, "window.reports"))
assertEquals("\"rgb(247, 250, 252)\"", evaluate(view, "getComputedStyle(document.body).backgroundColor"))
release.countDown()
await { evaluate(view, "window.reports") == "1" }
assertEquals("\"rgb(15, 19, 26)\"", evaluate(view, "getComputedStyle(document.body).backgroundColor"))
}
} finally {
release.countDown()
server.close()
responder.join(1000)
}
}

@Test
fun preInstallFailureSettlesAfterTimingEviction() {
withPage { view ->
evaluate(view, """
window.failed = false;
var link = document.createElement('link');
link.rel = 'stylesheet'; link.href = 'http://127.0.0.1:1/dark.css';
link.onerror = function () { window.failed = true; };
document.head.appendChild(link);
""".trimIndent())
await { evaluate(view, "window.failed") == "true" }
// An already-fired error cannot be observed by the hook's listener.
// Resource Timing entries may have been cleared or evicted as well.
evaluate(view, "performance.clearResourceTimings()")
assertEquals("0", evaluate(view, "performance.getEntriesByName(link.href, 'resource').length"))

assertEquals("0", evaluate(view, "window.reports"))
evaluate(view, ANDROID_THEME_HOOK_JS)
await { evaluate(view, "window.reports") == "1" }
Comment thread
TimeToBuildBob marked this conversation as resolved.
Resolved
assertEquals("\"settled\"", evaluate(view, "window.__awAndroidThemeState()"))
}
}

private fun withPage(baseUrl: String = "http://127.0.0.1/", test: (WebView) -> Unit) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
lateinit var view: WebView
instrumentation.runOnMainSync {
view = WebView(instrumentation.targetContext)
view.settings.javaScriptEnabled = true
view.loadDataWithBaseURL(baseUrl, """
<!doctype html><html><head>
<style>body { background: rgb(247, 250, 252); }</style></head>
<body><div id="app"><div id="wrapper">dashboard</div></div><script>
window.reports = 0;
window.Android = { reportColorScheme: function () {},
reportThemeSettled: function () { window.reports++; } };
</script></body></html>
""".trimIndent(), "text/html", "UTF-8", null)
}
try {
await { evaluate(view, "document.readyState === 'complete' && typeof window.reports === 'number'") == "true" }
test(view)
} finally {
instrumentation.runOnMainSync { view.destroy() }
}
}

private fun evaluate(view: WebView, script: String): String {
val done = CountDownLatch(1)
var result = ""
InstrumentationRegistry.getInstrumentation().runOnMainSync {
view.evaluateJavascript(script) { result = it; done.countDown() }
}
assertTrue(done.await(3, TimeUnit.SECONDS))
return result
}

private fun await(condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(25)
}
throw AssertionError("Timed out waiting for theme hook")
}
}
26 changes: 20 additions & 6 deletions mobile/src/main/java/net/activitywatch/android/WebUiTheme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,6 @@ internal val ANDROID_THEME_HOOK_JS = """
// The sheet exists but its rules are unreadable (cross-origin): loaded.
return true;
}
if (link.__awPresentAtHookInstall && document.readyState === 'complete') {
// Present before this hook ran and the document finished loading, so the
// link has already loaded or failed. A null sheet means it failed and the
// readable light fallback must reveal now.
return true;
}
if (!link.__awWatched) {
link.__awWatched = true;
var done = function () {
Expand All @@ -92,6 +86,26 @@ internal val ANDROID_THEME_HOOK_JS = """
link.addEventListener('load', done);
link.addEventListener('error', done);
}
if (link.__awPresentAtHookInstall && document.readyState === 'complete') {
// Present before this hook ran and the document finished loading. A link
// that was added before the load event has already dispatched load/error
// (our listener above will never fire). However, a link appended in a
// post-load fetch callback may still be in-flight when this hook runs.
// PerformanceResourceTiming's responseEnd distinguishes the two cases:
// > 0 means the response was received before the hook; treat a null
// sheet as "failed" and reveal the readable fallback now.
try {
var entries = performance.getEntriesByName(link.href, 'resource');
if (entries.length > 0 && entries[entries.length - 1].responseEnd > 0) {
return true;
}
// Request still in-flight; the listener above will fire on completion.
return false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ P2 — An empty timing list does not establish that the stylesheet is still loading. For example, a document with CSP style-src 'none' can reject dark.css and dispatch its error before hook installation without creating a resource timing entry. The link then has no sheet, getEntriesByName returns [], and this branch returns false. Because the newly attached error listener missed the event, checkSettled never reports settlement and the readable fallback remains gated despite the stylesheet having definitively failed.

@TimeToBuildBob TimeToBuildBob Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up at c0d5b95: added and ran preInstallFailureSettlesAfterTimingEviction in a real WebView (API 36, WebView 133.0.6943.137). It waits for the link error before injecting the hook, clears Resource Timing, verifies no matching entries and zero prior reports, then observes settlement. This passes on unchanged production code. A CSP trial on the same WebView also settled; it produced a timing entry and did not satisfy the claimed null-sheet premise. The new test does not prove behavior on every supported WebView, so I am leaving this finding open rather than claiming a production repair. The local reviewer repeated that this test would time out, but the actual instrumentation run passed (2/2); no speculative early-reveal fallback was added. Next verification is the API 29 CI run and, if needed, an executable null-sheet/no-entry reproduction on an affected WebView.

} catch (e) {
// performance API unavailable; fall back: treat null sheet as failed.
return true;
}
}
return false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ class WebUiThemeTest {
// failed and the light fallback must reveal without waiting.
assertTrue(ANDROID_THEME_HOOK_JS.contains("__awPresentAtHookInstall"))
assertTrue(ANDROID_THEME_HOOK_JS.contains("document.readyState === 'complete'"))
// PerformanceResourceTiming guards against the race where a link is
// appended dynamically (post-load) but still in-flight when the hook
// runs: responseEnd > 0 confirms the request completed before the hook.
assertTrue(ANDROID_THEME_HOOK_JS.contains("getEntriesByName"))
assertTrue(ANDROID_THEME_HOOK_JS.contains("responseEnd"))
Comment thread
TimeToBuildBob marked this conversation as resolved.
Resolved
}

@Test
Expand Down
Loading