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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ configure<PublishingExtension> {

dependencies {
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.webkit:webkit:1.13.0")

androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:runner:1.5.2")
Expand Down
18 changes: 18 additions & 0 deletions app/src/androidTest/assets/test_page_early_request.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script>
// Fires immediately during HTML parsing — before onPageStarted() can inject the
// interception hooks via evaluateJavascript() on older devices.
fetch('https://example.com/api/early-fetch', {
method: 'POST',
body: '{"early":true}',
headers: {
'Content-Type': 'application/json'
}
});
</script>
</head>
<body></body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.acsbendi.requestinspectorwebview

import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import android.webkit.WebView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.webkit.WebViewFeature
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

@RunWith(AndroidJUnit4::class)
class EarlyRequestTest {

private lateinit var webView: WebView
private lateinit var matcher: CapturingRequestMatcher

@After
fun tearDown() {
InstrumentationRegistry.getInstrumentation().runOnMainSync { webView.destroy() }
}

/**
* Verifies that a fetch() fired by an inline <script> — which executes before
* onPageStarted() can inject the interceptor via evaluateJavascript() — is still
* recorded with full JS details when DOCUMENT_START_SCRIPT is supported.
*
* The 500 ms delay in onPageStarted() forces the race deterministically: inline
* scripts always fire before the fallback evaluateJavascript() injection runs.
* The test therefore only passes when the fix registers the hooks at document
* creation time via addDocumentStartJavaScript() in the constructor, before any
* page script executes.
*/
@SuppressLint("SetJavaScriptEnabled")
@Test
fun earlyFetch_withDelayedFallbackInjection_isCapturedByDocumentStartScript() {
assumeTrue(
"Skipping: DOCUMENT_START_SCRIPT not supported on this device",
WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
)

matcher = CapturingRequestMatcher()
val earlyRequestLatch = matcher.expectRequest()
val pageFinishedLatch = CountDownLatch(1)

InstrumentationRegistry.getInstrumentation().runOnMainSync {
val context = InstrumentationRegistry.getInstrumentation().targetContext
webView = WebView(context)

webView.webViewClient = object : RequestInspectorWebViewClient(webView, matcher) {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
// Delay the evaluateJavascript fallback injection so that inline
// page scripts always fire before the hooks could be installed that
// way. On devices with DOCUMENT_START_SCRIPT the hooks were already
// registered in the constructor, so this delay is irrelevant there.
Handler(Looper.getMainLooper()).postDelayed({
super.onPageStarted(view, url, favicon)
}, 500)
}

override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
pageFinishedLatch.countDown()
}
}
webView.loadUrl("file:///android_asset/test_page_early_request.html")
}

assertTrue("Page failed to load", pageFinishedLatch.await(15, TimeUnit.SECONDS))
assertTrue(
"Early fetch JS details were not recorded — hooks were not installed before inline scripts ran",
earlyRequestLatch.await(5, TimeUnit.SECONDS)
)

val req = matcher.lastRequest!!
assertEquals(WebViewRequestType.FETCH, req.type)
assertEquals("https://example.com/api/early-fetch", req.url.toString())
assertEquals("POST", req.method)
assertEquals("{\"early\":true}", req.body)
assertEquals("application/json", req.headers["content-type"])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import androidx.core.net.toUri
import androidx.webkit.WebViewCompat
import com.acsbendi.requestinspectorwebview.matcher.RequestMatcher
import org.intellij.lang.annotations.Language
import org.json.JSONArray
Expand Down Expand Up @@ -410,6 +411,18 @@ window.fetch = function () {
}
"""

/**
* Registers the request interception JavaScript to run at document creation time,
* before any page scripts execute. Requires WebViewFeature.DOCUMENT_START_SCRIPT to be supported.
*/
fun registerDocumentStartScript(webView: WebView, extraJavaScriptToInject: String) {
WebViewCompat.addDocumentStartJavaScript(
webView,
"$JAVASCRIPT_INTERCEPTION_CODE\n$extraJavaScriptToInject",
setOf("*")
)
}

fun enabledRequestInspection(webView: WebView, extraJavaScriptToInject: String) {
webView.evaluateJavascript(
"javascript: $JAVASCRIPT_INTERCEPTION_CODE\n$extraJavaScriptToInject",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.WebViewFeature
import com.acsbendi.requestinspectorwebview.matcher.RequestMatcher
import com.acsbendi.requestinspectorwebview.matcher.RequestUrlMatcher

Expand All @@ -19,16 +20,24 @@ open class RequestInspectorWebViewClient @JvmOverloads constructor(

private val interceptionJavascriptInterface = RequestInspectorJavaScriptInterface(webView, matcher)

private val isDocumentStartScriptSupported = WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)

init {
val webSettings = webView.settings
webSettings.javaScriptEnabled = true
webSettings.domStorageEnabled = true
if (isDocumentStartScriptSupported) {
RequestInspectorJavaScriptInterface.registerDocumentStartScript(webView, options.extraJavaScriptToInject)
}
}

final override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
if (request.isForMainFrame) {
matcher.onLoadMainFrame(request.url.toString())
}
Comment thread
maxeoneo marked this conversation as resolved.
val webViewRequest = interceptionJavascriptInterface.createWebViewRequest(request)
return shouldInterceptRequest(view, webViewRequest)
}
Expand All @@ -48,11 +57,12 @@ open class RequestInspectorWebViewClient @JvmOverloads constructor(

override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
Log.i(LOG_TAG, "Page started loading, enabling request inspection. URL: $url")
matcher.onPageStarted(url)
RequestInspectorJavaScriptInterface.enabledRequestInspection(
view,
options.extraJavaScriptToInject
)
if (!isDocumentStartScriptSupported) {
RequestInspectorJavaScriptInterface.enabledRequestInspection(
view,
options.extraJavaScriptToInject
)
}
super.onPageStarted(view, url, favicon)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@ import java.util.UUID
* request checking for allowed headers. Even when cleaning up the headers after the request is matched with it's body,
* the CORS request will fail because the browser engine only knows about the adapted header and doesn't execute the
* CORS request, because the preflight check doesn't return the custom header as allowed.
*
* This matcher tracks the current page's origin, so each instance must be used by only one WebView.
* Sharing the same instance across multiple WebViews will cause their requests to interfere with each other.
*/
class GeneratedUuidInHeaderRequestMatcher() : GeneratedUuidRequestMatcher() {

// @Volatile ensures writes in onLoadMainFrame are visible to getAdditionalHeaders when called
// from different threads
@Volatile
Comment thread
maxeoneo marked this conversation as resolved.
private var origin: String = ""

override fun getUuidFromRequest(recordedRequest: RecordedRequest): String? =
Expand Down Expand Up @@ -49,7 +55,7 @@ class GeneratedUuidInHeaderRequestMatcher() : GeneratedUuidRequestMatcher() {
return headersJson
}

override fun onPageStarted(url: String) {
override fun onLoadMainFrame(url: String) {
origin = getOrigin(url)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ abstract class GeneratedUuidRequestMatcher : RequestMatcher {
return recordedRequest
}

override fun onPageStarted(url: String) {}
override fun onLoadMainFrame(url: String) {}

companion object {
const val REQUEST_INSPECTOR_ID = "x-request-inspector-id"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ interface RequestMatcher {
fun createWebViewRequest(request: WebResourceRequest): WebViewRequest
fun getAdditionalHeaders(url: String): JSONObject = JSONObject()
fun getAdditionalQueryParams(): String = ""
fun onLoadMainFrame(url: String) {
// Delegate to onPageStarted() so that existing implementations overriding the deprecated
// method continue to work without modification.
@Suppress("DEPRECATION")
onPageStarted(url)
}

@Deprecated(
message = "Renamed to onLoadMainFrame(). Override onLoadMainFrame() instead.",
replaceWith = ReplaceWith("onLoadMainFrame(url)")
)
fun onPageStarted(url: String) {}
}