From 002c0f7e536563b2555f040360bf5776320cf4ad Mon Sep 17 00:00:00 2001 From: Arjan Schrijver Date: Sun, 28 Dec 2025 22:38:30 +0100 Subject: [PATCH] Internet helper: Support forwarding POST requests --- .../activities/ExternalPebbleJSActivity.java | 5 +- .../appmanager/RebbleAppStoreActivity.kt | 5 +- .../banglejs/AppsManagementActivity.java | 8 +- .../devices/pebble/webview/PebbleJsService.kt | 7 +- .../gadgetbridge/webview/GBWebClient.java | 106 +++++++++++++++++- .../webview/RequestInterceptorInterface.kt | 31 +++++ 6 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/RequestInterceptorInterface.kt diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java index 93892d2933..19fe885148 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java @@ -52,6 +52,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; import nodomain.freeyourgadget.gadgetbridge.webview.GBChromeClient; import nodomain.freeyourgadget.gadgetbridge.webview.GBWebClient; +import nodomain.freeyourgadget.gadgetbridge.webview.RequestInterceptorInterface; public class ExternalPebbleJSActivity extends AbstractGBActivity { @@ -115,7 +116,8 @@ public class ExternalPebbleJSActivity extends AbstractGBActivity { setContentView(R.layout.activity_legacy_external_pebble_js); myWebView = findViewById(R.id.configureWebview); myWebView.clearCache(true); - myWebView.setWebViewClient(new GBWebClient(GBWebClient.REQUEST_TYPE_PEBBLE_APP_CONFIG)); + GBWebClient gbWebClient = new GBWebClient(GBWebClient.REQUEST_TYPE_PEBBLE_APP_CONFIG); + myWebView.setWebViewClient(gbWebClient); myWebView.setWebChromeClient(new GBChromeClient()); WebSettings webSettings = myWebView.getSettings(); //noinspection SetJavaScriptEnabled @@ -132,6 +134,7 @@ public class ExternalPebbleJSActivity extends AbstractGBActivity { JSInterface gbJSInterface = new JSInterface(device, uuid); myWebView.addJavascriptInterface(gbJSInterface, "GBjs"); myWebView.addJavascriptInterface(new ActivityJSInterface(), "GBActivity"); + myWebView.addJavascriptInterface(new RequestInterceptorInterface(gbWebClient), "GBReqInt"); myWebView.loadUrl("file:///android_asset/app_config/configure.html?rand=" + Math.random() * 500); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt index 954d32cae8..4014882ee1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt @@ -38,6 +38,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils import nodomain.freeyourgadget.gadgetbridge.webview.GBChromeClient import nodomain.freeyourgadget.gadgetbridge.webview.GBWebClient +import nodomain.freeyourgadget.gadgetbridge.webview.RequestInterceptorInterface import org.json.JSONObject import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -135,7 +136,7 @@ class RebbleAppStoreActivity : AbstractGBActivity() { settings.loadWithOverviewMode = true settings.useWideViewPort = true - webView!!.webViewClient = object : GBWebClient(REQUEST_TYPE_PEBBLE_APP_STORE) { + val gbWebClient = object : GBWebClient(REQUEST_TYPE_PEBBLE_APP_STORE) { override fun shouldOverrideUrlLoading( wv: WebView, request: WebResourceRequest @@ -170,6 +171,7 @@ class RebbleAppStoreActivity : AbstractGBActivity() { view.loadUrl("about:blank") } } + webView!!.webViewClient = gbWebClient webView!!.webChromeClient = object : GBChromeClient() { override fun onPermissionRequest(request: PermissionRequest) { @@ -177,6 +179,7 @@ class RebbleAppStoreActivity : AbstractGBActivity() { } } + webView!!.addJavascriptInterface(RequestInterceptorInterface(gbWebClient), "GBReqInt") webView!!.loadUrl(url) } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java index bc90ffd628..1612f3ec9a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java @@ -54,6 +54,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs.BangleJSDev import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.webview.GBChromeClient; import nodomain.freeyourgadget.gadgetbridge.webview.GBWebClient; +import nodomain.freeyourgadget.gadgetbridge.webview.RequestInterceptorInterface; public class AppsManagementActivity extends AbstractGBActivity { private static final Logger LOG = LoggerFactory.getLogger(AppsManagementActivity.class); @@ -218,7 +219,7 @@ public class AppsManagementActivity extends AbstractGBActivity { webView.addJavascriptInterface(new WebViewInterface(this), "Android"); webView.setWebContentsDebuggingEnabled(true); // FIXME - webView.setWebViewClient(new GBWebClient(GBWebClient.REQUEST_TYPE_BANGLE_APP_LOADER){ + GBWebClient gbWebClient = new GBWebClient(GBWebClient.REQUEST_TYPE_BANGLE_APP_LOADER){ @Override public void onPageFinished(WebView view, String weburl){ //webView.loadUrl("javascript:showToast('WebView in Espruino')"); @@ -229,7 +230,10 @@ public class AppsManagementActivity extends AbstractGBActivity { Toast.makeText(AppsManagementActivity.this, "Error:" + description, Toast.LENGTH_SHORT).show(); view.loadUrl("about:blank"); } - }); + }; + webView.setWebViewClient(gbWebClient); + + webView.addJavascriptInterface(new RequestInterceptorInterface(gbWebClient), "GBReqInt"); Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(mGBDevice.getAddress())); String url = devicePrefs.getString(PREF_BANGLEJS_WEBVIEW_URL, "").trim(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/PebbleJsService.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/PebbleJsService.kt index d87fda5687..6703ec985a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/PebbleJsService.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/PebbleJsService.kt @@ -33,6 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice import nodomain.freeyourgadget.gadgetbridge.util.GB import nodomain.freeyourgadget.gadgetbridge.webview.GBChromeClient import nodomain.freeyourgadget.gadgetbridge.webview.GBWebClient +import nodomain.freeyourgadget.gadgetbridge.webview.RequestInterceptorInterface import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.UUID @@ -102,6 +103,7 @@ class PebbleJsService : Service() { LOG.info("WEBVIEW starting for device ${device.address}") WebView.setWebContentsDebuggingEnabled(true) val uiContext = applicationContext.createConfigurationContext(resources.configuration) + val gbWebClient = GBWebClient(GBWebClient.REQUEST_TYPE_PEBBLE_BACKGROUND_JS) val wv = WebView(uiContext).apply { settings.javaScriptEnabled = true settings.domStorageEnabled = true @@ -111,10 +113,11 @@ class PebbleJsService : Service() { settings.allowFileAccessFromFileURLs = true settings.allowUniversalAccessFromFileURLs = true visibility = View.GONE - webViewClient = GBWebClient(GBWebClient.REQUEST_TYPE_PEBBLE_BACKGROUND_JS) + webViewClient = gbWebClient webChromeClient = GBChromeClient() + addJavascriptInterface(RequestInterceptorInterface(gbWebClient), "GBReqInt") + setWillNotDraw(true) } - wv.setWillNotDraw(true) wv.clearCache(true) wv.resumeTimers() webviews[device.address] = wv diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBWebClient.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBWebClient.java index c99f1a3f3f..2994c0745e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBWebClient.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBWebClient.java @@ -18,6 +18,7 @@ package nodomain.freeyourgadget.gadgetbridge.webview; import android.content.Intent; +import android.graphics.Bitmap; import android.net.Uri; import android.os.RemoteException; import android.webkit.WebResourceRequest; @@ -39,7 +40,10 @@ import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.time.LocalDate; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.HashMap; +import java.util.Iterator; +import java.util.List; import java.util.Map; import nodomain.freeyourgadget.gadgetbridge.GBApplication; @@ -65,11 +69,54 @@ public class GBWebClient extends WebViewClient { "rawgit.com", //for trekvolle }; + private final Map> postData = new HashMap<>(); + private record Entry(long timestamp, String body) {} + public GBWebClient(int type) { super(); requestType = type; } + /** + * Stores HTTP POST body strings for later retrieval in mimicReply() + */ + public synchronized void storePostBody(String url, String body) { + long now = System.currentTimeMillis(); + + // Cleanup expired entries (more than a minute old) + Iterator>> mapIt = postData.entrySet().iterator(); + while (mapIt.hasNext()) { + Map.Entry> mapEntry = mapIt.next(); + List list = mapEntry.getValue(); + list.removeIf(e -> now - e.timestamp > 60*1000); + if (list.isEmpty()) { + mapIt.remove(); + } + } + + // Add new entry + postData + .computeIfAbsent(url, k -> new ArrayList<>()) + .add(new Entry(now, body)); + } + + /** + * Returns the earliest known HTTP POST body for a certain URL + */ + private synchronized String getFirstPostForUrl(String url) { + List list = postData.get(url); + if (list == null || list.isEmpty()) { + return null; + } + + Entry first = list.remove(0); + if (list.isEmpty()) { + postData.remove(url); + } + + return first.body; + } + @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { Uri parsedUri = request.getUrl(); @@ -135,7 +182,13 @@ public class GBWebClient extends WebViewClient { if (!forceLocal && !directInternetAccess && InternetHelperSingleton.INSTANCE.ensureInternetHelperBound()) { LOG.debug("WEBVIEW forwarding request to the internet helper"); try { - WebResourceResponse wrr = InternetHelperSingleton.INSTANCE.send(requestedUri, HttpRequest.Method.valueOf(method), requestHeaders, null, "application/octet-stream", false); + HttpRequest.Method requestMethod = HttpRequest.Method.valueOf(method); + String body = null; + if (requestMethod == HttpRequest.Method.POST) { + body = getFirstPostForUrl(requestedUri.toString()); + LOG.debug("WEBVIEW POSTing with body: {}", body); + } + WebResourceResponse wrr = InternetHelperSingleton.INSTANCE.send(requestedUri, requestMethod, requestHeaders, body, "application/octet-stream", false); if (wrr != null && wrr.getStatusCode() < 400) return wrr; else @@ -192,6 +245,57 @@ public class GBWebClient extends WebViewClient { return shouldOverrideUrlLoading(view, request.getUrl().toString()); } + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + injectPostInterceptor(view); + } + + @Override + public void onPageFinished(WebView view, String url) { + injectPostInterceptor(view); + } + + private void injectPostInterceptor(WebView view) { + // Inject JavaScript for capturing POST body data, which we need when forwarding + // requests to the internet helper + view.evaluateJavascript( + """ + (function () { + if (window.__p) return; window.__p = 1; + const sb = b => b instanceof FormData + ? [...b].map(e => e.map(encodeURIComponent).join('=')).join('&') + : b ? b.toString() : null; + + const send = (u, b) => GBReqInt.onPostBody(new URL(u, location.href).toString(), sb(b)); + + const xo = XMLHttpRequest.prototype.open; + const xs = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (m, u) { + this._m = m; this._u = u; return xo.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function (b) { + if (this._m === 'POST') send(this._u, b); + return xs.apply(this, arguments); + }; + + const f = window.fetch; + window.fetch = function (i, o = {}) { + if ((o.method || 'GET') === 'POST') + send(typeof i === 'string' ? i : i.url, o.body); + return f.apply(this, arguments); + }; + + document.addEventListener('submit', e => { + const f = e.target; + if (f.method && f.method.toUpperCase() === 'POST') + send(f.action || location.href, new FormData(f)); + }, true); + })(); + """, + null + ); + } + private WebResourceResponse mimicRawGitResponse(String path) { if("/aHcVolle/TrekVolle/master/online.html".equals(path)) { //TrekVolle online check Map headers = new HashMap<>(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/RequestInterceptorInterface.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/RequestInterceptorInterface.kt new file mode 100644 index 0000000000..80a05ab75d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/RequestInterceptorInterface.kt @@ -0,0 +1,31 @@ +/* Copyright (C) 2025 Arjan Schrijver + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.webview + +import android.webkit.JavascriptInterface +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +class RequestInterceptorInterface(val gbWebClient: GBWebClient) { + val LOG: Logger = LoggerFactory.getLogger(RequestInterceptorInterface::class.java) + + @JavascriptInterface + fun onPostBody(url: String, body: String) { + LOG.debug("POST_INTERCEPTED FOR {}, BODY: {}", url, body) + gbWebClient.storePostBody(url, body) + } +} \ No newline at end of file