From d531c99d53a2e63b5ef3a023d4184904676532e4 Mon Sep 17 00:00:00 2001 From: Andrey Bocharnikov Date: Wed, 20 May 2026 21:24:31 +0400 Subject: [PATCH 1/2] fix(android): inject provider on SPA redirects and same-URL reload - Expand allowedOrigins on cross-origin navigation - Register document-start scripts with https://* / http://* rules - Force re-injection on same-URL reload (clear inject markers) --- .../src/org/mobilewebview/MobileWebView.java | 171 +++++++++++++++--- .../android/mobilewebviewbackend_android.cpp | 23 ++- .../src/common/mobilewebviewbackend.cpp | 30 ++- mobilewebview/src/js/bootstrap_page.js | 98 +++++++--- .../tests/tst_mobilewebviewbackend_common.mm | 68 +++++++ 5 files changed, 330 insertions(+), 60 deletions(-) diff --git a/mobilewebview/android/src/org/mobilewebview/MobileWebView.java b/mobilewebview/android/src/org/mobilewebview/MobileWebView.java index a6aa4e9..d13c6c3 100644 --- a/mobilewebview/android/src/org/mobilewebview/MobileWebView.java +++ b/mobilewebview/android/src/org/mobilewebview/MobileWebView.java @@ -63,9 +63,35 @@ public class MobileWebView { private String mBootstrapBridgeScript = ""; private volatile String mCurrentMainFrameOrigin = ""; private volatile boolean mBridgeInjectedForCurrentNavigation = false; + /** Same-URL reload/back-forward: force evaluateJavascript reinject (ignore stale script markers). */ + private volatile boolean mForceScriptReinject = false; + private volatile String mActiveNavigationUrl = ""; private final List mDocumentStartScriptHandlers = new ArrayList<>(); private volatile boolean mUseDocumentStartInjection = false; + /** True when document-start or evaluateJavascript already loaded user scripts. */ + private static final String SCRIPTS_ALREADY_PRESENT_JS = + "(function(){" + + "if(window.__SQ_USER_SCRIPTS_LOADED__)return true;" + + "if(window.__ETHEREUM_WRAPPER_INSTANCE__)return true;" + + "return false;" + + "})();"; + + private static final String MARK_USER_SCRIPTS_LOADED_JS = + "window.__SQ_USER_SCRIPTS_LOADED__=true;"; + + /** Clears inject markers so reload of the same URL can reinstall provider scripts. */ + private static final String CLEAR_INJECT_STATE_JS = + "(function(){try{" + + "delete window.__SQ_USER_SCRIPTS_LOADED__;" + + "delete window.__ETHEREUM_WRAPPER_INSTANCE__;" + + "delete window.__ETHEREUM_INSTALLED__;" + + "delete window.__STATUS_ETHEREUM_INJECTOR_INIT__;" + + "delete window.__STATUS_QWEBCHANNEL_CONNECTED__;" + + "var el=document.documentElement;" + + "if(el&&el.dataset){delete el.dataset.sqBridgeReady;}" + + "}catch(e){}})();"; + // Navigation state private boolean mBridgeInstalled = false; private String mPendingUrl = null; @@ -77,6 +103,12 @@ private interface NativeCallback { void invoke(long ptr); } + private enum ScriptInjectionPhase { + NONE, + ON_PAGE_STARTED, + ON_PAGE_COMMIT_VISIBLE + } + /** * Constructor - creates and initializes WebView */ @@ -296,6 +328,8 @@ public void goBackOrForward(int offset) { public void reload() { runOnMainThread(() -> { if (mWebView != null) { + mBridgeInjectedForCurrentNavigation = false; + mForceScriptReinject = true; mWebView.reload(); } }); @@ -607,20 +641,26 @@ private class MobileWebViewClient extends WebViewClient { @Override public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { Log.d(TAG, "onPageStarted: " + url); + final String navUrl = url != null ? url : ""; + final boolean sameUrlReload = navUrl.equals(mActiveNavigationUrl); + mActiveNavigationUrl = navUrl; mBridgeInjectedForCurrentNavigation = false; - withNativePtr(MobileWebView.this::nativeOnNavigationStarted); - handleNavigationLifecycle(view, url, true); + if (sameUrlReload) { + mForceScriptReinject = true; + } + withNativePtr(ptr -> nativeOnNavigationStarted(ptr, navUrl)); + handleNavigationLifecycle(view, url, true, ScriptInjectionPhase.ON_PAGE_STARTED); } @Override public void onPageCommitVisible(WebView view, String url) { - handleNavigationLifecycle(view, url, false); + handleNavigationLifecycle(view, url, false, ScriptInjectionPhase.ON_PAGE_COMMIT_VISIBLE); } @Override public void onPageFinished(WebView view, String url) { Log.d(TAG, "onPageFinished: " + url); - handleNavigationLifecycle(view, url, false); + handleNavigationLifecycle(view, url, false, ScriptInjectionPhase.NONE); withNativePtr(ptr -> nativeOnNavigationFinished(ptr, url)); } @@ -714,23 +754,58 @@ public boolean onConsoleMessage(ConsoleMessage consoleMessage) { } /** - * Inject bootstrap scripts and user scripts at document start + * Inject bootstrap and user scripts via evaluateJavascript when document-start + * is unavailable or did not cover the current origin. */ private void injectBridgeScriptsOnce() { - if (mBridgeInjectedForCurrentNavigation) { + if (mBridgeInjectedForCurrentNavigation && !mForceScriptReinject) { + return; + } + + if (!mForceScriptReinject + && mUseDocumentStartInjection + && isOriginCoveredByAllowedOrigins(mCurrentMainFrameOrigin)) { return; } - if (mUseDocumentStartInjection) { + injectBridgeScripts(() -> { mBridgeInjectedForCurrentNavigation = true; + mForceScriptReinject = false; + }); + } + + /** + * Fallback on onPageCommitVisible: inject only if document-start did not set bridge ready. + */ + private void injectBridgeScriptsOnceWithFallbackCheck() { + if (mBridgeInjectedForCurrentNavigation && !mForceScriptReinject) { + return; + } + if (mWebView == null) { return; } - injectBridgeScripts(); - mBridgeInjectedForCurrentNavigation = true; + if (!mUseDocumentStartInjection || mForceScriptReinject) { + injectBridgeScriptsOnce(); + return; + } + + mWebView.evaluateJavascript(SCRIPTS_ALREADY_PRESENT_JS, value -> { + if (mBridgeInjectedForCurrentNavigation && !mForceScriptReinject) { + return; + } + if ("true".equals(value) && !mForceScriptReinject) { + mBridgeInjectedForCurrentNavigation = true; + return; + } + injectBridgeScripts(() -> { + mBridgeInjectedForCurrentNavigation = true; + mForceScriptReinject = false; + }); + }); } - private void injectBridgeScripts() { + private void injectBridgeScripts(Runnable onComplete) { final boolean installed; final List userScripts; final String pageScript; @@ -747,21 +822,34 @@ private void injectBridgeScripts() { } if (mWebView == null) { Log.w(TAG, "injectBridgeScripts skipped: WebView is null"); + if (onComplete != null) { + onComplete.run(); + } return; } - Log.d(TAG, "Injecting bridge scripts: userScripts=" + userScripts.size() - + ", bootstrapPageLen=" + pageScript.length() - + ", bootstrapBridgeLen=" + bridgeScript.length()); - injectScriptIfPresent(pageScript, "bootstrap_page"); - injectScriptIfPresent(bridgeScript, "bootstrap_bridge_android"); + final Runnable injectBody = () -> { + injectScriptIfPresent(pageScript, "bootstrap_page"); + injectScriptIfPresent(bridgeScript, "bootstrap_bridge_android"); - // Inject user scripts - for (String scriptContent : userScripts) { - if (scriptContent != null && !scriptContent.isEmpty()) { - mWebView.evaluateJavascript(scriptContent, null); + for (String scriptContent : userScripts) { + if (scriptContent != null && !scriptContent.isEmpty()) { + mWebView.evaluateJavascript(scriptContent, null); + } } + mWebView.evaluateJavascript(MARK_USER_SCRIPTS_LOADED_JS, value -> { + if (onComplete != null) { + onComplete.run(); + } + }); + }; + + if (mForceScriptReinject) { + mWebView.evaluateJavascript(CLEAR_INJECT_STATE_JS, value -> injectBody.run()); + return; } + + injectBody.run(); } private boolean hasNativePtr() { @@ -775,14 +863,42 @@ private void withNativePtr(NativeCallback callback) { } } - private void handleNavigationLifecycle(WebView view, String url, boolean warnWhenBridgeMissing) { - mCurrentMainFrameOrigin = OriginUtils.extractOrigin(url); + private void handleNavigationLifecycle(WebView view, String url, boolean warnWhenBridgeMissing, + ScriptInjectionPhase injectionPhase) { + final String newOrigin = OriginUtils.extractOrigin(url); + mCurrentMainFrameOrigin = newOrigin; final boolean installed; synchronized (mBridgeLock) { installed = mBridgeInstalled; } + + // Track redirect targets (e.g. opensea.io -> www.opensea.io) for NativeBridge and + // document-start re-registration. + if (newOrigin != null && !newOrigin.isEmpty()) { + boolean originsExpanded = false; + synchronized (mBridgeLock) { + if (!OriginUtils.isOriginAllowed(newOrigin, mAllowedOrigins)) { + mAllowedOrigins.add(newOrigin); + originsExpanded = true; + } + } + if (originsExpanded) { + configureBridgeInjectionMode(); + } + } + if (installed) { - injectBridgeScriptsOnce(); + switch (injectionPhase) { + case ON_PAGE_STARTED: + injectBridgeScriptsOnce(); + break; + case ON_PAGE_COMMIT_VISIBLE: + injectBridgeScriptsOnceWithFallbackCheck(); + break; + case NONE: + default: + break; + } } else if (warnWhenBridgeMissing) { Log.w(TAG, "onPageStarted: bridge not installed yet"); } @@ -790,6 +906,12 @@ private void handleNavigationLifecycle(WebView view, String url, boolean warnWhe notifyHistoryState(view); } + private boolean isOriginCoveredByAllowedOrigins(String origin) { + synchronized (mBridgeLock) { + return OriginUtils.isOriginAllowed(origin, mAllowedOrigins); + } + } + private void injectScriptIfPresent(String script, String scriptName) { if (script != null && !script.isEmpty()) { mWebView.evaluateJavascript(script, null); @@ -876,6 +998,9 @@ private boolean addDocumentStartScriptIfPresent(String script, Set allow private static Set buildAllowedOriginRules(List allowedOrigins) { Set allowedOriginRules = new HashSet<>(); + // Wallet/bootstrap scripts must run on redirect targets; NativeBridge still validates origins. + allowedOriginRules.add("https://*"); + allowedOriginRules.add("http://*"); for (String origin : allowedOrigins) { if (origin != null && !origin.isEmpty()) { allowedOriginRules.add(origin); @@ -960,7 +1085,7 @@ private void notifyHistoryState(WebView view) { // Native callback methods (implemented in C++) private native void nativeOnWebMessageReceived(long nativePtr, String message, String origin, boolean isMainFrame); - private native void nativeOnNavigationStarted(long nativePtr); + private native void nativeOnNavigationStarted(long nativePtr, String url); private native void nativeOnNavigationFinished(long nativePtr, String url); private native void nativeOnNavigationFailed(long nativePtr); private native void nativeOnJavaScriptResult(long nativePtr, String result, String error); diff --git a/mobilewebview/src/android/mobilewebviewbackend_android.cpp b/mobilewebview/src/android/mobilewebviewbackend_android.cpp index 32e8f7a..2f6eecf 100644 --- a/mobilewebview/src/android/mobilewebviewbackend_android.cpp +++ b/mobilewebview/src/android/mobilewebviewbackend_android.cpp @@ -65,7 +65,7 @@ class AndroidWebViewPrivate : public MobileWebViewBackendPrivate // Callback handlers (called from JNI) void onWebMessageReceived(const QString &message, const QString &origin, bool isMainFrame); - void onNavigationStarted(); + void onNavigationStarted(const QString &url); void onNavigationFinished(const QString &url); void onNavigationFailed(); void onTitleChanged(const QString &title); @@ -790,8 +790,12 @@ void AndroidWebViewPrivate::onWebMessageReceived(const QString &message, const Q emit q_ptr->webMessageReceived(message, origin, isMainFrame); } -void AndroidWebViewPrivate::onNavigationStarted() +void AndroidWebViewPrivate::onNavigationStarted(const QString &url) { + // Update origins before user scripts / QWebChannel handshake on the next pass. + if (!url.isEmpty()) { + updateUrlState(QUrl(url)); + } setLoading(true); setLoaded(false); setLoadProgress(0); @@ -890,13 +894,20 @@ Java_org_mobilewebview_MobileWebView_nativeOnWebMessageReceived(JNIEnv *env, job } JNIEXPORT void JNICALL -Java_org_mobilewebview_MobileWebView_nativeOnNavigationStarted(JNIEnv *env, jobject obj, jlong nativePtr) +Java_org_mobilewebview_MobileWebView_nativeOnNavigationStarted(JNIEnv *env, jobject obj, + jlong nativePtr, jstring url) { if (nativePtr == 0) return; - + AndroidWebViewPrivate *backend = reinterpret_cast(nativePtr); - QMetaObject::invokeMethod(backend->q_ptr, [backend]() { - backend->onNavigationStarted(); + QString qUrl; + if (url) { + const char *urlChars = env->GetStringUTFChars(url, nullptr); + qUrl = QString::fromUtf8(urlChars); + env->ReleaseStringUTFChars(url, urlChars); + } + QMetaObject::invokeMethod(backend->q_ptr, [backend, qUrl]() { + backend->onNavigationStarted(qUrl); }, Qt::QueuedConnection); } diff --git a/mobilewebview/src/common/mobilewebviewbackend.cpp b/mobilewebview/src/common/mobilewebviewbackend.cpp index 8569a85..bb0d79c 100644 --- a/mobilewebview/src/common/mobilewebviewbackend.cpp +++ b/mobilewebview/src/common/mobilewebviewbackend.cpp @@ -143,21 +143,37 @@ void MobileWebViewBackendPrivate::setFavicon(const QString &favicon) void MobileWebViewBackendPrivate::updateUrlState(const QUrl &url) { - if (m_url != url) { - m_url = url; - emit q_ptr->urlChanged(); + if (m_url == url) { + return; + } + const QString prevOrigin = extractOrigin(m_url); + const QString newOrigin = extractOrigin(url); + m_url = url; + emit q_ptr->urlChanged(); + + if (!newOrigin.isEmpty() && newOrigin != prevOrigin) { + QStringList merged = m_allowedOrigins; + if (!merged.contains(newOrigin)) { + merged.append(newOrigin); + } + updateAllowedOrigins(merged); } } void MobileWebViewBackendPrivate::updateAllowedOrigins(const QStringList &origins) { - m_allowedOrigins = origins; - + // Merge so redirect chains (OpenSea -> Coinbase -> back) keep all origins allowed. + for (const QString &origin : origins) { + if (!origin.isEmpty() && !m_allowedOrigins.contains(origin)) { + m_allowedOrigins.append(origin); + } + } + if (m_transport) { - m_transport->setAllowedOrigins(origins); + m_transport->setAllowedOrigins(m_allowedOrigins); } - updateAllowedOriginsImpl(origins); + updateAllowedOriginsImpl(m_allowedOrigins); } void MobileWebViewBackendPrivate::notifySnapshotReady(quint64 requestId, const QImage &image) diff --git a/mobilewebview/src/js/bootstrap_page.js b/mobilewebview/src/js/bootstrap_page.js index 8d15eac..bedb9b5 100644 --- a/mobilewebview/src/js/bootstrap_page.js +++ b/mobilewebview/src/js/bootstrap_page.js @@ -4,14 +4,18 @@ 'use strict'; var TAG = '[BootstrapPage]'; - + window[ns] = window[ns] || {}; var _onmessage = null; - + // Queue for messages sent before bridge is ready var _pendingMessages = []; var _bridgeObserver = null; - + // Sticky flag: once the native bridge has been usable, do not gate on dataset.sqBridgeReady. + // SPAs (e.g. OpenSea) may clear/replace after hydration and drop the attribute while + // the __sq_req__ listener remains valid. + var _transportReady = false; + function getDocumentElement() { return document.documentElement || document.querySelector('html'); } @@ -22,22 +26,33 @@ var el = getDocumentElement(); return el ? el.dataset.sqBridgeReady === '1' : false; } - + + function markTransportReady() { + if (_transportReady) { + flushPendingMessages(); + return; + } + _transportReady = true; + flushPendingMessages(); + } + + function postToBridge(msg) { + document.dispatchEvent(new CustomEvent('__sq_req__', { detail: msg })); + } + function flushPendingMessages() { - console.log(TAG, 'Flushing', _pendingMessages.length, 'pending messages'); while (_pendingMessages.length > 0) { - var msg = _pendingMessages.shift(); - document.dispatchEvent(new CustomEvent('__sq_req__', { detail: msg })); + postToBridge(_pendingMessages.shift()); } } - + // Wait for bridge to be ready using MutationObserver; if DOM is not ready yet, retry. function waitForBridge(callback) { - if (isBridgeReady()) { + if (_transportReady || isBridgeReady()) { callback(); return; } - + // Only create one observer if (_bridgeObserver) return; @@ -64,29 +79,26 @@ if (isBridgeReady()) { _bridgeObserver.disconnect(); _bridgeObserver = null; - console.log(TAG, 'Bridge became ready (MutationObserver)'); callback(); } }); - + _bridgeObserver.observe(el, { attributes: true, attributeFilter: ['data-sq-bridge-ready'] }); } - + // Create WebChannel transport facade window[ns].webChannelTransport = { send: function(msg) { - if (isBridgeReady()) { - // Send request to bridgeWorld via DOM event - // see IsolatedWorldContext in webviewbackend.mm - document.dispatchEvent(new CustomEvent('__sq_req__', { detail: msg })); - } else { - // Queue message until bridge is ready - _pendingMessages.push(msg); - waitForBridge(flushPendingMessages); + if (_transportReady || isBridgeReady()) { + markTransportReady(); + postToBridge(msg); + return; } + _pendingMessages.push(msg); + waitForBridge(markTransportReady); }, set onmessage(fn) { _onmessage = fn; @@ -95,7 +107,7 @@ return _onmessage; } }; - + // Called from native via evaluateJavaScript to deliver messages from Qt // (Kept for backward compatibility with non-isolated mode) // see PageWorldContext in webviewbackend.mm @@ -104,14 +116,52 @@ _onmessage({ data: data }); } }; - - // Listen for push messages from bridgeWorld + + // Listen for push messages from bridgeWorld; receiving one proves the transport is alive. document.addEventListener('__sq_push__', function(e) { + markTransportReady(); if (typeof _onmessage === 'function') { _onmessage({ data: e.detail }); } }); + // Re-arm when SPA replaces and drops data-sq-bridge-ready after first connect. + (function watchBridgeDataset() { + var lastEl = null; + function onElement(el) { + if (el === lastEl) { + return; + } + lastEl = el; + new MutationObserver(function() { + if (!_transportReady && isBridgeReady()) { + markTransportReady(); + } + }).observe(el, { + attributes: true, + attributeFilter: ['data-sq-bridge-ready'] + }); + if (!_transportReady && isBridgeReady()) { + markTransportReady(); + } + } + function attach() { + var el = getDocumentElement(); + if (!el) { + setTimeout(attach, 50); + return; + } + onElement(el); + new MutationObserver(function() { + var current = getDocumentElement(); + if (current && current !== lastEl) { + onElement(current); + } + }).observe(document, { childList: true, subtree: true }); + } + attach(); + })(); + // Signal that the WebChannel transport is ready // This allows other scripts (like ethereum_injector.js) to know when they can initialize window[ns].__ready = true; diff --git a/mobilewebview/tests/tst_mobilewebviewbackend_common.mm b/mobilewebview/tests/tst_mobilewebviewbackend_common.mm index 1e313bc..0127ea4 100644 --- a/mobilewebview/tests/tst_mobilewebviewbackend_common.mm +++ b/mobilewebview/tests/tst_mobilewebviewbackend_common.mm @@ -172,6 +172,9 @@ void captureSnapshotImpl(quint64 requestId) override void lifecycleHooksTriggerNativeCallbacks(); void bridgeEdgeBranchesAreCovered(); void navigationDelegateUpdatesStates(); + void updateUrlStateRefreshesAllowedOriginsOnCrossOriginNavigation(); + void updateUrlStateKeepsAllowedOriginsOnSameOriginNavigation(); + void updateUrlStateAtNavigationStartRefreshesOriginsBeforeFinish(); void parseUserScriptsCoversVariants(); void escapeJsonForJsEscapesRequiredCharacters(); void extractOriginFromFrameInfoHandlesNull(); @@ -496,6 +499,71 @@ void captureSnapshotImpl(quint64 requestId) override [delegate release]; } +void MobileWebViewBackendCommonTest::updateUrlStateRefreshesAllowedOriginsOnCrossOriginNavigation() +{ + g_lastCreatedPrivate = nullptr; + MobileWebViewBackend backend; + QVERIFY(g_lastCreatedPrivate != nullptr); + + auto *d = g_lastCreatedPrivate; + QSignalSpy urlSpy(&backend, &MobileWebViewBackend::urlChanged); + + backend.setUrl(QUrl(QStringLiteral("https://a.example/path"))); + const int originsCallsBefore = d->updateAllowedOriginsCalls; + const QStringList originsBefore = d->lastAllowedOrigins; + urlSpy.clear(); + + backend.updateUrlState(QUrl(QStringLiteral("https://b.example/page"))); + + QCOMPARE(backend.url().toString(), QStringLiteral("https://b.example/page")); + QCOMPARE(urlSpy.count(), 1); + QCOMPARE(d->updateAllowedOriginsCalls, originsCallsBefore + 1); + const QStringList expectedOrigins{ + QStringLiteral("https://a.example"), QStringLiteral("https://b.example")}; + QCOMPARE(d->lastAllowedOrigins, expectedOrigins); + QVERIFY(originsBefore != d->lastAllowedOrigins); +} + +void MobileWebViewBackendCommonTest::updateUrlStateAtNavigationStartRefreshesOriginsBeforeFinish() +{ + // Android onPageStarted calls updateUrlState before bridge scripts / QWebChannel connect. + g_lastCreatedPrivate = nullptr; + MobileWebViewBackend backend; + QVERIFY(g_lastCreatedPrivate != nullptr); + + auto *d = g_lastCreatedPrivate; + backend.setUrl(QUrl(QStringLiteral("https://duckduckgo.com/?q=opensea"))); + + backend.updateUrlState(QUrl(QStringLiteral("https://opensea.io/"))); + + const QStringList expectedOrigins{ + QStringLiteral("https://duckduckgo.com"), QStringLiteral("https://opensea.io")}; + QCOMPARE(d->lastAllowedOrigins, expectedOrigins); + QCOMPARE(backend.url().toString(), QStringLiteral("https://opensea.io/")); +} + +void MobileWebViewBackendCommonTest::updateUrlStateKeepsAllowedOriginsOnSameOriginNavigation() +{ + g_lastCreatedPrivate = nullptr; + MobileWebViewBackend backend; + QVERIFY(g_lastCreatedPrivate != nullptr); + + auto *d = g_lastCreatedPrivate; + + backend.setUrl(QUrl(QStringLiteral("https://a.example/path"))); + const int originsCallsAfterSetUrl = d->updateAllowedOriginsCalls; + const QStringList originsAfterSetUrl = d->lastAllowedOrigins; + + backend.updateUrlState(QUrl(QStringLiteral("https://a.example/other"))); + QCOMPARE(d->updateAllowedOriginsCalls, originsCallsAfterSetUrl); + QCOMPARE(d->lastAllowedOrigins, originsAfterSetUrl); + QCOMPARE(backend.url().toString(), QStringLiteral("https://a.example/other")); + + const int originsCallsBeforeRepeat = d->updateAllowedOriginsCalls; + backend.updateUrlState(QUrl(QStringLiteral("https://a.example/other"))); + QCOMPARE(d->updateAllowedOriginsCalls, originsCallsBeforeRepeat); +} + void MobileWebViewBackendCommonTest::parseUserScriptsCoversVariants() { QVariantMap mapScript; From 7eae98894afbc0cd34906a645065b224fac19fd5 Mon Sep 17 00:00:00 2001 From: Andrey Bocharnikov Date: Thu, 21 May 2026 01:16:03 +0400 Subject: [PATCH 2/2] chore: split MobileWebView.java --- .github/workflows/ci.yml | 18 +- .../org/mobilewebview/BridgeInjectorHost.java | 9 + .../mobilewebview/BridgeScriptInjector.java | 295 +++++++++ .../src/org/mobilewebview/BridgeState.java | 21 + .../src/org/mobilewebview/ChromeHost.java | 10 + .../mobilewebview/CustomSchemeUrlHandler.java | 77 +++ .../mobilewebview/MobileWebChromeClient.java | 55 ++ .../src/org/mobilewebview/MobileWebView.java | 591 +++--------------- .../mobilewebview/MobileWebViewClient.java | 75 +++ .../MobileWebViewNativeBridge.java | 44 ++ .../org/mobilewebview/NativeBridgeHost.java | 13 + .../src/org/mobilewebview/NavigationHost.java | 14 + .../mobilewebview/ScriptInjectionPhase.java | 7 + scripts/run_java_android_tests.sh | 38 ++ 14 files changed, 752 insertions(+), 515 deletions(-) create mode 100644 mobilewebview/android/src/org/mobilewebview/BridgeInjectorHost.java create mode 100644 mobilewebview/android/src/org/mobilewebview/BridgeScriptInjector.java create mode 100644 mobilewebview/android/src/org/mobilewebview/BridgeState.java create mode 100644 mobilewebview/android/src/org/mobilewebview/ChromeHost.java create mode 100644 mobilewebview/android/src/org/mobilewebview/CustomSchemeUrlHandler.java create mode 100644 mobilewebview/android/src/org/mobilewebview/MobileWebChromeClient.java create mode 100644 mobilewebview/android/src/org/mobilewebview/MobileWebViewClient.java create mode 100644 mobilewebview/android/src/org/mobilewebview/MobileWebViewNativeBridge.java create mode 100644 mobilewebview/android/src/org/mobilewebview/NativeBridgeHost.java create mode 100644 mobilewebview/android/src/org/mobilewebview/NavigationHost.java create mode 100644 mobilewebview/android/src/org/mobilewebview/ScriptInjectionPhase.java create mode 100755 scripts/run_java_android_tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88086e4..f889050 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,22 +21,8 @@ jobs: distribution: temurin java-version: "17" - - name: Run Java utility tests - run: | - mkdir -p build/java-tests - javac -d build/java-tests \ - mobilewebview/android/src/org/mobilewebview/OriginUtils.java \ - mobilewebview/android/src/org/mobilewebview/BridgeScriptBuilder.java \ - mobilewebview/android/src/org/mobilewebview/PendingActionQueue.java \ - mobilewebview/android/src/org/mobilewebview/WebViewUrlPolicy.java \ - mobilewebview/android/tests/org/mobilewebview/OriginUtilsTest.java \ - mobilewebview/android/tests/org/mobilewebview/BridgeScriptBuilderTest.java \ - mobilewebview/android/tests/org/mobilewebview/MobileWebViewPendingActionsTest.java \ - mobilewebview/android/tests/org/mobilewebview/WebViewUrlPolicyTest.java - java -cp build/java-tests org.mobilewebview.OriginUtilsTest - java -cp build/java-tests org.mobilewebview.BridgeScriptBuilderTest - java -cp build/java-tests org.mobilewebview.MobileWebViewPendingActionsTest - java -cp build/java-tests org.mobilewebview.WebViewUrlPolicyTest + - name: Run Java Android unit tests + run: bash scripts/run_java_android_tests.sh - name: Install build dependencies run: | diff --git a/mobilewebview/android/src/org/mobilewebview/BridgeInjectorHost.java b/mobilewebview/android/src/org/mobilewebview/BridgeInjectorHost.java new file mode 100644 index 0000000..915f3ad --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/BridgeInjectorHost.java @@ -0,0 +1,9 @@ +package org.mobilewebview; + +import android.webkit.WebView; + +interface BridgeInjectorHost { + WebView webView(); + BridgeState snapshot(); + String currentOrigin(); +} diff --git a/mobilewebview/android/src/org/mobilewebview/BridgeScriptInjector.java b/mobilewebview/android/src/org/mobilewebview/BridgeScriptInjector.java new file mode 100644 index 0000000..478de4e --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/BridgeScriptInjector.java @@ -0,0 +1,295 @@ +package org.mobilewebview; + +import android.util.Log; +import android.webkit.WebView; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class BridgeScriptInjector { + private static final String TAG = "MobileWebView"; + + /** True when document-start or evaluateJavascript already loaded user scripts. */ + private static final String SCRIPTS_ALREADY_PRESENT_JS = + "(function(){return !!window.__SQ_USER_SCRIPTS_LOADED__;})();"; + + private static final String MARK_USER_SCRIPTS_LOADED_JS = + "window.__SQ_USER_SCRIPTS_LOADED__=true;"; + + /** Clears mobilewebview inject markers so reload of the same URL can reinject bridge scripts. */ + private static final String CLEAR_INJECT_STATE_JS = + "(function(){try{" + + "delete window.__SQ_USER_SCRIPTS_LOADED__;" + + "var el=document.documentElement;" + + "if(el&&el.dataset){delete el.dataset.sqBridgeReady;}" + + "}catch(e){}})();"; + + private final BridgeInjectorHost mHost; + + private volatile boolean mBridgeInjectedForCurrentNavigation = false; + /** Same-URL reload/back-forward: force evaluateJavascript reinject (ignore stale script markers). */ + private volatile boolean mForceScriptReinject = false; + private final List mDocumentStartScriptHandlers = new ArrayList<>(); + private volatile boolean mUseDocumentStartInjection = false; + + BridgeScriptInjector(BridgeInjectorHost host) { + mHost = host; + } + + void resetForNavigation(boolean sameUrlReload) { + mBridgeInjectedForCurrentNavigation = false; + if (sameUrlReload) { + mForceScriptReinject = true; + } + } + + void markForceReinject() { + mBridgeInjectedForCurrentNavigation = false; + mForceScriptReinject = true; + } + + void injectOnce(ScriptInjectionPhase phase) { + switch (phase) { + case ON_PAGE_STARTED: + injectBridgeScriptsOnce(); + break; + case ON_PAGE_COMMIT_VISIBLE: + injectBridgeScriptsOnceWithFallbackCheck(); + break; + case NONE: + default: + break; + } + } + + void configureInjectionMode() { + WebView webView = mHost.webView(); + if (webView == null) { + return; + } + BridgeState state = mHost.snapshot(); + if (!state.bridgeInstalled) { + return; + } + + final boolean supportsDocumentStart = supportsDocumentStartScript(); + if (!supportsDocumentStart) { + mUseDocumentStartInjection = false; + clearDocumentStartScripts(); + Log.i(TAG, "DOCUMENT_START_SCRIPT unavailable; using onPageStarted fallback injection"); + return; + } + + boolean registeredOk; + try { + registeredOk = registerDocumentStartScripts(); + } catch (RuntimeException ignored) { + clearDocumentStartScripts(); + registeredOk = false; + } + mUseDocumentStartInjection = registeredOk; + } + + void clearDocumentStartScripts() { + for (Object handler : mDocumentStartScriptHandlers) { + if (handler == null) { + continue; + } + try { + Method remove = handler.getClass().getMethod("remove"); + remove.invoke(handler); + } catch (Exception e) { + Log.w(TAG, "Failed to remove document-start script", e); + } + } + mDocumentStartScriptHandlers.clear(); + } + + /** + * Inject bootstrap and user scripts via evaluateJavascript when document-start + * is unavailable or did not cover the current origin. + */ + private void injectBridgeScriptsOnce() { + if (mBridgeInjectedForCurrentNavigation && !mForceScriptReinject) { + return; + } + + if (!mForceScriptReinject + && mUseDocumentStartInjection + && isOriginCoveredByAllowedOrigins(mHost.currentOrigin())) { + return; + } + + injectBridgeScripts(() -> { + mBridgeInjectedForCurrentNavigation = true; + mForceScriptReinject = false; + }); + } + + /** + * Fallback on onPageCommitVisible: inject only if document-start did not set bridge ready. + */ + private void injectBridgeScriptsOnceWithFallbackCheck() { + if (mBridgeInjectedForCurrentNavigation && !mForceScriptReinject) { + return; + } + WebView webView = mHost.webView(); + if (webView == null) { + return; + } + + if (!mUseDocumentStartInjection || mForceScriptReinject) { + injectBridgeScriptsOnce(); + return; + } + + webView.evaluateJavascript(SCRIPTS_ALREADY_PRESENT_JS, value -> { + if (mBridgeInjectedForCurrentNavigation && !mForceScriptReinject) { + return; + } + if ("true".equals(value) && !mForceScriptReinject) { + mBridgeInjectedForCurrentNavigation = true; + return; + } + injectBridgeScripts(() -> { + mBridgeInjectedForCurrentNavigation = true; + mForceScriptReinject = false; + }); + }); + } + + private void injectBridgeScripts(Runnable onComplete) { + BridgeState state = mHost.snapshot(); + if (!state.bridgeInstalled) { + Log.w(TAG, "injectBridgeScripts skipped: bridge not installed"); + return; + } + WebView webView = mHost.webView(); + if (webView == null) { + Log.w(TAG, "injectBridgeScripts skipped: WebView is null"); + if (onComplete != null) { + onComplete.run(); + } + return; + } + + final Runnable injectBody = () -> { + injectScriptIfPresent(webView, state.bootstrapPageScript, "bootstrap_page"); + injectScriptIfPresent(webView, state.bootstrapBridgeScript, "bootstrap_bridge_android"); + + for (String scriptContent : state.userScripts) { + if (scriptContent != null && !scriptContent.isEmpty()) { + webView.evaluateJavascript(scriptContent, null); + } + } + webView.evaluateJavascript(MARK_USER_SCRIPTS_LOADED_JS, value -> { + if (onComplete != null) { + onComplete.run(); + } + }); + }; + + if (mForceScriptReinject) { + webView.evaluateJavascript(CLEAR_INJECT_STATE_JS, value -> injectBody.run()); + return; + } + + injectBody.run(); + } + + private boolean isOriginCoveredByAllowedOrigins(String origin) { + return OriginUtils.isOriginAllowed(origin, mHost.snapshot().allowedOrigins); + } + + private void injectScriptIfPresent(WebView webView, String script, String scriptName) { + if (script != null && !script.isEmpty()) { + webView.evaluateJavascript(script, null); + return; + } + Log.w(TAG, scriptName + " script is empty"); + } + + private boolean registerDocumentStartScripts() { + clearDocumentStartScripts(); + + BridgeState state = mHost.snapshot(); + final Set allowedOriginRules = buildAllowedOriginRules(state.allowedOrigins); + boolean ok = addDocumentStartScriptIfPresent(state.bootstrapPageScript, allowedOriginRules) + && addDocumentStartScriptIfPresent(state.bootstrapBridgeScript, allowedOriginRules); + + for (String scriptContent : state.userScripts) { + if (scriptContent == null || scriptContent.isEmpty()) { + continue; + } + ok = ok && addDocumentStartScriptIfPresent(scriptContent, allowedOriginRules); + } + + if (!ok) { + Log.w(TAG, "document-start registration failed; using onPageStarted fallback injection"); + clearDocumentStartScripts(); + } + return ok; + } + + private boolean addDocumentStartScriptIfPresent(String script, Set allowedOriginRules) { + if (script == null || script.isEmpty()) { + return true; + } + + Object handler = addDocumentStartJavaScript(script, allowedOriginRules); + if (handler != null) { + mDocumentStartScriptHandlers.add(handler); + return true; + } + Log.w(TAG, "Skipping document-start script: WebViewCompat.addDocumentStartJavaScript failed"); + return false; + } + + private static Set buildAllowedOriginRules(List allowedOrigins) { + Set allowedOriginRules = new HashSet<>(); + // Wallet/bootstrap scripts must run on redirect targets; NativeBridge still validates origins. + allowedOriginRules.add("https://*"); + allowedOriginRules.add("http://*"); + for (String origin : allowedOrigins) { + if (origin != null && !origin.isEmpty()) { + allowedOriginRules.add(origin); + } + } + + if (allowedOriginRules.isEmpty()) { + allowedOriginRules.add("*"); + } + return allowedOriginRules; + } + + private static boolean supportsDocumentStartScript() { + try { + Class featureClass = Class.forName("androidx.webkit.WebViewFeature"); + Object featureName = featureClass.getField("DOCUMENT_START_SCRIPT").get(null); + Object supported = featureClass + .getMethod("isFeatureSupported", String.class) + .invoke(null, featureName); + return supported instanceof Boolean && (Boolean) supported; + } catch (Throwable t) { + return false; + } + } + + private Object addDocumentStartJavaScript(String script, Set allowedOriginRules) { + WebView webView = mHost.webView(); + if (webView == null) { + return null; + } + try { + Class compatClass = Class.forName("androidx.webkit.WebViewCompat"); + return compatClass + .getMethod("addDocumentStartJavaScript", WebView.class, String.class, Set.class) + .invoke(null, webView, script, allowedOriginRules); + } catch (Throwable t) { + return null; + } + } +} diff --git a/mobilewebview/android/src/org/mobilewebview/BridgeState.java b/mobilewebview/android/src/org/mobilewebview/BridgeState.java new file mode 100644 index 0000000..227c05f --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/BridgeState.java @@ -0,0 +1,21 @@ +package org.mobilewebview; + +import java.util.List; + +final class BridgeState { + final boolean bridgeInstalled; + final List userScripts; + final String bootstrapPageScript; + final String bootstrapBridgeScript; + final List allowedOrigins; + + BridgeState(boolean bridgeInstalled, List userScripts, + String bootstrapPageScript, String bootstrapBridgeScript, + List allowedOrigins) { + this.bridgeInstalled = bridgeInstalled; + this.userScripts = userScripts; + this.bootstrapPageScript = bootstrapPageScript; + this.bootstrapBridgeScript = bootstrapBridgeScript; + this.allowedOrigins = allowedOrigins; + } +} diff --git a/mobilewebview/android/src/org/mobilewebview/ChromeHost.java b/mobilewebview/android/src/org/mobilewebview/ChromeHost.java new file mode 100644 index 0000000..d51c05e --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/ChromeHost.java @@ -0,0 +1,10 @@ +package org.mobilewebview; + +import android.graphics.Bitmap; + +interface ChromeHost { + void onTitleChanged(String title); + void onProgressChanged(int progress); + void onFavicon(Bitmap icon); + void onNewWindowRequested(String url, boolean userGesture); +} diff --git a/mobilewebview/android/src/org/mobilewebview/CustomSchemeUrlHandler.java b/mobilewebview/android/src/org/mobilewebview/CustomSchemeUrlHandler.java new file mode 100644 index 0000000..c974c76 --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/CustomSchemeUrlHandler.java @@ -0,0 +1,77 @@ +package org.mobilewebview; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; +import android.webkit.WebView; + +import java.net.URISyntaxException; +import java.util.Locale; + +/** + * Handles custom URL schemes (intent, tel, mailto, geo, market, etc.). + */ +final class CustomSchemeUrlHandler { + private static final String TAG = "MobileWebView"; + + private CustomSchemeUrlHandler() { } + + /** + * @return true if navigation was handled or cancelled; false to let WebView load + */ + static boolean handle(WebView view, Uri uri) { + if (uri == null) { + return true; + } + String rawScheme = uri.getScheme(); + if (rawScheme == null) { + return true; + } + if (WebViewUrlPolicy.isSchemeLeftToWebView(rawScheme)) { + return false; + } + String schemeLower = rawScheme.toLowerCase(Locale.ROOT); + Context ctx = view.getContext(); + + if ("intent".equals(schemeLower)) { + final Intent intent; + try { + intent = Intent.parseUri(uri.toString(), Intent.URI_INTENT_SCHEME); + } catch (URISyntaxException e) { + Log.w(TAG, "Invalid intent URL", e); + return true; + } + UrlLoadingHelper.applyWebViewSecurityPolicy(intent); + if (intent.resolveActivity(ctx.getPackageManager()) != null) { + try { + ctx.startActivity(intent); + return true; + } catch (ActivityNotFoundException e) { + // try browser_fallback_url + } catch (SecurityException e) { + Log.w(TAG, "Refused to start activity from intent URL", e); + } + } + String fallback = intent.getStringExtra("browser_fallback_url"); + if (fallback != null && WebViewUrlPolicy.isHttpOrHttpsUrlForFallback(fallback)) { + view.loadUrl(fallback); + } + return true; + } + + Intent appIntent = new Intent(Intent.ACTION_VIEW, uri); + UrlLoadingHelper.applyWebViewSecurityPolicy(appIntent); + if (appIntent.resolveActivity(ctx.getPackageManager()) == null) { + return true; + } + try { + ctx.startActivity(appIntent); + } catch (ActivityNotFoundException ignored) { + } catch (SecurityException e) { + Log.w(TAG, "Refused to start view intent", e); + } + return true; + } +} diff --git a/mobilewebview/android/src/org/mobilewebview/MobileWebChromeClient.java b/mobilewebview/android/src/org/mobilewebview/MobileWebChromeClient.java new file mode 100644 index 0000000..e997607 --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/MobileWebChromeClient.java @@ -0,0 +1,55 @@ +package org.mobilewebview; + +import android.graphics.Bitmap; +import android.util.Log; +import android.webkit.ConsoleMessage; +import android.webkit.WebChromeClient; +import android.webkit.WebView; + +final class MobileWebChromeClient extends WebChromeClient { + private static final String TAG = "MobileWebView"; + + private final ChromeHost mHost; + + MobileWebChromeClient(ChromeHost host) { + mHost = host; + } + + @Override + public boolean onCreateWindow(WebView view, boolean isDialog, boolean isUserGesture, + android.os.Message resultMsg) { + WebView.HitTestResult hitTestResult = view.getHitTestResult(); + String requestedUrl = hitTestResult != null ? hitTestResult.getExtra() : null; + + if (requestedUrl != null && !requestedUrl.isEmpty()) { + mHost.onNewWindowRequested(requestedUrl, isUserGesture); + return false; + } + + return false; + } + + @Override + public void onReceivedTitle(WebView view, String title) { + mHost.onTitleChanged(title != null ? title : ""); + } + + @Override + public void onProgressChanged(WebView view, int newProgress) { + mHost.onProgressChanged(newProgress); + } + + @Override + public void onReceivedIcon(WebView view, Bitmap icon) { + mHost.onFavicon(icon); + } + + @Override + public boolean onConsoleMessage(ConsoleMessage consoleMessage) { + Log.d(TAG, "Console [" + consoleMessage.messageLevel() + "]: " + + consoleMessage.message() + " -- From line " + + consoleMessage.lineNumber() + " of " + + consoleMessage.sourceId()); + return true; + } +} diff --git a/mobilewebview/android/src/org/mobilewebview/MobileWebView.java b/mobilewebview/android/src/org/mobilewebview/MobileWebView.java index d13c6c3..f27e093 100644 --- a/mobilewebview/android/src/org/mobilewebview/MobileWebView.java +++ b/mobilewebview/android/src/org/mobilewebview/MobileWebView.java @@ -7,45 +7,30 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; -import android.graphics.Rect; import android.util.Base64; import android.util.Log; import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.Intent; import android.net.Uri; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; -import android.webkit.ConsoleMessage; -import android.webkit.JavascriptInterface; -import android.webkit.WebChromeClient; -import android.webkit.WebResourceError; -import android.webkit.WebResourceRequest; import android.webkit.WebSettings; import android.webkit.WebBackForwardList; import android.webkit.WebHistoryItem; import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.os.Build; import android.os.Handler; import android.os.Looper; import java.io.ByteArrayOutputStream; -import java.lang.reflect.Method; -import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashSet; import java.util.List; -import java.util.Locale; -import java.util.Set; /** * MobileWebView - Native Android WebView wrapper for Qt integration * Provides QWebChannel bridge, user script injection, and origin validation */ -public class MobileWebView { +public class MobileWebView implements ChromeHost, NavigationHost, NativeBridgeHost, BridgeInjectorHost { private static final String TAG = "MobileWebView"; private static final int ANDROID_CONTENT_VIEW_ID = 0x01020002; // android.R.id.content @@ -62,53 +47,20 @@ public class MobileWebView { private String mBootstrapPageScript = ""; private String mBootstrapBridgeScript = ""; private volatile String mCurrentMainFrameOrigin = ""; - private volatile boolean mBridgeInjectedForCurrentNavigation = false; - /** Same-URL reload/back-forward: force evaluateJavascript reinject (ignore stale script markers). */ - private volatile boolean mForceScriptReinject = false; private volatile String mActiveNavigationUrl = ""; - private final List mDocumentStartScriptHandlers = new ArrayList<>(); - private volatile boolean mUseDocumentStartInjection = false; - - /** True when document-start or evaluateJavascript already loaded user scripts. */ - private static final String SCRIPTS_ALREADY_PRESENT_JS = - "(function(){" - + "if(window.__SQ_USER_SCRIPTS_LOADED__)return true;" - + "if(window.__ETHEREUM_WRAPPER_INSTANCE__)return true;" - + "return false;" - + "})();"; - - private static final String MARK_USER_SCRIPTS_LOADED_JS = - "window.__SQ_USER_SCRIPTS_LOADED__=true;"; - - /** Clears inject markers so reload of the same URL can reinstall provider scripts. */ - private static final String CLEAR_INJECT_STATE_JS = - "(function(){try{" - + "delete window.__SQ_USER_SCRIPTS_LOADED__;" - + "delete window.__ETHEREUM_WRAPPER_INSTANCE__;" - + "delete window.__ETHEREUM_INSTALLED__;" - + "delete window.__STATUS_ETHEREUM_INJECTOR_INIT__;" - + "delete window.__STATUS_QWEBCHANNEL_CONNECTED__;" - + "var el=document.documentElement;" - + "if(el&&el.dataset){delete el.dataset.sqBridgeReady;}" - + "}catch(e){}})();"; // Navigation state private boolean mBridgeInstalled = false; private String mPendingUrl = null; private final Handler mMainHandler = new Handler(Looper.getMainLooper()); private final PendingActionQueue mPendingActionQueue = new PendingActionQueue(); + private final BridgeScriptInjector mBridgeInjector = new BridgeScriptInjector(this); @FunctionalInterface private interface NativeCallback { void invoke(long ptr); } - private enum ScriptInjectionPhase { - NONE, - ON_PAGE_STARTED, - ON_PAGE_COMMIT_VISIBLE - } - /** * Constructor - creates and initializes WebView */ @@ -204,11 +156,8 @@ private void setupWebView() { WebView.setWebContentsDebuggingEnabled(true); } - // Set WebViewClient for navigation callbacks - mWebView.setWebViewClient(new MobileWebViewClient()); - - // Set WebChromeClient for console messages - mWebView.setWebChromeClient(new MobileWebChromeClient()); + mWebView.setWebViewClient(new MobileWebViewClient(this)); + mWebView.setWebChromeClient(new MobileWebChromeClient(this)); // Add to view hierarchy (initially hidden). Must happen on UI thread. mWebView.setVisibility(View.GONE); @@ -222,8 +171,7 @@ private void setupWebView() { } } - // JavaScript interface for QWebChannel bridge - mWebView.addJavascriptInterface(new NativeBridge(), "NativeBridge"); + mWebView.addJavascriptInterface(new MobileWebViewNativeBridge(this), "NativeBridge"); } /** @@ -247,7 +195,7 @@ public void installMessageBridge(String namespace, String[] allowedOrigins, mBootstrapBridgeScript = bootstrapBridgeScript != null ? bootstrapBridgeScript : ""; mBridgeInstalled = true; } - runOnMainThread(this::configureBridgeInjectionMode); + runOnMainThread(mBridgeInjector::configureInjectionMode); } /** @@ -260,7 +208,7 @@ public void updateAllowedOrigins(String[] origins) { mAllowedOrigins.addAll(Arrays.asList(origins)); } } - runOnMainThread(this::configureBridgeInjectionMode); + runOnMainThread(mBridgeInjector::configureInjectionMode); } /** @@ -328,8 +276,7 @@ public void goBackOrForward(int offset) { public void reload() { runOnMainThread(() -> { if (mWebView != null) { - mBridgeInjectedForCurrentNavigation = false; - mForceScriptReinject = true; + mBridgeInjector.markForceReinject(); mWebView.reload(); } }); @@ -518,7 +465,7 @@ public void setInteractionEnabled(boolean enabled) { public void destroy() { mNativePtr = 0; // zero out immediately so JNI callbacks are ignored runOnMainThread(() -> { - clearDocumentStartScripts(); + mBridgeInjector.clearDocumentStartScripts(); if (mWebView != null) { mWebView.stopLoading(); mWebView.loadUrl("about:blank"); @@ -541,315 +488,117 @@ public WebView getWebView() { return mWebView; } - /** - * JavaScript interface for Qt bridge - */ - private class NativeBridge { - /** - * Called from JavaScript via NativeBridge.postMessage() - */ - @JavascriptInterface - public void postMessage(String message) { - if (!hasNativePtr()) { - return; - } - - // Prefer tracked main-frame origin to avoid transient URL mismatches during redirects. - String resolvedOrigin = mCurrentMainFrameOrigin; - if (resolvedOrigin == null || resolvedOrigin.isEmpty()) { - String currentUrl = mWebView.getUrl(); - resolvedOrigin = OriginUtils.extractOrigin(currentUrl); - } - final String origin = resolvedOrigin; - - final List allowedOrigins; - synchronized (mBridgeLock) { - allowedOrigins = new ArrayList<>(mAllowedOrigins); - } - if (!OriginUtils.isOriginAllowed(origin, allowedOrigins)) { - Log.w(TAG, "Rejected message from disallowed origin: " + origin); - return; - } + // --- ChromeHost --- - // Forward to C++ layer - withNativePtr(ptr -> nativeOnWebMessageReceived(ptr, message, origin, false)); - } + @Override + public void onTitleChanged(String title) { + withNativePtr(ptr -> nativeOnTitleChanged(ptr, title)); } - /** - * Handles custom URL schemes (intent, tel, mailto, geo, market, etc.). - * @return true if navigation was handled or cancelled; false to let WebView load - */ - private static boolean handleCustomSchemeUrl(WebView view, Uri uri) { - if (uri == null) { - return true; - } - String rawScheme = uri.getScheme(); - if (rawScheme == null) { - return true; - } - if (WebViewUrlPolicy.isSchemeLeftToWebView(rawScheme)) { - return false; - } - String schemeLower = rawScheme.toLowerCase(Locale.ROOT); - Context ctx = view.getContext(); - - if ("intent".equals(schemeLower)) { - final Intent intent; - try { - intent = Intent.parseUri(uri.toString(), Intent.URI_INTENT_SCHEME); - } catch (URISyntaxException e) { - Log.w(TAG, "Invalid intent URL", e); - return true; - } - UrlLoadingHelper.applyWebViewSecurityPolicy(intent); - if (intent.resolveActivity(ctx.getPackageManager()) != null) { - try { - ctx.startActivity(intent); - return true; - } catch (ActivityNotFoundException e) { - // try browser_fallback_url - } catch (SecurityException e) { - Log.w(TAG, "Refused to start activity from intent URL", e); - } - } - String fallback = intent.getStringExtra("browser_fallback_url"); - if (fallback != null && WebViewUrlPolicy.isHttpOrHttpsUrlForFallback(fallback)) { - view.loadUrl(fallback); - } - return true; - } + @Override + public void onProgressChanged(int progress) { + withNativePtr(ptr -> nativeOnLoadProgressChanged(ptr, progress)); + } - Intent appIntent = new Intent(Intent.ACTION_VIEW, uri); - UrlLoadingHelper.applyWebViewSecurityPolicy(appIntent); - if (appIntent.resolveActivity(ctx.getPackageManager()) == null) { - return true; + @Override + public void onFavicon(Bitmap icon) { + if (icon == null) { + return; } try { - ctx.startActivity(appIntent); - } catch (ActivityNotFoundException ignored) { - } catch (SecurityException e) { - Log.w(TAG, "Refused to start view intent", e); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + icon.compress(Bitmap.CompressFormat.PNG, 100, baos); + String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP); + String dataUri = "data:image/png;base64," + base64; + withNativePtr(ptr -> nativeOnFaviconReceived(ptr, dataUri)); + } catch (Exception e) { + Log.w(TAG, "onReceivedIcon: failed to encode favicon", e); } - return true; } - /** - * WebViewClient for navigation callbacks - */ - private class MobileWebViewClient extends WebViewClient { - @Override - public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { - Log.d(TAG, "onPageStarted: " + url); - final String navUrl = url != null ? url : ""; - final boolean sameUrlReload = navUrl.equals(mActiveNavigationUrl); - mActiveNavigationUrl = navUrl; - mBridgeInjectedForCurrentNavigation = false; - if (sameUrlReload) { - mForceScriptReinject = true; - } - withNativePtr(ptr -> nativeOnNavigationStarted(ptr, navUrl)); - handleNavigationLifecycle(view, url, true, ScriptInjectionPhase.ON_PAGE_STARTED); - } - - @Override - public void onPageCommitVisible(WebView view, String url) { - handleNavigationLifecycle(view, url, false, ScriptInjectionPhase.ON_PAGE_COMMIT_VISIBLE); - } - - @Override - public void onPageFinished(WebView view, String url) { - Log.d(TAG, "onPageFinished: " + url); - handleNavigationLifecycle(view, url, false, ScriptInjectionPhase.NONE); - withNativePtr(ptr -> nativeOnNavigationFinished(ptr, url)); - } - - @Override - public void onReceivedError(WebView view, WebResourceRequest request, - WebResourceError error) { - if (request.isForMainFrame()) { - Log.e(TAG, "onReceivedError: " + error.getDescription()); - withNativePtr(MobileWebView.this::nativeOnNavigationFailed); - } - } - - @SuppressWarnings("deprecation") - @Override - public boolean shouldOverrideUrlLoading(WebView view, String url) { - if (url == null) { - return false; - } - // Pre-24 only; no isForMainFrame / hasGesture — stricter only on API 24+. - return handleCustomSchemeUrl(view, Uri.parse(url)); - } - - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - if (request == null || !request.isForMainFrame() || request.getUrl() == null) { - return false; - } - Uri u = request.getUrl(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - String sch = u.getScheme(); - String sl = sch != null ? sch.toLowerCase(Locale.ROOT) : ""; - if (!"intent".equals(sl) && !request.hasGesture()) { - return false; - } - } - return handleCustomSchemeUrl(view, u); - } + @Override + public void onNewWindowRequested(String url, boolean userGesture) { + withNativePtr(ptr -> nativeOnNewWindowRequested(ptr, url, userGesture)); } - /** - * WebChromeClient for console messages - */ - private class MobileWebChromeClient extends WebChromeClient { - @Override - public boolean onCreateWindow(WebView view, boolean isDialog, boolean isUserGesture, android.os.Message resultMsg) { - WebView.HitTestResult hitTestResult = view.getHitTestResult(); - String requestedUrl = hitTestResult != null ? hitTestResult.getExtra() : null; - - if (requestedUrl != null && !requestedUrl.isEmpty()) { - withNativePtr(ptr -> nativeOnNewWindowRequested(ptr, requestedUrl, isUserGesture)); - return false; - } - - return false; - } - - @Override - public void onReceivedTitle(WebView view, String title) { - withNativePtr(ptr -> nativeOnTitleChanged(ptr, title != null ? title : "")); - } - - @Override - public void onProgressChanged(WebView view, int newProgress) { - withNativePtr(ptr -> nativeOnLoadProgressChanged(ptr, newProgress)); - } + // --- NavigationHost --- - @Override - public void onReceivedIcon(WebView view, Bitmap icon) { - if (icon == null) { - return; - } - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - icon.compress(Bitmap.CompressFormat.PNG, 100, baos); - String base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP); - String dataUri = "data:image/png;base64," + base64; - withNativePtr(ptr -> nativeOnFaviconReceived(ptr, dataUri)); - } catch (Exception e) { - Log.w(TAG, "onReceivedIcon: failed to encode favicon", e); - } - } + @Override + public void onNavigationStarted(String url) { + final String navUrl = url != null ? url : ""; + final boolean sameUrlReload = navUrl.equals(mActiveNavigationUrl); + mActiveNavigationUrl = navUrl; + mBridgeInjector.resetForNavigation(sameUrlReload); + withNativePtr(ptr -> nativeOnNavigationStarted(ptr, navUrl)); + } - @Override - public boolean onConsoleMessage(ConsoleMessage consoleMessage) { - Log.d(TAG, "Console [" + consoleMessage.messageLevel() + "]: " + - consoleMessage.message() + " -- From line " + - consoleMessage.lineNumber() + " of " + - consoleMessage.sourceId()); - return true; - } + @Override + public void onNavigationFinished(String url) { + withNativePtr(ptr -> nativeOnNavigationFinished(ptr, url)); } - /** - * Inject bootstrap and user scripts via evaluateJavascript when document-start - * is unavailable or did not cover the current origin. - */ - private void injectBridgeScriptsOnce() { - if (mBridgeInjectedForCurrentNavigation && !mForceScriptReinject) { - return; - } + @Override + public void onMainFrameError() { + withNativePtr(MobileWebView.this::nativeOnNavigationFailed); + } - if (!mForceScriptReinject - && mUseDocumentStartInjection - && isOriginCoveredByAllowedOrigins(mCurrentMainFrameOrigin)) { - return; - } + @Override + public void onNavigationLifecycle(WebView view, String url, boolean warnIfBridgeMissing, + ScriptInjectionPhase injectionPhase) { + handleNavigationLifecycle(view, url, warnIfBridgeMissing, injectionPhase); + } - injectBridgeScripts(() -> { - mBridgeInjectedForCurrentNavigation = true; - mForceScriptReinject = false; - }); + @Override + public boolean handleCustomScheme(WebView view, Uri uri) { + return CustomSchemeUrlHandler.handle(view, uri); } - /** - * Fallback on onPageCommitVisible: inject only if document-start did not set bridge ready. - */ - private void injectBridgeScriptsOnceWithFallbackCheck() { - if (mBridgeInjectedForCurrentNavigation && !mForceScriptReinject) { - return; - } - if (mWebView == null) { - return; - } + // --- NativeBridgeHost --- - if (!mUseDocumentStartInjection || mForceScriptReinject) { - injectBridgeScriptsOnce(); - return; - } + @Override + public long nativePtr() { + return mNativePtr; + } - mWebView.evaluateJavascript(SCRIPTS_ALREADY_PRESENT_JS, value -> { - if (mBridgeInjectedForCurrentNavigation && !mForceScriptReinject) { - return; - } - if ("true".equals(value) && !mForceScriptReinject) { - mBridgeInjectedForCurrentNavigation = true; - return; - } - injectBridgeScripts(() -> { - mBridgeInjectedForCurrentNavigation = true; - mForceScriptReinject = false; - }); - }); + @Override + public String currentMainFrameOrigin() { + return mCurrentMainFrameOrigin; } - private void injectBridgeScripts(Runnable onComplete) { - final boolean installed; - final List userScripts; - final String pageScript; - final String bridgeScript; + @Override + public List allowedOriginsSnapshot() { synchronized (mBridgeLock) { - installed = mBridgeInstalled; - userScripts = new ArrayList<>(mUserScripts); - pageScript = mBootstrapPageScript; - bridgeScript = mBootstrapBridgeScript; - } - if (!installed) { - Log.w(TAG, "injectBridgeScripts skipped: bridge not installed"); - return; - } - if (mWebView == null) { - Log.w(TAG, "injectBridgeScripts skipped: WebView is null"); - if (onComplete != null) { - onComplete.run(); - } - return; + return new ArrayList<>(mAllowedOrigins); } + } - final Runnable injectBody = () -> { - injectScriptIfPresent(pageScript, "bootstrap_page"); - injectScriptIfPresent(bridgeScript, "bootstrap_bridge_android"); + @Override + public WebView webView() { + return mWebView; + } - for (String scriptContent : userScripts) { - if (scriptContent != null && !scriptContent.isEmpty()) { - mWebView.evaluateJavascript(scriptContent, null); - } - } - mWebView.evaluateJavascript(MARK_USER_SCRIPTS_LOADED_JS, value -> { - if (onComplete != null) { - onComplete.run(); - } - }); - }; + @Override + public void onWebMessage(String message, String origin) { + withNativePtr(ptr -> nativeOnWebMessageReceived(ptr, message, origin, false)); + } - if (mForceScriptReinject) { - mWebView.evaluateJavascript(CLEAR_INJECT_STATE_JS, value -> injectBody.run()); - return; + // --- BridgeInjectorHost --- + + @Override + public BridgeState snapshot() { + synchronized (mBridgeLock) { + return new BridgeState( + mBridgeInstalled, + new ArrayList<>(mUserScripts), + mBootstrapPageScript, + mBootstrapBridgeScript, + new ArrayList<>(mAllowedOrigins)); } + } - injectBody.run(); + @Override + public String currentOrigin() { + return mCurrentMainFrameOrigin; } private boolean hasNativePtr() { @@ -883,22 +632,12 @@ private void handleNavigationLifecycle(WebView view, String url, boolean warnWhe } } if (originsExpanded) { - configureBridgeInjectionMode(); + mBridgeInjector.configureInjectionMode(); } } if (installed) { - switch (injectionPhase) { - case ON_PAGE_STARTED: - injectBridgeScriptsOnce(); - break; - case ON_PAGE_COMMIT_VISIBLE: - injectBridgeScriptsOnceWithFallbackCheck(); - break; - case NONE: - default: - break; - } + mBridgeInjector.injectOnce(injectionPhase); } else if (warnWhenBridgeMissing) { Log.w(TAG, "onPageStarted: bridge not installed yet"); } @@ -906,152 +645,6 @@ private void handleNavigationLifecycle(WebView view, String url, boolean warnWhe notifyHistoryState(view); } - private boolean isOriginCoveredByAllowedOrigins(String origin) { - synchronized (mBridgeLock) { - return OriginUtils.isOriginAllowed(origin, mAllowedOrigins); - } - } - - private void injectScriptIfPresent(String script, String scriptName) { - if (script != null && !script.isEmpty()) { - mWebView.evaluateJavascript(script, null); - return; - } - Log.w(TAG, scriptName + " script is empty"); - } - - private void configureBridgeInjectionMode() { - if (mWebView == null) { - return; - } - final boolean installed; - synchronized (mBridgeLock) { - installed = mBridgeInstalled; - } - if (!installed) { - return; - } - - final boolean supportsDocumentStart = supportsDocumentStartScript(); - if (!supportsDocumentStart) { - mUseDocumentStartInjection = false; - clearDocumentStartScripts(); - Log.i(TAG, "DOCUMENT_START_SCRIPT unavailable; using onPageStarted fallback injection"); - return; - } - - boolean registeredOk; - try { - registeredOk = registerDocumentStartScripts(); - } catch (RuntimeException ignored) { - clearDocumentStartScripts(); - registeredOk = false; - } - mUseDocumentStartInjection = registeredOk; - } - - private boolean registerDocumentStartScripts() { - clearDocumentStartScripts(); - - final List originsSnap; - final List userScriptsSnap; - final String pageScript; - final String bridgeScript; - synchronized (mBridgeLock) { - originsSnap = new ArrayList<>(mAllowedOrigins); - userScriptsSnap = new ArrayList<>(mUserScripts); - pageScript = mBootstrapPageScript; - bridgeScript = mBootstrapBridgeScript; - } - - final Set allowedOriginRules = buildAllowedOriginRules(originsSnap); - boolean ok = addDocumentStartScriptIfPresent(pageScript, allowedOriginRules) - && addDocumentStartScriptIfPresent(bridgeScript, allowedOriginRules); - - for (String scriptContent : userScriptsSnap) { - if (scriptContent == null || scriptContent.isEmpty()) { - continue; - } - ok = ok && addDocumentStartScriptIfPresent(scriptContent, allowedOriginRules); - } - - if (!ok) { - Log.w(TAG, "document-start registration failed; using onPageStarted fallback injection"); - clearDocumentStartScripts(); - } - return ok; - } - - private boolean addDocumentStartScriptIfPresent(String script, Set allowedOriginRules) { - if (script == null || script.isEmpty()) { - return true; - } - - Object handler = addDocumentStartJavaScript(script, allowedOriginRules); - if (handler != null) { - mDocumentStartScriptHandlers.add(handler); - return true; - } - Log.w(TAG, "Skipping document-start script: WebViewCompat.addDocumentStartJavaScript failed"); - return false; - } - - private static Set buildAllowedOriginRules(List allowedOrigins) { - Set allowedOriginRules = new HashSet<>(); - // Wallet/bootstrap scripts must run on redirect targets; NativeBridge still validates origins. - allowedOriginRules.add("https://*"); - allowedOriginRules.add("http://*"); - for (String origin : allowedOrigins) { - if (origin != null && !origin.isEmpty()) { - allowedOriginRules.add(origin); - } - } - - if (allowedOriginRules.isEmpty()) { - allowedOriginRules.add("*"); - } - return allowedOriginRules; - } - - private void clearDocumentStartScripts() { - for (Object handler : mDocumentStartScriptHandlers) { - if (handler == null) { - continue; - } - try { - Method remove = handler.getClass().getMethod("remove"); - remove.invoke(handler); - } catch (Exception e) { - Log.w(TAG, "Failed to remove document-start script", e); - } - } - mDocumentStartScriptHandlers.clear(); - } - - private static boolean supportsDocumentStartScript() { - try { - Class featureClass = Class.forName("androidx.webkit.WebViewFeature"); - Object featureName = featureClass.getField("DOCUMENT_START_SCRIPT").get(null); - Object supported = featureClass - .getMethod("isFeatureSupported", String.class) - .invoke(null, featureName); - return supported instanceof Boolean && (Boolean) supported; - } catch (Throwable t) { - return false; - } - } - - private Object addDocumentStartJavaScript(String script, Set allowedOriginRules) { - try { - Class compatClass = Class.forName("androidx.webkit.WebViewCompat"); - return compatClass - .getMethod("addDocumentStartJavaScript", WebView.class, String.class, Set.class) - .invoke(null, mWebView, script, allowedOriginRules); - } catch (Throwable t) { - return null; - } - } - private void notifyHistoryState(WebView view) { if (view == null) { return; diff --git a/mobilewebview/android/src/org/mobilewebview/MobileWebViewClient.java b/mobilewebview/android/src/org/mobilewebview/MobileWebViewClient.java new file mode 100644 index 0000000..5dd4035 --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/MobileWebViewClient.java @@ -0,0 +1,75 @@ +package org.mobilewebview; + +import android.net.Uri; +import android.os.Build; +import android.util.Log; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import java.util.Locale; + +final class MobileWebViewClient extends WebViewClient { + private static final String TAG = "MobileWebView"; + + private final NavigationHost mHost; + + MobileWebViewClient(NavigationHost host) { + mHost = host; + } + + @Override + public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { + Log.d(TAG, "onPageStarted: " + url); + mHost.onNavigationStarted(url); + mHost.onNavigationLifecycle(view, url, true, ScriptInjectionPhase.ON_PAGE_STARTED); + } + + @Override + public void onPageCommitVisible(WebView view, String url) { + mHost.onNavigationLifecycle(view, url, false, ScriptInjectionPhase.ON_PAGE_COMMIT_VISIBLE); + } + + @Override + public void onPageFinished(WebView view, String url) { + Log.d(TAG, "onPageFinished: " + url); + mHost.onNavigationLifecycle(view, url, false, ScriptInjectionPhase.NONE); + mHost.onNavigationFinished(url); + } + + @Override + public void onReceivedError(WebView view, WebResourceRequest request, + WebResourceError error) { + if (request.isForMainFrame()) { + Log.e(TAG, "onReceivedError: " + error.getDescription()); + mHost.onMainFrameError(); + } + } + + @SuppressWarnings("deprecation") + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (url == null) { + return false; + } + // Pre-24 only; no isForMainFrame / hasGesture — stricter only on API 24+. + return mHost.handleCustomScheme(view, Uri.parse(url)); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + if (request == null || !request.isForMainFrame() || request.getUrl() == null) { + return false; + } + Uri u = request.getUrl(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + String sch = u.getScheme(); + String sl = sch != null ? sch.toLowerCase(Locale.ROOT) : ""; + if (!"intent".equals(sl) && !request.hasGesture()) { + return false; + } + } + return mHost.handleCustomScheme(view, u); + } +} diff --git a/mobilewebview/android/src/org/mobilewebview/MobileWebViewNativeBridge.java b/mobilewebview/android/src/org/mobilewebview/MobileWebViewNativeBridge.java new file mode 100644 index 0000000..3f4c8bd --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/MobileWebViewNativeBridge.java @@ -0,0 +1,44 @@ +package org.mobilewebview; + +import android.util.Log; +import android.webkit.JavascriptInterface; +import android.webkit.WebView; + +import java.util.List; + +final class MobileWebViewNativeBridge { + private static final String TAG = "MobileWebView"; + + private final NativeBridgeHost mHost; + + MobileWebViewNativeBridge(NativeBridgeHost host) { + mHost = host; + } + + /** + * Called from JavaScript via NativeBridge.postMessage() + */ + @JavascriptInterface + public void postMessage(String message) { + if (mHost.nativePtr() == 0) { + return; + } + + // Prefer tracked main-frame origin to avoid transient URL mismatches during redirects. + String resolvedOrigin = mHost.currentMainFrameOrigin(); + if (resolvedOrigin == null || resolvedOrigin.isEmpty()) { + WebView webView = mHost.webView(); + String currentUrl = webView != null ? webView.getUrl() : null; + resolvedOrigin = OriginUtils.extractOrigin(currentUrl); + } + final String origin = resolvedOrigin; + + final List allowedOrigins = mHost.allowedOriginsSnapshot(); + if (!OriginUtils.isOriginAllowed(origin, allowedOrigins)) { + Log.w(TAG, "Rejected message from disallowed origin: " + origin); + return; + } + + mHost.onWebMessage(message, origin); + } +} diff --git a/mobilewebview/android/src/org/mobilewebview/NativeBridgeHost.java b/mobilewebview/android/src/org/mobilewebview/NativeBridgeHost.java new file mode 100644 index 0000000..e6cf7b2 --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/NativeBridgeHost.java @@ -0,0 +1,13 @@ +package org.mobilewebview; + +import android.webkit.WebView; + +import java.util.List; + +interface NativeBridgeHost { + long nativePtr(); + String currentMainFrameOrigin(); + List allowedOriginsSnapshot(); + WebView webView(); + void onWebMessage(String message, String origin); +} diff --git a/mobilewebview/android/src/org/mobilewebview/NavigationHost.java b/mobilewebview/android/src/org/mobilewebview/NavigationHost.java new file mode 100644 index 0000000..bccf560 --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/NavigationHost.java @@ -0,0 +1,14 @@ +package org.mobilewebview; + +import android.net.Uri; +import android.webkit.WebView; + +interface NavigationHost { + void onNavigationStarted(String url); + void onNavigationFinished(String url); + void onMainFrameError(); + void onNavigationLifecycle(WebView view, String url, + boolean warnIfBridgeMissing, + ScriptInjectionPhase phase); + boolean handleCustomScheme(WebView view, Uri uri); +} diff --git a/mobilewebview/android/src/org/mobilewebview/ScriptInjectionPhase.java b/mobilewebview/android/src/org/mobilewebview/ScriptInjectionPhase.java new file mode 100644 index 0000000..9a26307 --- /dev/null +++ b/mobilewebview/android/src/org/mobilewebview/ScriptInjectionPhase.java @@ -0,0 +1,7 @@ +package org.mobilewebview; + +enum ScriptInjectionPhase { + NONE, + ON_PAGE_STARTED, + ON_PAGE_COMMIT_VISIBLE +} diff --git a/scripts/run_java_android_tests.sh b/scripts/run_java_android_tests.sh new file mode 100755 index 0000000..083ca0c --- /dev/null +++ b/scripts/run_java_android_tests.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Compile and run JVM unit tests for mobilewebview Android helpers (pure Java, no android.jar). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$ROOT/build/java-tests" +ANDROID_SRC="$ROOT/mobilewebview/android/src/org/mobilewebview" +ANDROID_TEST="$ROOT/mobilewebview/android/tests/org/mobilewebview" + +mkdir -p "$OUT" + +SOURCES=( + "$ANDROID_SRC/OriginUtils.java" + "$ANDROID_SRC/BridgeScriptBuilder.java" + "$ANDROID_SRC/PendingActionQueue.java" + "$ANDROID_SRC/WebViewUrlPolicy.java" + "$ANDROID_TEST/OriginUtilsTest.java" + "$ANDROID_TEST/BridgeScriptBuilderTest.java" + "$ANDROID_TEST/MobileWebViewPendingActionsTest.java" + "$ANDROID_TEST/WebViewUrlPolicyTest.java" +) + +echo "Compiling Java tests..." +javac -d "$OUT" "${SOURCES[@]}" + +TESTS=( + org.mobilewebview.OriginUtilsTest + org.mobilewebview.BridgeScriptBuilderTest + org.mobilewebview.MobileWebViewPendingActionsTest + org.mobilewebview.WebViewUrlPolicyTest +) + +for test in "${TESTS[@]}"; do + echo "Running $test..." + java -cp "$OUT" "$test" +done + +echo "All Java tests passed."