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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,28 @@ To manually process requests:
}
```

For both cases you can choose between different strategies for how the recorded requests (including
the body) and the intercepted requests are matched together. By default, only the url is used. If
you want to use a different strategy, for example if you have parallel requests to the same url with
different bodies (e.g. GraphQL queries), you can pass a custom `RequestMatcher` to the constructor
of `RequestInspectorWebViewClient`:

```kotlin
val webView = WebView(this)
webView.webViewClient = RequestInspectorWebViewClient(
webView,
matcher = RequestUuidInHeaderMatcher()
)
```

Currently available matchers are `RequestUuidInHeaderMatcher` and `RequestUuidInUrlMatcher`, which
both create an UUID and add it to the request before it's recorded and sent. They only differ by how
the attach the UUID to the request, as an additional header or as an additional query param. But
both clean up the request before it's been sent.

If you want to implement your own matching strategy, you can implement the `RequestMatcher`
interface and pass an instance of it to the constructor of `RequestInspectorWebViewClient`.

Known limitations
===

Expand Down
15 changes: 12 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ android {

defaultConfig {
minSdk = 21
targetSdk = 31
testOptions {
targetSdk = 31
}
lint {
targetSdk = 31
}

version = currentVersion
}
Expand All @@ -28,8 +33,8 @@ android {
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

publishing {
Expand All @@ -52,3 +57,7 @@ publishing {
}
}
}

dependencies {
implementation("androidx.core:core-ktx:1.17.0")
}
Original file line number Diff line number Diff line change
@@ -1,38 +1,31 @@
package com.acsbendi.requestinspectorwebview

import android.net.Uri
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import androidx.core.net.toUri
import com.acsbendi.requestinspectorwebview.matcher.RequestMatcher
import org.intellij.lang.annotations.Language
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.net.URLEncoder

internal class RequestInspectorJavaScriptInterface(webView: WebView) {
class RequestInspectorJavaScriptInterface(webView: WebView, val matcher: RequestMatcher) {

init {
webView.addJavascriptInterface(this, INTERFACE_NAME)
}

private val recordedRequests = ArrayList<RecordedRequest>()

fun findRecordedRequestForUrl(url: String): RecordedRequest? {
return synchronized(recordedRequests) {
// use findLast instead of find to find the last added query matching a URL -
// they are included at the end of the list when written.
recordedRequests.findLast { recordedRequest ->
// Added search by exact URL to find the actual request body
url == recordedRequest.url
} ?: recordedRequests.findLast { recordedRequest ->
// Previously, there was only a search by contains, and because of this, sometimes the wrong request body was found
url.contains(recordedRequest.url)
}
}
fun createWebViewRequest(request: WebResourceRequest): WebViewRequest {
return matcher.createWebViewRequest(request)
}

data class RecordedRequest(
val type: WebViewRequestType,
val url: String,
val url: Uri,
val method: String,
val body: String,
val formParameters: Map<String, String>,
Expand Down Expand Up @@ -80,7 +73,7 @@ internal class RequestInspectorJavaScriptInterface(webView: WebView) {
addRecordedRequest(
RecordedRequest(
WebViewRequestType.FORM,
url,
url.toUri(),
method,
body,
formParameterMap,
Expand All @@ -98,7 +91,7 @@ internal class RequestInspectorJavaScriptInterface(webView: WebView) {
addRecordedRequest(
RecordedRequest(
WebViewRequestType.XML_HTTP,
url,
url.toUri(),
method,
body,
mapOf(),
Expand All @@ -116,7 +109,7 @@ internal class RequestInspectorJavaScriptInterface(webView: WebView) {
addRecordedRequest(
RecordedRequest(
WebViewRequestType.FETCH,
url,
url.toUri(),
method,
body,
mapOf(),
Expand All @@ -127,14 +120,28 @@ internal class RequestInspectorJavaScriptInterface(webView: WebView) {
)
}

@JavascriptInterface
fun getAdditionalHeaders(url: String): String {
return matcher.getAdditionalHeaders(url).toString()
}

@JavascriptInterface
fun getAdditionalQueryParam(): String {
return matcher.getAdditionalQueryParams()
}

private fun addRecordedRequest(recordedRequest: RecordedRequest) {
synchronized(recordedRequests) {
recordedRequests.add(recordedRequest)
}
matcher.addRecordedRequest(recordedRequest)
}

private fun getHeadersAsMap(headersString: String): MutableMap<String, String> {
val headersObject = JSONObject(headersString)
val headersObject = try {
JSONObject(headersString)
} catch (_: JSONException) {
// When during the creation of a JSONObject from the string a JSONException is thrown, we simply return an
// empty JSONObject. This happens e.g. when JS send "undefined" or an empty string as headers.
JSONObject()
}
val map = HashMap<String, String>()
for (key in headersObject.keys()) {
val lowercaseHeader = key.lowercase()
Expand All @@ -158,7 +165,6 @@ internal class RequestInspectorJavaScriptInterface(webView: WebView) {
return map
}


private fun getUrlEncodedFormBody(formParameterJsonArray: JSONArray): String {
val resultStringBuilder = StringBuilder()
repeat(formParameterJsonArray.length()) { i ->
Expand Down Expand Up @@ -250,6 +256,22 @@ function getFullUrl(url) {
}
}

function appendAdditionalQueryParams(url) {
try {
var extraQueryParam = $INTERFACE_NAME.getAdditionalQueryParam();
if (extraQueryParam) {
if (url.indexOf('?') === -1) {
url += '?' + extraQueryParam;
} else {
url += '&' + extraQueryParam;
}
}
} catch (e) {
console.warn('Failed to inject query param from Kotlin:', e);
}
return url;
}

function recordFormSubmission(form) {
var jsonArr = [];
for (i = 0; i < form.elements.length; i++) {
Expand All @@ -270,7 +292,7 @@ function recordFormSubmission(form) {

const path = form.attributes['action'] === undefined ? "/" : form.attributes['action'].nodeValue;
const method = form.attributes['method'] === undefined ? "GET" : form.attributes['method'].nodeValue;
const url = getFullUrl(path);
const url = appendAdditionalQueryParams(getFullUrl(path));
const encType = form.attributes['enctype'] === undefined ? "application/x-www-form-urlencoded" : form.attributes['enctype'].nodeValue;
const err = new Error();
$INTERFACE_NAME.recordFormSubmission(
Expand Down Expand Up @@ -302,9 +324,9 @@ let xmlhttpRequestUrl = null;
XMLHttpRequest.prototype._open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
lastXmlhttpRequestPrototypeMethod = method;
xmlhttpRequestUrl = url;
xmlhttpRequestUrl = appendAdditionalQueryParams(url);
const asyncWithDefault = async === undefined ? true : async;
this._open(method, url, asyncWithDefault, user, password);
this._open(method, xmlhttpRequestUrl, asyncWithDefault, user, password);
};
XMLHttpRequest.prototype._setRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
Expand All @@ -314,7 +336,18 @@ XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
XMLHttpRequest.prototype._send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
const err = new Error();
const url = getFullUrl(xmlhttpRequestUrl);
let url = getFullUrl(xmlhttpRequestUrl);
// Inject headers from Kotlin if any
try {
var extraHeaders = JSON.parse($INTERFACE_NAME.getAdditionalHeaders(url));
for (var h in extraHeaders) {
if (extraHeaders.hasOwnProperty(h)) {
this.setRequestHeader(h, extraHeaders[h]);
}
}
} catch (e) {
console.warn('Failed to inject headers from Kotlin (XHR):', e);
}
$INTERFACE_NAME.recordXhr(
url,
lastXmlhttpRequestPrototypeMethod,
Expand All @@ -331,22 +364,39 @@ XMLHttpRequest.prototype.send = function (body) {
window._fetch = window.fetch;
window.fetch = function () {
const firstArgument = arguments[0];
let url;
let method;
let body;
let headers;
let url, method, body, headers;
if (typeof firstArgument === 'string') {
url = firstArgument;
url = appendAdditionalQueryParams(firstArgument);
if (!arguments[1]) arguments[1] = {};
method = arguments[1] && 'method' in arguments[1] ? arguments[1]['method'] : "GET";
body = arguments[1] && 'body' in arguments[1] ? arguments[1]['body'] : "";
headers = JSON.stringify(arguments[1] && 'headers' in arguments[1] ? arguments[1]['headers'] : {});
headers = arguments[1] && 'headers' in arguments[1] ? arguments[1]['headers'] : {};
// Inject headers from Kotlin if any
try {
var extraHeaders = JSON.parse($INTERFACE_NAME.getAdditionalHeaders(url));
arguments[1].headers = Object.assign({}, extraHeaders, headers || {});
} catch (e) {
console.warn('Failed to inject headers from Kotlin (fetch):', e);
}
arguments[0] = url;
} else {
// Request object
url = firstArgument.url;
url = appendAdditionalQueryParams(firstArgument.url);
method = firstArgument.method;
body = firstArgument.body;
headers = JSON.stringify(Object.fromEntries(firstArgument.headers.entries()));
headers = Object.fromEntries(firstArgument.headers.entries());
// Inject headers from Kotlin if any
try {
var extraHeaders = JSON.parse($INTERFACE_NAME.getAdditionalHeaders(url));
for (var h in extraHeaders) {
firstArgument.headers.set ? firstArgument.headers.set(h, extraHeaders[h]) : firstArgument.headers[h] = extraHeaders[h];
}
} catch (e) {
console.warn('Failed to inject headers from Kotlin (fetch):', e);
}
firstArgument.url = url;
}

const fullUrl = getFullUrl(url);
const err = new Error();
$INTERFACE_NAME.recordFetch(fullUrl, method, body, headers, err.stack);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import com.acsbendi.requestinspectorwebview.matcher.RequestMatcher
import com.acsbendi.requestinspectorwebview.matcher.RequestUrlMatcher

@SuppressLint("SetJavaScriptEnabled")
open class RequestInspectorWebViewClient @JvmOverloads constructor(
webView: WebView,
val matcher: RequestMatcher = RequestUrlMatcher(),
private val options: RequestInspectorOptions = RequestInspectorOptions()
) : WebViewClient() {

private val interceptionJavascriptInterface = RequestInspectorJavaScriptInterface(webView)
private val interceptionJavascriptInterface = RequestInspectorJavaScriptInterface(webView, matcher)

init {
val webSettings = webView.settings
Expand All @@ -26,10 +29,7 @@ open class RequestInspectorWebViewClient @JvmOverloads constructor(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
val recordedRequest = interceptionJavascriptInterface.findRecordedRequestForUrl(
request.url.toString()
)
val webViewRequest = WebViewRequest.create(request, recordedRequest)
val webViewRequest = interceptionJavascriptInterface.createWebViewRequest(request)
return shouldInterceptRequest(view, webViewRequest)
}

Expand All @@ -48,6 +48,7 @@ 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.acsbendi.requestinspectorwebview.matcher

import com.acsbendi.requestinspectorwebview.RequestInspectorJavaScriptInterface.RecordedRequest
import android.webkit.WebResourceRequest
import com.acsbendi.requestinspectorwebview.WebViewRequest
import org.json.JSONObject

interface RequestMatcher {
fun addRecordedRequest(recordedRequest: RecordedRequest)
fun createWebViewRequest(request: WebResourceRequest): WebViewRequest
fun getAdditionalHeaders(url: String): JSONObject = JSONObject()
fun getAdditionalQueryParams(): String = ""
fun onPageStarted(url: String) {}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.acsbendi.requestinspectorwebview.matcher

import com.acsbendi.requestinspectorwebview.RequestInspectorJavaScriptInterface.RecordedRequest
import android.webkit.WebResourceRequest
import com.acsbendi.requestinspectorwebview.WebViewRequest

class RequestUrlMatcher : RequestMatcher {
private val recordedRequests = ArrayList<RecordedRequest>()

override fun addRecordedRequest(recordedRequest: RecordedRequest) {
synchronized(recordedRequests) {
recordedRequests.add(recordedRequest)
}
}

override fun createWebViewRequest(request: WebResourceRequest): WebViewRequest {
val recordedRequest = findRecordedRequest(request)
return WebViewRequest.create(request, recordedRequest)
}

private fun findRecordedRequest(request: WebResourceRequest): RecordedRequest? {
return synchronized(recordedRequests) {
val url = request.url.toString()
// use findLast instead of find to find the last added query matching a URL -
// they are included at the end of the list when written.
recordedRequests.findLast { recordedRequest ->
// Added search by exact URL to find the actual request body
url == recordedRequest.url.toString()
} ?: recordedRequests.findLast { recordedRequest ->
// Previously, there was only a search by contains, and because of this, sometimes the wrong request body was found
url.contains(recordedRequest.url.toString())
}
}
}
}
Loading