Repository navigation
fix(theme): guard against post-load dark.css in-flight race and navigation unload #381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
7e7bcee
c0d5b95
b3033f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" } | ||
| 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") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 () { | ||
|
|
@@ -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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.