diff --git a/.changes/fix-android-body.md b/.changes/fix-android-body.md new file mode 100644 index 000000000..c87dae28d --- /dev/null +++ b/.changes/fix-android-body.md @@ -0,0 +1,5 @@ +--- +"wry": patch +--- + +Fix empty custom protocol body on Android. diff --git a/src/android/binding.rs b/src/android/binding.rs index 8e7156834..7f5e88b6a 100644 --- a/src/android/binding.rs +++ b/src/android/binding.rs @@ -39,7 +39,7 @@ macro_rules! android_binding { $package, RustWebViewClient, handleRequest, - [JString, JObject, jboolean], + [JString, JObject, JString, jboolean], jobject ); android_fn!( @@ -104,6 +104,7 @@ fn handle_request( env: &mut JNIEnv, webview_id: JString, request: JObject, + body: JString, is_document_start_script_enabled: jboolean, ) -> JniResult { if let Some(handler) = REQUEST_HANDLER.lock().unwrap().as_ref() { @@ -157,11 +158,26 @@ fn handle_request( } } - let final_request = match request_builder.body(Vec::new()) { + let body = env.get_string(&body)?; + let body = body.to_str().ok().unwrap_or_default(); + let body = match body.chars().next() { + Some('s') => body.chars().skip(1).map(|c| c as u8).collect(), + Some('a') => { + let array_str = body.chars().skip(1).collect::(); + array_str + .split(',') + .map(|s| s.parse::().unwrap()) + .collect() + } + None => Vec::new(), + _ => panic!("unexpected Android body format: {body}"), + }; + + let final_request = match request_builder.body(body) { Ok(req) => req, - Err(e) => { + Err(_e) => { #[cfg(feature = "tracing")] - tracing::warn!("Failed to build response: {}", e); + tracing::warn!("Failed to build response: {_e}"); return Ok(*JObject::null()); } }; @@ -190,9 +206,9 @@ fn handle_request( } else { None }; - if let Some(err) = status_err { + if let Some(_err) = status_err { #[cfg(feature = "tracing")] - tracing::warn!("{}", err); + tracing::warn!("{_err}"); return Ok(*JObject::null()); } @@ -271,18 +287,20 @@ pub unsafe fn handleRequest( _: JClass, webview_id: JString, request: JObject, + body: JString, is_document_start_script_enabled: jboolean, ) -> jobject { match handle_request( &mut env, webview_id, request, + body, is_document_start_script_enabled, ) { Ok(response) => response, - Err(e) => { + Err(_e) => { #[cfg(feature = "tracing")] - tracing::warn!("Failed to handle request: {}", e); + tracing::warn!("Failed to handle request: {_e}"); JObject::null().as_raw() } } @@ -304,9 +322,9 @@ pub unsafe fn shouldOverride(mut env: JNIEnv, _: JClass, url: JString) -> jboole .map(|f| !(f.handler)(url)) .unwrap_or(false) } - Err(e) => { + Err(_e) => { #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {}", e); + tracing::warn!("Failed to parse JString: {_e}"); false } } @@ -326,9 +344,9 @@ pub unsafe fn onEval(mut env: JNIEnv, _: JClass, id: jint, result: JString) { cb(result.into()); } } - Err(e) => { + Err(_e) => { #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {}", e); + tracing::warn!("Failed to parse JString: {_e}"); } } } @@ -345,9 +363,9 @@ pub unsafe fn ipc(mut env: JNIEnv, _: JClass, url: JString, body: JString) { (ipc.handler)(Request::builder().uri(url).body(body).unwrap()) } } - (Err(e), _) | (_, Err(e)) => { + (Err(_e), _) | (_, Err(_e)) => { #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {}", e) + tracing::warn!("Failed to parse JString: {_e}") } } } @@ -361,9 +379,9 @@ pub unsafe fn handleReceivedTitle(mut env: JNIEnv, _: JClass, _webview: JObject, (title_handler.handler)(title) } } - Err(e) => { + Err(_e) => { #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {}", e) + tracing::warn!("Failed to parse JString: {_e}") } } } @@ -391,9 +409,9 @@ pub unsafe fn onPageLoading(mut env: JNIEnv, _: JClass, url: JString) { (on_load.handler)(PageLoadEvent::Started, url) } } - Err(e) => { + Err(_e) => { #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {}", e) + tracing::warn!("Failed to parse JString: {_e}") } } } @@ -407,9 +425,9 @@ pub unsafe fn onPageLoaded(mut env: JNIEnv, _: JClass, url: JString) { (on_load.handler)(PageLoadEvent::Finished, url) } } - Err(e) => { + Err(_e) => { #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {}", e) + tracing::warn!("Failed to parse JString: {_e}") } } } diff --git a/src/android/kotlin/RequestInterceptor.kt b/src/android/kotlin/RequestInterceptor.kt new file mode 100644 index 000000000..96669b206 --- /dev/null +++ b/src/android/kotlin/RequestInterceptor.kt @@ -0,0 +1,172 @@ +// Copyright 2020-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT +// taken from https://github.com/acsbendi/Android-Request-Inspector-WebView +// Copyright 2022 Bendegúz Ács + +@file:Suppress("unused") + +package {{package}} + +import android.util.Log +import android.webkit.JavascriptInterface +import org.json.JSONArray +import org.json.JSONObject +import java.net.URLEncoder + +class RequestInterceptor { + private val interceptedRequests = HashMap() + + fun removeInterceptedRequest(id: String): RecordedRequest? { + return interceptedRequests.remove(id) + } + + data class RecordedRequest( + val url: String, + val body: String, + ) + + @JavascriptInterface + fun recordFormSubmission( + url: String, + formParameterList: String, + enctype: String? + ) { + val formParameterJsonArray = JSONArray(formParameterList) + + val body = when (enctype) { + "application/x-www-form-urlencoded" -> { + getUrlEncodedFormBody(formParameterJsonArray) + } + + "multipart/form-data" -> { + getMultiPartFormBody(formParameterJsonArray) + } + + "text/plain" -> { + getPlainTextFormBody(formParameterJsonArray) + } + + else -> { + Log.e("RequestInterceptor", "Incorrect encoding received from JavaScript: $enctype") + "" + } + } + + addRecordedRequest( + id, + RecordedRequest( + url.removeSuffix("/"), + body + ) + ) + } + + @JavascriptInterface + fun recordXhr(id: String, url: String, body: String) { + addRecordedRequest( + id, + RecordedRequest( + url, + body + ) + ) + } + + @JavascriptInterface + fun recordFetch(id: String, url: String, body: String) { + addRecordedRequest( + id, + RecordedRequest( + url, + body + ) + ) + } + + private fun addRecordedRequest(id: String, recordedRequest: RecordedRequest) { + interceptedRequests[id] = recordedRequest + } + + private fun getUrlEncodedFormBody(formParameterJsonArray: JSONArray): String { + val resultStringBuilder = StringBuilder() + repeat(formParameterJsonArray.length()) { i -> + val formParameter = formParameterJsonArray.get(i) as JSONObject + val name = formParameter.getString("name") + val value = formParameter.optString("value") + val checked = formParameter.optBoolean("checked") + val type = formParameter.optString("type") + val encodedValue = URLEncoder.encode(value, "UTF-8") + + if (!isExcludedFormParameter(type, checked)) { + if (i != 0) { + resultStringBuilder.append("&") + } + resultStringBuilder.append(name) + resultStringBuilder.append("=") + resultStringBuilder.append(encodedValue) + } + + + } + return resultStringBuilder.toString() + } + + private fun getMultiPartFormBody(formParameterJsonArray: JSONArray): String { + val resultStringBuilder = StringBuilder() + repeat(formParameterJsonArray.length()) { i -> + val formParameter = formParameterJsonArray.get(i) as JSONObject + val name = formParameter.getString("name") + val value = formParameter.optString("value") + val checked = formParameter.optBoolean("checked") + val type = formParameter.optString("type") + + if (!isExcludedFormParameter(type, checked)) { + resultStringBuilder.append("--") + resultStringBuilder.append(MULTIPART_FORM_BOUNDARY) + resultStringBuilder.append("\n") + resultStringBuilder.append("Content-Disposition: form-data; name=\"$name\"") + resultStringBuilder.append("\n\n") + resultStringBuilder.append(value) + resultStringBuilder.append("\n") + } + + } + resultStringBuilder.append("--") + resultStringBuilder.append(MULTIPART_FORM_BOUNDARY) + resultStringBuilder.append("--") + return resultStringBuilder.toString() + } + + private fun getPlainTextFormBody(formParameterJsonArray: JSONArray): String { + val resultStringBuilder = StringBuilder() + repeat(formParameterJsonArray.length()) { i -> + val formParameter = formParameterJsonArray.get(i) as JSONObject + val name = formParameter.getString("name") + val value = formParameter.optString("value") + val checked = formParameter.optBoolean("checked") + val type = formParameter.optString("type") + + if (!isExcludedFormParameter(type, checked)) { + if (i != 0) { + resultStringBuilder.append("\n") + } + resultStringBuilder.append(name) + resultStringBuilder.append("=") + resultStringBuilder.append(value) + } + + } + return resultStringBuilder.toString() + } + + private fun isExcludedFormParameter(type: String, checked: Boolean): Boolean { + return (type == "radio" || type == "checkbox") && !checked + } + + companion object { + private const val MULTIPART_FORM_BOUNDARY = "----WebKitFormBoundaryU7CgQs9WnqlZYKs6" + const val INTERFACE_NAME = "RequestInterceptor" + const val REQUEST_ID_HEADER_NAME = "wry-internal-request-id" + } +} diff --git a/src/android/kotlin/RustWebView.kt b/src/android/kotlin/RustWebView.kt index 8debc1c7a..ab85e7e28 100644 --- a/src/android/kotlin/RustWebView.kt +++ b/src/android/kotlin/RustWebView.kt @@ -16,6 +16,7 @@ import kotlin.collections.Map @SuppressLint("RestrictedApi") class RustWebView(context: Context, val initScripts: Array, val id: String): WebView(context) { val isDocumentStartScriptEnabled: Boolean + val requestInterceptor: RequestInterceptor init { settings.javaScriptEnabled = true @@ -34,6 +35,9 @@ class RustWebView(context: Context, val initScripts: Array, val id: Stri isDocumentStartScriptEnabled = false } + requestInterceptor = RequestInterceptor() + addJavascriptInterface(requestInterceptor, RequestInterceptor.INTERFACE_NAME) + {{class-init}} } diff --git a/src/android/kotlin/RustWebViewClient.kt b/src/android/kotlin/RustWebViewClient.kt index 343ad1490..78416a60b 100644 --- a/src/android/kotlin/RustWebViewClient.kt +++ b/src/android/kotlin/RustWebViewClient.kt @@ -39,8 +39,11 @@ class RustWebViewClient(context: Context): WebViewClient() { return if (withAssetLoader()) { assetLoader.shouldInterceptRequest(request.url) } else { - val rustWebview = view as RustWebView; - val response = handleRequest(rustWebview.id, request, rustWebview.isDocumentStartScriptEnabled) + val rustWebview = view as RustWebView + val interceptedRequest = rustWebview.requestInterceptor.removeInterceptedRequest( + request.requestHeaders.remove(RequestInterceptor.REQUEST_ID_HEADER_NAME) ?: request.url.toString().removeSuffix("/") + ) + val response = handleRequest(rustWebview.id, request, interceptedRequest?.body ?: "", rustWebview.isDocumentStartScriptEnabled) interceptedState[request.url.toString()] = response != null return response } @@ -96,7 +99,7 @@ class RustWebViewClient(context: Context): WebViewClient() { private external fun assetLoaderDomain(): String private external fun withAssetLoader(): Boolean - private external fun handleRequest(webviewId: String, request: WebResourceRequest, isDocumentStartScriptEnabled: Boolean): WebResourceResponse? + private external fun handleRequest(webviewId: String, request: WebResourceRequest, body: String, isDocumentStartScriptEnabled: Boolean): WebResourceResponse? private external fun shouldOverride(url: String): Boolean private external fun onPageLoading(url: String) private external fun onPageLoaded(url: String) diff --git a/src/android/main_pipe.rs b/src/android/main_pipe.rs index 5b81048ef..e67f1fe00 100644 --- a/src/android/main_pipe.rs +++ b/src/android/main_pipe.rs @@ -63,12 +63,17 @@ impl<'a> MainPipe<'a> { on_webview_created, autoplay, user_agent, - initialization_scripts, + mut initialization_scripts, id, javascript_disabled, .. } = attrs; + initialization_scripts.push(InitializationScript { + script: include_str!("request-interceptor.js").to_string(), + for_main_frame_only: false, + }); + let string_class = self.env.find_class("java/lang/String")?; let initialization_scripts_array = self.env.new_object_array( initialization_scripts.len() as i32, @@ -226,13 +231,13 @@ impl<'a> MainPipe<'a> { )?; if let Some(on_webview_created) = on_webview_created { - if let Err(e) = on_webview_created(super::Context { + if let Err(_e) = on_webview_created(super::Context { env: &mut self.env, activity, webview: &webview, }) { #[cfg(feature = "tracing")] - tracing::warn!("failed to run webview created hook: {e}"); + tracing::warn!("failed to run webview created hook: {_e}"); } } diff --git a/src/android/mod.rs b/src/android/mod.rs index 6f6805e0e..4ee720fdf 100644 --- a/src/android/mod.rs +++ b/src/android/mod.rs @@ -347,7 +347,7 @@ impl InnerWebView { Ok(()) } - pub fn id(&self) -> crate::WebViewId { + pub fn id(&self) -> crate::WebViewId<'_> { &self.id } @@ -416,12 +416,12 @@ impl InnerWebView { rx.recv_timeout(MAIN_PIPE_TIMEOUT).map_err(Into::into) } - pub fn set_cookie(&self, cookie: &cookie::Cookie<'_>) -> Result<()> { + pub fn set_cookie(&self, _cookie: &cookie::Cookie<'_>) -> Result<()> { // Unsupported Ok(()) } - pub fn delete_cookie(&self, cookie: &cookie::Cookie<'_>) -> Result<()> { + pub fn delete_cookie(&self, _cookie: &cookie::Cookie<'_>) -> Result<()> { // Unsupported Ok(()) } diff --git a/src/android/request-interceptor.js b/src/android/request-interceptor.js new file mode 100644 index 000000000..7f4288aeb --- /dev/null +++ b/src/android/request-interceptor.js @@ -0,0 +1,121 @@ +// Copyright 2020-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// taken from https://github.com/acsbendi/Android-Request-Inspector-WebView +// Copyright 2022 Bendegúz Ács + +(function() { + function getFullUrl(url) { + if (url.startsWith("/")) { + return location.protocol + '//' + location.host + url; + } + return url; + } + + function uid() { + return window.crypto.getRandomValues(new Uint32Array(1))[0].toString(); + } + + function recordFormSubmission(form) { + const path = form.attributes['action'] === undefined ? "/" : form.attributes['action'].nodeValue; + const url = getFullUrl(path); + + if (url.includes('.localhost')) { + const encType = form.attributes['enctype'] === undefined ? "application/x-www-form-urlencoded" : form.attributes['enctype'].nodeValue; + + const jsonArr = form.elements.map(el => ({ + name: el.name, + value: el.value, + type: el.type, + checked: el.checked, + id: el.id + })); + + window.RequestInterceptor.recordFormSubmission( + url, + JSON.stringify(jsonArr), + "{}", + encType + ); + } + } + + function handleFormSubmission(e) { + const form = e ? e.target : this; + recordFormSubmission(form); + form._submit(); + } + + HTMLFormElement.prototype._submit = HTMLFormElement.prototype.submit; + HTMLFormElement.prototype.submit = handleFormSubmission; + window.addEventListener('submit', function (submitEvent) { + const form = submitEvent ? submitEvent.target : this; + recordFormSubmission(form); + }, true); + + let xmlhttpRequestUrl = null; + let lastXmlhttpRequestPrototypeMethod = null; + XMLHttpRequest.prototype._open = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url, async, user, password) { + xmlhttpRequestUrl = url; + lastXmlhttpRequestPrototypeMethod = method; + const asyncWithDefault = async === undefined ? true : async; + this._open(method, url, asyncWithDefault, user, password); + }; + XMLHttpRequest.prototype._send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.send = function (body) { + const url = getFullUrl(xmlhttpRequestUrl); + if ((lastXmlhttpRequestPrototypeMethod === "POST" || lastXmlhttpRequestPrototypeMethod === "PUT" || lastXmlhttpRequestPrototypeMethod === "PATCH") && xmlhttpRequestUrl.includes('.localhost')) { + const id = uid(); + setRequestHeader('wry-internal-request-id', id); + window.RequestInterceptor.recordXhr( + id, + url, + serializeBody(body), + ); + } + xmlhttpRequestUrl = null; + lastXmlhttpRequestPrototypeMethod = null; + this._send(body); + }; + + const originalFetch = window.fetch; + window.fetch = function () { + const firstArgument = arguments[0]; + const [url, method] = typeof firstArgument === 'string' ? [firstArgument, arguments[1] && 'method' in arguments[1] ? arguments[1]['method'] : "GET"] : [firstArgument.url, firstArgument.method]; + const fullUrl = getFullUrl(url); + if ((method === "POST" || method === "PUT" || method === "PATCH") && url.includes('.localhost')) { + let body; + const id = uid(); + if (typeof firstArgument === 'string') { + body = arguments[1] && 'body' in arguments[1] ? arguments[1]['body'] : ""; + const headers = arguments[1] && 'headers' in arguments[1] ? arguments[1]['headers'] : {}; + headers['wry-internal-request-id'] = id; + } else { + // Request object + body = firstArgument.body; + const headers = firstArgument.headers; + headers['wry-internal-request-id'] = id; + } + window.RequestInterceptor.recordFetch(id, fullUrl, serializeBody(body)); + } + + return originalFetch.apply(this, arguments); + } + + function serializeBody(body) { + if (body === null || body === undefined) { + return ""; + } else if (typeof body === 'string') { + return 's' + body; + } else if (body instanceof Uint8Array) { + return 'a' + Array.from(body); + } else if (body instanceof ArrayBuffer) { + return 'a' + Array.from(new Uint8Array(body)); + } else if (Array.isArray(body) && body.every(item => typeof item === 'number')) { + return 'a' + body; + } + return 's' + JSON.stringify(body); + } +})(); diff --git a/src/webview2/mod.rs b/src/webview2/mod.rs index a2f5d5b99..d24f813ca 100644 --- a/src/webview2/mod.rs +++ b/src/webview2/mod.rs @@ -1351,7 +1351,7 @@ impl InnerWebView { /// Public APIs impl InnerWebView { - pub fn id(&self) -> crate::WebViewId { + pub fn id(&self) -> crate::WebViewId<'_> { &self.id }