From 6bb00b5b4d67098b1ff3a044450113b214423507 Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Wed, 22 Jul 2026 18:55:51 +0530 Subject: [PATCH 1/6] feat: add WebView Plugin API Add cordova-plugin-acode-webview allowing plugins to create and manage isolated WebView instances with fullscreen, window, panel, and hidden modes. Features: - Create independent WebView instances via acode.webview.create() - Load URLs, HTML content, and execute JavaScript - Bidirectional messaging between plugin and WebView - Lifecycle management: show, hide, reload, destroy - Lifecycle events: pageFinished, titleChanged, closed --- package-lock.json | 11 + package.json | 4 +- src/index.d.ts | 38 +- src/lib/acode.js | 2 + src/lib/webview.js | 155 +++++++ src/plugins/webview/package.json | 11 + src/plugins/webview/plugin.xml | 26 ++ .../com/foxdebug/webview/WebViewActivity.java | 198 ++++++++ .../com/foxdebug/webview/WebViewInstance.java | 423 ++++++++++++++++++ .../com/foxdebug/webview/WebViewPlugin.java | 266 +++++++++++ src/plugins/webview/www/webview.js | 88 ++++ 11 files changed, 1220 insertions(+), 2 deletions(-) create mode 100644 src/lib/webview.js create mode 100644 src/plugins/webview/package.json create mode 100644 src/plugins/webview/plugin.xml create mode 100644 src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java create mode 100644 src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java create mode 100644 src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java create mode 100644 src/plugins/webview/www/webview.js diff --git a/package-lock.json b/package-lock.json index 50540a937..3af595deb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,7 @@ "com.foxdebug.acode.rk.plugin.plugincontext": "file:src/plugins/pluginContext", "cordova-android": "^15.0.0", "cordova-clipboard": "^1.3.0", + "cordova-plugin-acode-webview": "file:src/plugins/webview", "cordova-plugin-advanced-http": "file:src/plugins/cordova-plugin-advanced-http", "cordova-plugin-browser": "file:src/plugins/browser", "cordova-plugin-buildinfo": "file:src/plugins/cordova-plugin-buildinfo", @@ -8242,6 +8243,10 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/cordova-plugin-acode-webview": { + "resolved": "src/plugins/webview", + "link": true + }, "node_modules/cordova-plugin-advanced-http": { "resolved": "src/plugins/cordova-plugin-advanced-http", "link": true @@ -18565,6 +18570,12 @@ "version": "0.0.1", "dev": true, "license": "Apache-2.0" + }, + "src/plugins/webview": { + "name": "cordova-plugin-acode-webview", + "version": "1.0.0", + "dev": true, + "license": "Apache-2.0" } } } diff --git a/package.json b/package.json index 39fc2f685..e7de0b9f3 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "com.foxdebug.acode.rk.customtabs": {}, "cordova-plugin-system": {}, "cordova-plugin-crashhandler": {}, - "cordova-plugin-advanced-http": {} + "cordova-plugin-advanced-http": {}, + "cordova-plugin-acode-webview": {} }, "platforms": [ "android" @@ -84,6 +85,7 @@ "com.foxdebug.acode.rk.plugin.plugincontext": "file:src/plugins/pluginContext", "cordova-android": "^15.0.0", "cordova-clipboard": "^1.3.0", + "cordova-plugin-acode-webview": "file:src/plugins/webview", "cordova-plugin-advanced-http": "file:src/plugins/cordova-plugin-advanced-http", "cordova-plugin-browser": "file:src/plugins/browser", "cordova-plugin-buildinfo": "file:src/plugins/cordova-plugin-buildinfo", diff --git a/src/index.d.ts b/src/index.d.ts index 7f6cbaeb2..2b0c2b1e1 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -8,7 +8,10 @@ declare const PLUGIN_DIR: string; declare const KEYBINDING_FILE: string; declare const ANDROID_SDK_INT: number; declare const DOES_SUPPORT_THEME: boolean; -declare const acode: object; +declare const acode: { + webview: AcodeWebViewAPI; + [key: string]: unknown; +}; interface Window { ASSETS_DIRECTORY: string; @@ -107,3 +110,36 @@ interface AcodeFile { declare global { var Executor: Executor | undefined; } + +interface WebViewOptions { + title?: string; + mode?: "fullscreen" | "window" | "panel" | "hidden"; + width?: number; + height?: number; + x?: number; + y?: number; + allowNavigation?: boolean; + allowDownloads?: boolean; + visible?: boolean; +} + +interface AcodeWebView { + readonly id: string; + readonly options: WebViewOptions; + loadURL(url: string): Promise; + loadHTML(html: string): Promise; + evaluate(js: string): Promise; + onMessage(callback: (message: unknown) => void): void; + offMessage(callback: (message: unknown) => void): void; + on(event: string, callback: (event: string, data?: unknown) => void): void; + off(event: string, callback: (event: string, data?: unknown) => void): void; + postMessage(message: unknown): Promise; + show(): Promise; + hide(): Promise; + reload(): Promise; + destroy(): Promise; +} + +interface AcodeWebViewAPI { + create(options?: WebViewOptions): Promise; +} diff --git a/src/lib/acode.js b/src/lib/acode.js index 81c82f08a..341e2d7de 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -73,6 +73,7 @@ import encodings, { decode, encode } from "utils/encodings"; import helpers from "utils/helpers"; import KeyboardEvent from "utils/keyboardEvent"; import Url from "utils/Url"; +import webview from "./webview"; import config from "./config"; class Acode { @@ -396,6 +397,7 @@ class Acode { this.define("selectionMenu", selectionMenu); this.define("sidebarApps", sidebarAppsModule); this.define("terminal", terminalModule); + this.define("webview", webview); this.define("codemirror", codemirrorModule); this.define("@codemirror/autocomplete", cmAutocomplete); this.define("@codemirror/commands", cmCommands); diff --git a/src/lib/webview.js b/src/lib/webview.js new file mode 100644 index 000000000..b237fe38d --- /dev/null +++ b/src/lib/webview.js @@ -0,0 +1,155 @@ +import nativeBridge from "../plugins/webview/www/webview"; + +let initialized = false; +const instances = new Map(); +const eventCallbacks = new Map(); + +function ensureInit() { + if (!initialized) { + nativeBridge.setMessageCallback((payload) => { + const { id, message, event, data } = payload; + + if (event) { + const callbacks = eventCallbacks.get(id); + if (callbacks) { + callbacks.forEach((cb) => { + try { + cb(event, data); + } catch (e) { + console.error("WebView event callback error:", e); + } + }); + } + return; + } + + if (message !== undefined) { + const instance = instances.get(id); + if (instance && instance._messageCallbacks) { + let parsed = message; + try { + parsed = JSON.parse(message); + } catch (_) {} + instance._messageCallbacks.forEach((cb) => { + try { + cb(parsed); + } catch (e) { + console.error("WebView message callback error:", e); + } + }); + } + } + }); + initialized = true; + } +} + +class WebView { + constructor(id, options = {}) { + this.id = id; + this.options = options; + this._messageCallbacks = []; + this._eventCallbacks = []; + this._destroyed = false; + + instances.set(id, this); + eventCallbacks.set(id, this._eventCallbacks); + } + + async loadURL(url) { + this._checkDestroyed(); + await nativeBridge.loadURL(this.id, url); + } + + async loadHTML(html) { + this._checkDestroyed(); + await nativeBridge.loadHTML(this.id, html); + } + + async evaluate(js) { + this._checkDestroyed(); + return await nativeBridge.evaluate(this.id, js); + } + + onMessage(callback) { + this._checkDestroyed(); + if (typeof callback === "function") { + this._messageCallbacks.push(callback); + } + } + + offMessage(callback) { + this._messageCallbacks = this._messageCallbacks.filter((cb) => cb !== callback); + } + + on(event, callback) { + this._checkDestroyed(); + if (typeof callback === "function") { + this._eventCallbacks.push({ event, callback }); + } + } + + off(event, callback) { + this._eventCallbacks = this._eventCallbacks.filter( + (cb) => !(cb.event === event && cb.callback === callback) + ); + } + + async postMessage(message) { + this._checkDestroyed(); + await nativeBridge.postMessage(this.id, message); + } + + async show() { + this._checkDestroyed(); + await nativeBridge.show(this.id); + } + + async hide() { + this._checkDestroyed(); + await nativeBridge.hide(this.id); + } + + async reload() { + this._checkDestroyed(); + await nativeBridge.reload(this.id); + } + + async destroy() { + this._checkDestroyed(); + this._destroyed = true; + await nativeBridge.destroy(this.id); + instances.delete(this.id); + eventCallbacks.delete(this.id); + this._messageCallbacks = []; + this._eventCallbacks = []; + } + + _checkDestroyed() { + if (this._destroyed) { + throw new Error("WebView has been destroyed"); + } + } +} + +const webviewAPI = { + async create(options = {}) { + ensureInit(); + + const id = await nativeBridge.create({ + title: options.title || "", + mode: options.mode || "hidden", + width: options.width || 0, + height: options.height || 0, + x: options.x || 0, + y: options.y || 0, + allowNavigation: options.allowNavigation !== false, + allowDownloads: options.allowDownloads === true, + visible: options.visible !== false, + }); + + return new WebView(id, options); + }, +}; + +export default webviewAPI; diff --git a/src/plugins/webview/package.json b/src/plugins/webview/package.json new file mode 100644 index 000000000..673be361c --- /dev/null +++ b/src/plugins/webview/package.json @@ -0,0 +1,11 @@ +{ + "name": "cordova-plugin-acode-webview", + "version": "1.0.0", + "description": "Acode WebView Plugin API", + "main": "", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "Apache-2.0" +} diff --git a/src/plugins/webview/plugin.xml b/src/plugins/webview/plugin.xml new file mode 100644 index 000000000..c7a6ff773 --- /dev/null +++ b/src/plugins/webview/plugin.xml @@ -0,0 +1,26 @@ + + + cordova-plugin-acode-webview + Acode WebView Plugin API - Create and manage isolated WebView instances + Apache 2.0 + + + + + + + + + + + + + + + + + + + + diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java new file mode 100644 index 000000000..7c9c337f8 --- /dev/null +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java @@ -0,0 +1,198 @@ +package com.foxdebug.webview; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.Insets; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowInsetsController; +import android.webkit.JavascriptInterface; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.graphics.drawable.GradientDrawable; + +public class WebViewActivity extends Activity { + + private static final String TAG = "WebViewActivity"; + private static WebViewPlugin plugin; + + private WebView webView; + private String webviewId; + private String title; + private boolean allowNavigation; + private boolean allowDownloads; + + public static void setPlugin(WebViewPlugin p) { + plugin = p; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + webviewId = intent.getStringExtra("webviewId"); + title = intent.getStringExtra("title"); + allowNavigation = intent.getBooleanExtra("allowNavigation", true); + allowDownloads = intent.getBooleanExtra("allowDownloads", false); + + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + TextView titleBar = new TextView(this); + titleBar.setText(title != null ? title : "WebView"); + titleBar.setTextColor(Color.WHITE); + titleBar.setPadding(dpToPx(16), dpToPx(8), dpToPx(16), dpToPx(8)); + titleBar.setTextSize(16); + titleBar.setBackgroundColor(Color.argb(255, 33, 33, 33)); + + GradientDrawable closeBg = new GradientDrawable(); + closeBg.setColor(Color.argb(255, 80, 80, 80)); + closeBg.setCornerRadius(dpToPx(4)); + + TextView closeButton = new TextView(this); + closeButton.setText("✕"); + closeButton.setTextColor(Color.WHITE); + closeButton.setTextSize(18); + closeButton.setPadding(dpToPx(12), dpToPx(4), dpToPx(12), dpToPx(4)); + closeButton.setBackground(closeBg); + closeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + finish(); + } + }); + + LinearLayout titleLayout = new LinearLayout(this); + titleLayout.setOrientation(LinearLayout.HORIZONTAL); + titleLayout.setBackgroundColor(Color.argb(255, 33, 33, 33)); + titleLayout.setPadding(dpToPx(12), 0, dpToPx(12), 0); + + LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1 + ); + titleLayout.addView(titleBar, titleParams); + titleLayout.addView(closeButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + webView = new WebView(this); + webView.setWebViewClient(new FullscreenWebViewClient()); + webView.addJavascriptInterface(new JsBridge(), "AcodeWebViewNative"); + + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowContentAccess(true); + settings.setDisplayZoomControls(false); + settings.setLoadWithOverviewMode(true); + settings.setUseWideViewPort(true); + settings.setAllowFileAccess(true); + + String bridgeJs = + "(function() {" + + " window.webview = window.webview || {};" + + " window.webview._callbacks = [];" + + " window.webview.onMessage = function(cb) { window.webview._callbacks.push(cb); };" + + " window.webview.postMessage = function(msg) {" + + " var data = typeof msg === 'string' ? msg : JSON.stringify(msg);" + + " window.AcodeWebViewNative.postMessage(data);" + + " };" + + " window.webview.offMessage = function(cb) {" + + " window.webview._callbacks = window.webview._callbacks.filter(function(c) { return c !== cb; });" + + " };" + + "})();"; + + webView.evaluateJavascript(bridgeJs, null); + + FrameLayout webViewContainer = new FrameLayout(this); + webViewContainer.setBackgroundColor(Color.WHITE); + webViewContainer.addView(webView, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + layout.addView(titleLayout); + layout.addView(webViewContainer, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, 1 + )); + + setContentView(layout); + + if (Build.VERSION.SDK_INT >= 30) { + getWindow().setDecorFitsSystemWindows(false); + } + + WebViewInstance instance = plugin != null ? plugin.getInstance(webviewId) : null; + if (instance != null) { + instance.createWebView(this); + webView = instance.getWebView(); + webViewContainer.removeAllViews(); + webViewContainer.addView(webView, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + } + } + + @Override + public void onBackPressed() { + if (webView != null && webView.canGoBack()) { + webView.goBack(); + } else { + finish(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (plugin != null) { + plugin.sendEventToCordova(webviewId, "closed", null); + } + } + + private int dpToPx(int dp) { + return (int) (dp * getResources().getDisplayMetrics().density); + } + + private class FullscreenWebViewClient extends WebViewClient { + @Override + public boolean shouldOverrideUrlLoading(WebView view, android.webkit.WebResourceRequest request) { + if (!allowNavigation) { + return true; + } + return false; + } + } + + private class JsBridge { + @JavascriptInterface + public void postMessage(String message) { + if (plugin != null) { + plugin.sendMessageToCordova(webviewId, message); + } + } + + @JavascriptInterface + public String getWebViewId() { + return webviewId; + } + } +} diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java new file mode 100644 index 000000000..6844c6b13 --- /dev/null +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java @@ -0,0 +1,423 @@ +package com.foxdebug.webview; + +import android.app.Activity; +import android.content.DialogInterface; +import android.graphics.Color; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.webkit.DownloadListener; +import android.webkit.JavascriptInterface; +import android.webkit.URLUtil; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.app.AlertDialog; +import android.app.DownloadManager; +import android.content.Context; +import android.net.Uri; +import android.os.Environment; +import android.widget.FrameLayout; +import android.widget.Toast; +import org.apache.cordova.CallbackContext; +import org.json.JSONException; +import org.json.JSONObject; + +public class WebViewInstance { + + private static final String TAG = "WebViewInstance"; + + final String id; + final String mode; + final String title; + final int width; + final int height; + final int x; + final int y; + final boolean allowNavigation; + final boolean allowDownloads; + final boolean initiallyVisible; + final WebViewPlugin plugin; + + private WebView webView; + private FrameLayout container; + private boolean isDestroyed = false; + + WebViewInstance( + String id, String mode, String title, + int width, int height, int x, int y, + boolean allowNavigation, boolean allowDownloads, + boolean initiallyVisible, + Activity activity, + WebViewPlugin plugin + ) { + this.id = id; + this.mode = mode; + this.title = title; + this.width = width; + this.height = height; + this.x = x; + this.y = y; + this.allowNavigation = allowNavigation; + this.allowDownloads = allowDownloads; + this.initiallyVisible = initiallyVisible; + this.plugin = plugin; + } + + public WebView getWebView() { + return webView; + } + + void createWebView(Activity activity) { + webView = new WebView(activity); + + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowContentAccess(true); + settings.setDisplayZoomControls(false); + settings.setLoadWithOverviewMode(true); + settings.setUseWideViewPort(true); + settings.setAllowFileAccess(true); + settings.setAllowFileAccessFromFileURLs(true); + settings.setAllowUniversalAccessFromFileURLs(true); + + webView.setWebViewClient(new InstanceWebViewClient()); + webView.setWebChromeClient(new InstanceWebChromeClient()); + webView.setFocusable(true); + webView.setFocusableInTouchMode(true); + + webView.addJavascriptInterface(new JsBridge(), "AcodeWebViewNative"); + + if (allowDownloads) { + webView.setDownloadListener(new InstanceDownloadListener(activity)); + } + + String bridgeJs = + "(function() {" + + " window.webview = window.webview || {};" + + " window.webview._callbacks = [];" + + " window.webview.onMessage = function(cb) { window.webview._callbacks.push(cb); };" + + " window.webview.postMessage = function(msg) {" + + " var data = typeof msg === 'string' ? msg : JSON.stringify(msg);" + + " window.AcodeWebViewNative.postMessage(data);" + + " };" + + " window.webview.offMessage = function(cb) {" + + " window.webview._callbacks = window.webview._callbacks.filter(function(c) { return c !== cb; });" + + " };" + + "})();"; + + webView.evaluateJavascript(bridgeJs, null); + } + + void attachToActivity(Activity activity) { + container = new FrameLayout(activity); + + FrameLayout.LayoutParams webViewParams = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ); + + container.setBackgroundColor(Color.argb(180, 0, 0, 0)); + + if (mode.equals("window")) { + int w = width > 0 ? width : ViewGroup.LayoutParams.WRAP_CONTENT; + int h = height > 0 ? height : ViewGroup.LayoutParams.WRAP_CONTENT; + + webViewParams = new FrameLayout.LayoutParams( + w > 0 ? dpToPx(activity, w) : ViewGroup.LayoutParams.MATCH_PARENT, + h > 0 ? dpToPx(activity, h) : ViewGroup.LayoutParams.MATCH_PARENT + ); + webViewParams.gravity = Gravity.CENTER; + webViewParams.setMargins(dpToPx(activity, 16), dpToPx(activity, 48), dpToPx(activity, 16), dpToPx(activity, 48)); + + container.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + hideInner(); + } + }); + } else if (mode.equals("panel")) { + webViewParams = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + height > 0 ? dpToPx(activity, height) : (int)(getScreenHeight(activity) * 0.4) + ); + webViewParams.gravity = Gravity.BOTTOM; + } + + container.addView(webView, webViewParams); + + ViewGroup rootView = activity.findViewById(android.R.id.content); + if (rootView instanceof FrameLayout) { + rootView.addView(container, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + } + } + + void loadURL(String url) { + if (isDestroyed || webView == null) return; + final String safeUrl = (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) + ? url : "http://" + url; + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + webView.loadUrl(safeUrl); + } + }); + } + + void loadHTML(String html) { + if (isDestroyed || webView == null) return; + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null); + } + }); + } + + void evaluate(String js, final CallbackContext callbackContext) { + if (isDestroyed || webView == null) { + callbackContext.error("WebView destroyed"); + return; + } + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + webView.evaluateJavascript(js, new ValueCallback() { + @Override + public void onReceiveValue(String value) { + if (value != null && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1) + .replace("\\\"", "\"") + .replace("\\\\", "\\"); + } + callbackContext.success(value); + } + }); + } + }); + } + + void postMessage(String message, CallbackContext callbackContext) { + if (isDestroyed || webView == null) { + callbackContext.error("WebView destroyed"); + return; + } + String escaped = message + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "\\r"); + + String js = "if(window.webview&&window.webview._callbacks){" + + "var msg=" + safeParseJSON(message) + ";" + + "window.webview._callbacks.forEach(function(cb){try{cb(msg)}catch(e){console.error(e)}});" + + "}"; + + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + webView.evaluateJavascript(js, null); + callbackContext.success(); + } + }); + } + + private String safeParseJSON(String message) { + try { + new JSONObject(message); + return message; + } catch (JSONException e1) { + try { + new org.json.JSONArray(message); + return message; + } catch (JSONException e2) { + return "\"" + message.replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } + } + } + + void show(final CallbackContext callbackContext) { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + if (isDestroyed || container == null) { + if (callbackContext != null) callbackContext.error("Cannot show"); + return; + } + container.setVisibility(View.VISIBLE); + if (callbackContext != null) callbackContext.success(); + } + }); + } + + void hide(final CallbackContext callbackContext) { + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + if (isDestroyed || container == null) { + if (callbackContext != null) callbackContext.error("Cannot hide"); + return; + } + container.setVisibility(View.GONE); + if (callbackContext != null) callbackContext.success(); + } + }); + } + + private void hideInner() { + if (container != null) { + container.setVisibility(View.GONE); + } + } + + void reload(CallbackContext callbackContext) { + if (isDestroyed || webView == null) { + callbackContext.error("WebView destroyed"); + return; + } + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + webView.reload(); + callbackContext.success(); + } + }); + } + + void destroy() { + isDestroyed = true; + + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + if (container != null && container.getParent() != null) { + ((ViewGroup) container.getParent()).removeView(container); + } + if (webView != null) { + webView.removeJavascriptInterface("AcodeWebViewNative"); + webView.setWebViewClient(null); + webView.setWebChromeClient(null); + webView.loadUrl("about:blank"); + webView.destroy(); + } + webView = null; + container = null; + } + }); + } + + void onPageFinished() { + try { + JSONObject data = new JSONObject(); + data.put("url", webView.getUrl()); + data.put("title", webView.getTitle()); + plugin.sendEventToCordova(id, "pageFinished", data); + } catch (Exception e) { + Log.e(TAG, "onPageFinished error", e); + } + } + + private static int dpToPx(Context context, int dp) { + return (int) (dp * context.getResources().getDisplayMetrics().density); + } + + private static int getScreenHeight(Activity activity) { + return activity.getResources().getDisplayMetrics().heightPixels; + } + + private class InstanceWebViewClient extends WebViewClient { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + if (!allowNavigation) { + return true; + } + return false; + } + + @Override + public void onPageFinished(WebView view, String url) { + super.onPageFinished(view, url); + WebViewInstance.this.onPageFinished(); + } + } + + private class InstanceWebChromeClient extends WebChromeClient { + @Override + public void onReceivedTitle(WebView view, String pageTitle) { + super.onReceivedTitle(view, pageTitle); + try { + JSONObject data = new JSONObject(); + data.put("title", pageTitle); + plugin.sendEventToCordova(id, "titleChanged", data); + } catch (Exception e) { + Log.e(TAG, "onReceivedTitle error", e); + } + } + } + + private class InstanceDownloadListener implements DownloadListener { + private final Context context; + + InstanceDownloadListener(Context context) { + this.context = context; + } + + @Override + public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) { + final String fileName = URLUtil.guessFileName(url, contentDisposition, mimeType); + + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + new AlertDialog.Builder(context) + .setTitle("Download file") + .setMessage("Do you want to download \"" + fileName + "\"?") + .setPositiveButton("Yes", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); + request.setMimeType(mimeType); + request.addRequestHeader("User-Agent", userAgent); + request.setDescription("Downloading file..."); + request.setTitle(fileName); + request.allowScanningByMediaScanner(); + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); + + DownloadManager dm = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + dm.enqueue(request); + Toast.makeText(context, "Download started...", Toast.LENGTH_SHORT).show(); + } + }) + .setNegativeButton("Cancel", null) + .show(); + } + }); + } + } + + public class JsBridge { + @JavascriptInterface + public void postMessage(String message) { + plugin.sendMessageToCordova(id, message); + } + + @JavascriptInterface + public String getWebViewId() { + return id; + } + } +} diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java new file mode 100644 index 000000000..e060d007f --- /dev/null +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java @@ -0,0 +1,266 @@ +package com.foxdebug.webview; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Color; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.webkit.JavascriptInterface; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.FrameLayout; +import java.util.HashMap; +import java.util.UUID; +import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaPlugin; +import org.apache.cordova.PluginResult; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class WebViewPlugin extends CordovaPlugin { + + private static final String TAG = "AcodeWebView"; + private static WebViewPlugin instance; + + private final HashMap instances = new HashMap<>(); + private CallbackContext messageCallback; + + @Override + protected void pluginInitialize() { + instance = this; + } + + public static WebViewPlugin getInstance() { + return instance; + } + + @Override + public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { + try { + switch (action) { + case "setMessageCallback": + this.messageCallback = callbackContext; + PluginResult keepResult = new PluginResult(PluginResult.Status.NO_RESULT); + keepResult.setKeepCallback(true); + callbackContext.sendPluginResult(keepResult); + return true; + case "create": + create(args.getJSONObject(0), callbackContext); + return true; + case "loadURL": + loadURL(args.getString(0), args.getString(1), callbackContext); + return true; + case "loadHTML": + loadHTML(args.getString(0), args.getString(1), callbackContext); + return true; + case "evaluate": + evaluate(args.getString(0), args.getString(1), callbackContext); + return true; + case "postMessage": + postMessage(args.getString(0), args.getString(1), callbackContext); + return true; + case "show": + show(args.getString(0), callbackContext); + return true; + case "hide": + hide(args.getString(0), callbackContext); + return true; + case "reload": + reload(args.getString(0), callbackContext); + return true; + case "destroy": + destroy(args.getString(0), callbackContext); + return true; + default: + callbackContext.error("Unknown action: " + action); + return true; + } + } catch (Exception e) { + Log.e(TAG, "Error: " + action, e); + callbackContext.error(e.getMessage()); + } + return true; + } + + private String generateId() { + return "wv_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + private void create(JSONObject options, final CallbackContext callbackContext) throws JSONException { + final String id = generateId(); + final String mode = options.optString("mode", "hidden"); + final String title = options.optString("title", "WebView"); + final int width = options.optInt("width", 0); + final int height = options.optInt("height", 0); + final int x = options.optInt("x", 0); + final int y = options.optInt("y", 0); + final boolean allowNavigation = options.optBoolean("allowNavigation", true); + final boolean allowDownloads = options.optBoolean("allowDownloads", false); + final boolean visible = options.optBoolean("visible", true); + + final String effectiveMode = visible ? mode : "hidden"; + + cordova.getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + WebViewInstance instance = new WebViewInstance( + id, effectiveMode, title, + width, height, x, y, + allowNavigation, allowDownloads, + visible, cordova.getActivity(), + WebViewPlugin.this + ); + + if (effectiveMode.equals("fullscreen")) { + instances.put(id, instance); + launchFullscreenActivity(id, title, allowNavigation, allowDownloads); + callbackContext.success(id); + return; + } + + instance.createWebView(cordova.getActivity()); + + if (effectiveMode.equals("window") || effectiveMode.equals("panel")) { + instance.attachToActivity(cordova.getActivity()); + } + + instances.put(id, instance); + callbackContext.success(id); + } catch (Exception e) { + Log.e(TAG, "Create error: " + e.getMessage(), e); + callbackContext.error(e.getMessage()); + } + } + }); + } + + private void launchFullscreenActivity( + String id, String title, boolean allowNavigation, boolean allowDownloads + ) { + Intent intent = new Intent(cordova.getActivity(), WebViewActivity.class); + intent.putExtra("webviewId", id); + intent.putExtra("title", title); + intent.putExtra("allowNavigation", allowNavigation); + intent.putExtra("allowDownloads", allowDownloads); + cordova.getActivity().startActivity(intent); + } + + public WebViewInstance getInstance(String id) { + return instances.get(id); + } + + private void loadURL(String id, String url, CallbackContext callbackContext) { + WebViewInstance instance = getInstance(id); + if (instance == null) { callbackContext.error("WebView not found: " + id); return; } + instance.loadURL(url); + callbackContext.success(); + } + + private void loadHTML(String id, String html, CallbackContext callbackContext) { + WebViewInstance instance = getInstance(id); + if (instance == null) { callbackContext.error("WebView not found: " + id); return; } + instance.loadHTML(html); + callbackContext.success(); + } + + private void evaluate(String id, String js, CallbackContext callbackContext) { + WebViewInstance instance = getInstance(id); + if (instance == null) { callbackContext.error("WebView not found: " + id); return; } + instance.evaluate(js, callbackContext); + } + + private void postMessage(String id, String message, CallbackContext callbackContext) { + WebViewInstance instance = getInstance(id); + if (instance == null) { callbackContext.error("WebView not found: " + id); return; } + instance.postMessage(message, callbackContext); + } + + private void show(String id, CallbackContext callbackContext) { + WebViewInstance instance = getInstance(id); + if (instance == null) { callbackContext.error("WebView not found: " + id); return; } + instance.show(callbackContext); + } + + private void hide(String id, CallbackContext callbackContext) { + WebViewInstance instance = getInstance(id); + if (instance == null) { callbackContext.error("WebView not found: " + id); return; } + instance.hide(callbackContext); + } + + private void reload(String id, CallbackContext callbackContext) { + WebViewInstance instance = getInstance(id); + if (instance == null) { callbackContext.error("WebView not found: " + id); return; } + instance.reload(callbackContext); + } + + private void destroy(String id, CallbackContext callbackContext) { + final WebViewInstance instance = instances.remove(id); + if (instance == null) { callbackContext.error("WebView not found: " + id); return; } + + cordova.getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + instance.destroy(); + callbackContext.success(); + } + }); + } + + public void sendMessageToCordova(String id, String message) { + if (messageCallback == null) return; + + try { + JSONObject payload = new JSONObject(); + payload.put("id", id); + payload.put("message", message); + + PluginResult result = new PluginResult(PluginResult.Status.OK, payload); + result.setKeepCallback(true); + messageCallback.sendPluginResult(result); + } catch (JSONException e) { + Log.e(TAG, "Error building message payload", e); + } + } + + public void sendEventToCordova(String id, String event, JSONObject data) { + if (messageCallback == null) return; + + try { + JSONObject payload = new JSONObject(); + payload.put("id", id); + payload.put("event", event); + if (data != null) { + payload.put("data", data); + } + + PluginResult result = new PluginResult(PluginResult.Status.OK, payload); + result.setKeepCallback(true); + messageCallback.sendPluginResult(result); + } catch (JSONException e) { + Log.e(TAG, "Error building event payload", e); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + for (WebViewInstance instance : instances.values()) { + try { instance.destroy(); } catch (Exception ignored) {} + } + instances.clear(); + instance = null; + } +} diff --git a/src/plugins/webview/www/webview.js b/src/plugins/webview/www/webview.js new file mode 100644 index 000000000..b1935dddf --- /dev/null +++ b/src/plugins/webview/www/webview.js @@ -0,0 +1,88 @@ +const SERVICE = "AcodeWebView"; + +let messageCallback = null; + +function setMessageCallback(callback) { + messageCallback = callback; + cordova.exec( + (payload) => { + if (messageCallback) { + messageCallback(payload); + } + }, + (error) => { + console.error("WebView message callback error:", error); + }, + SERVICE, + "setMessageCallback", + [] + ); +} + +function create(options) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, SERVICE, "create", [options || {}]); + }); +} + +function loadURL(id, url) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, SERVICE, "loadURL", [id, url]); + }); +} + +function loadHTML(id, html) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, SERVICE, "loadHTML", [id, html]); + }); +} + +function evaluate(id, js) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, SERVICE, "evaluate", [id, js]); + }); +} + +function postMessage(id, message) { + return new Promise((resolve, reject) => { + const payload = typeof message === "string" ? message : JSON.stringify(message); + cordova.exec(resolve, reject, SERVICE, "postMessage", [id, payload]); + }); +} + +function show(id) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, SERVICE, "show", [id]); + }); +} + +function hide(id) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, SERVICE, "hide", [id]); + }); +} + +function reload(id) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, SERVICE, "reload", [id]); + }); +} + +function destroy(id) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, SERVICE, "destroy", [id]); + }); +} + +export default { + setMessageCallback, + create, + loadURL, + loadHTML, + evaluate, + postMessage, + show, + hide, + reload, + destroy, +}; From fdbca224b49949e0bfe2dcfd38170f0dc3be1188 Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Wed, 22 Jul 2026 19:00:34 +0530 Subject: [PATCH 2/6] format --- src/lib/acode.js | 2 +- src/lib/webview.js | 280 +++++++++++++++++++++++---------------------- 2 files changed, 142 insertions(+), 140 deletions(-) diff --git a/src/lib/acode.js b/src/lib/acode.js index 341e2d7de..3e0ecf694 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -73,8 +73,8 @@ import encodings, { decode, encode } from "utils/encodings"; import helpers from "utils/helpers"; import KeyboardEvent from "utils/keyboardEvent"; import Url from "utils/Url"; -import webview from "./webview"; import config from "./config"; +import webview from "./webview"; class Acode { #modules = {}; diff --git a/src/lib/webview.js b/src/lib/webview.js index b237fe38d..70965c27b 100644 --- a/src/lib/webview.js +++ b/src/lib/webview.js @@ -5,151 +5,153 @@ const instances = new Map(); const eventCallbacks = new Map(); function ensureInit() { - if (!initialized) { - nativeBridge.setMessageCallback((payload) => { - const { id, message, event, data } = payload; - - if (event) { - const callbacks = eventCallbacks.get(id); - if (callbacks) { - callbacks.forEach((cb) => { - try { - cb(event, data); - } catch (e) { - console.error("WebView event callback error:", e); - } - }); - } - return; - } - - if (message !== undefined) { - const instance = instances.get(id); - if (instance && instance._messageCallbacks) { - let parsed = message; - try { - parsed = JSON.parse(message); - } catch (_) {} - instance._messageCallbacks.forEach((cb) => { - try { - cb(parsed); - } catch (e) { - console.error("WebView message callback error:", e); - } - }); - } - } - }); - initialized = true; - } + if (!initialized) { + nativeBridge.setMessageCallback((payload) => { + const { id, message, event, data } = payload; + + if (event) { + const callbacks = eventCallbacks.get(id); + if (callbacks) { + callbacks.forEach((cb) => { + try { + cb(event, data); + } catch (e) { + console.error("WebView event callback error:", e); + } + }); + } + return; + } + + if (message !== undefined) { + const instance = instances.get(id); + if (instance && instance._messageCallbacks) { + let parsed = message; + try { + parsed = JSON.parse(message); + } catch (_) {} + instance._messageCallbacks.forEach((cb) => { + try { + cb(parsed); + } catch (e) { + console.error("WebView message callback error:", e); + } + }); + } + } + }); + initialized = true; + } } class WebView { - constructor(id, options = {}) { - this.id = id; - this.options = options; - this._messageCallbacks = []; - this._eventCallbacks = []; - this._destroyed = false; - - instances.set(id, this); - eventCallbacks.set(id, this._eventCallbacks); - } - - async loadURL(url) { - this._checkDestroyed(); - await nativeBridge.loadURL(this.id, url); - } - - async loadHTML(html) { - this._checkDestroyed(); - await nativeBridge.loadHTML(this.id, html); - } - - async evaluate(js) { - this._checkDestroyed(); - return await nativeBridge.evaluate(this.id, js); - } - - onMessage(callback) { - this._checkDestroyed(); - if (typeof callback === "function") { - this._messageCallbacks.push(callback); - } - } - - offMessage(callback) { - this._messageCallbacks = this._messageCallbacks.filter((cb) => cb !== callback); - } - - on(event, callback) { - this._checkDestroyed(); - if (typeof callback === "function") { - this._eventCallbacks.push({ event, callback }); - } - } - - off(event, callback) { - this._eventCallbacks = this._eventCallbacks.filter( - (cb) => !(cb.event === event && cb.callback === callback) - ); - } - - async postMessage(message) { - this._checkDestroyed(); - await nativeBridge.postMessage(this.id, message); - } - - async show() { - this._checkDestroyed(); - await nativeBridge.show(this.id); - } - - async hide() { - this._checkDestroyed(); - await nativeBridge.hide(this.id); - } - - async reload() { - this._checkDestroyed(); - await nativeBridge.reload(this.id); - } - - async destroy() { - this._checkDestroyed(); - this._destroyed = true; - await nativeBridge.destroy(this.id); - instances.delete(this.id); - eventCallbacks.delete(this.id); - this._messageCallbacks = []; - this._eventCallbacks = []; - } - - _checkDestroyed() { - if (this._destroyed) { - throw new Error("WebView has been destroyed"); - } - } + constructor(id, options = {}) { + this.id = id; + this.options = options; + this._messageCallbacks = []; + this._eventCallbacks = []; + this._destroyed = false; + + instances.set(id, this); + eventCallbacks.set(id, this._eventCallbacks); + } + + async loadURL(url) { + this._checkDestroyed(); + await nativeBridge.loadURL(this.id, url); + } + + async loadHTML(html) { + this._checkDestroyed(); + await nativeBridge.loadHTML(this.id, html); + } + + async evaluate(js) { + this._checkDestroyed(); + return await nativeBridge.evaluate(this.id, js); + } + + onMessage(callback) { + this._checkDestroyed(); + if (typeof callback === "function") { + this._messageCallbacks.push(callback); + } + } + + offMessage(callback) { + this._messageCallbacks = this._messageCallbacks.filter( + (cb) => cb !== callback, + ); + } + + on(event, callback) { + this._checkDestroyed(); + if (typeof callback === "function") { + this._eventCallbacks.push({ event, callback }); + } + } + + off(event, callback) { + this._eventCallbacks = this._eventCallbacks.filter( + (cb) => !(cb.event === event && cb.callback === callback), + ); + } + + async postMessage(message) { + this._checkDestroyed(); + await nativeBridge.postMessage(this.id, message); + } + + async show() { + this._checkDestroyed(); + await nativeBridge.show(this.id); + } + + async hide() { + this._checkDestroyed(); + await nativeBridge.hide(this.id); + } + + async reload() { + this._checkDestroyed(); + await nativeBridge.reload(this.id); + } + + async destroy() { + this._checkDestroyed(); + this._destroyed = true; + await nativeBridge.destroy(this.id); + instances.delete(this.id); + eventCallbacks.delete(this.id); + this._messageCallbacks = []; + this._eventCallbacks = []; + } + + _checkDestroyed() { + if (this._destroyed) { + throw new Error("WebView has been destroyed"); + } + } } const webviewAPI = { - async create(options = {}) { - ensureInit(); - - const id = await nativeBridge.create({ - title: options.title || "", - mode: options.mode || "hidden", - width: options.width || 0, - height: options.height || 0, - x: options.x || 0, - y: options.y || 0, - allowNavigation: options.allowNavigation !== false, - allowDownloads: options.allowDownloads === true, - visible: options.visible !== false, - }); - - return new WebView(id, options); - }, + async create(options = {}) { + ensureInit(); + + const id = await nativeBridge.create({ + title: options.title || "", + mode: options.mode || "hidden", + width: options.width || 0, + height: options.height || 0, + x: options.x || 0, + y: options.y || 0, + allowNavigation: options.allowNavigation !== false, + allowDownloads: options.allowDownloads === true, + visible: options.visible !== false, + }); + + return new WebView(id, options); + }, }; export default webviewAPI; From d58b26c1dcb47d26fdb019e9a859b73041fcb6fa Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Wed, 22 Jul 2026 19:03:30 +0530 Subject: [PATCH 3/6] fix: remove title bar and close button from fullscreen WebView Fullscreen mode is now a clean full-screen WebView with no chrome. Back button handles navigation and closing. --- .../com/foxdebug/webview/WebViewActivity.java | 157 +++++------------- .../com/foxdebug/webview/WebViewPlugin.java | 8 +- 2 files changed, 42 insertions(+), 123 deletions(-) diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java index 7c9c337f8..00ee0f07b 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java @@ -2,35 +2,22 @@ import android.app.Activity; import android.content.Intent; -import android.graphics.Color; -import android.graphics.Insets; import android.os.Build; import android.os.Bundle; -import android.util.Log; -import android.view.View; import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowInsets; -import android.view.WindowInsetsController; import android.webkit.JavascriptInterface; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.graphics.drawable.GradientDrawable; public class WebViewActivity extends Activity { - private static final String TAG = "WebViewActivity"; private static WebViewPlugin plugin; private WebView webView; private String webviewId; - private String title; private boolean allowNavigation; - private boolean allowDownloads; public static void setPlugin(WebViewPlugin p) { plugin = p; @@ -42,113 +29,56 @@ public void onCreate(Bundle savedInstanceState) { Intent intent = getIntent(); webviewId = intent.getStringExtra("webviewId"); - title = intent.getStringExtra("title"); allowNavigation = intent.getBooleanExtra("allowNavigation", true); - allowDownloads = intent.getBooleanExtra("allowDownloads", false); - LinearLayout layout = new LinearLayout(this); - layout.setOrientation(LinearLayout.VERTICAL); - layout.setLayoutParams(new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )); + WebViewInstance instance = plugin != null ? plugin.getInstance(webviewId) : null; - TextView titleBar = new TextView(this); - titleBar.setText(title != null ? title : "WebView"); - titleBar.setTextColor(Color.WHITE); - titleBar.setPadding(dpToPx(16), dpToPx(8), dpToPx(16), dpToPx(8)); - titleBar.setTextSize(16); - titleBar.setBackgroundColor(Color.argb(255, 33, 33, 33)); - - GradientDrawable closeBg = new GradientDrawable(); - closeBg.setColor(Color.argb(255, 80, 80, 80)); - closeBg.setCornerRadius(dpToPx(4)); - - TextView closeButton = new TextView(this); - closeButton.setText("✕"); - closeButton.setTextColor(Color.WHITE); - closeButton.setTextSize(18); - closeButton.setPadding(dpToPx(12), dpToPx(4), dpToPx(12), dpToPx(4)); - closeButton.setBackground(closeBg); - closeButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - finish(); - } - }); - - LinearLayout titleLayout = new LinearLayout(this); - titleLayout.setOrientation(LinearLayout.HORIZONTAL); - titleLayout.setBackgroundColor(Color.argb(255, 33, 33, 33)); - titleLayout.setPadding(dpToPx(12), 0, dpToPx(12), 0); - - LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( - 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1 - ); - titleLayout.addView(titleBar, titleParams); - titleLayout.addView(closeButton, new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - )); + if (instance != null && instance.getWebView() == null) { + instance.createWebView(this); + webView = instance.getWebView(); + } else if (instance != null) { + webView = instance.getWebView(); + } else { + webView = new WebView(this); + webView.addJavascriptInterface(new JsBridge(), "AcodeWebViewNative"); + + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowContentAccess(true); + settings.setDisplayZoomControls(false); + settings.setLoadWithOverviewMode(true); + settings.setUseWideViewPort(true); + settings.setAllowFileAccess(true); + + webView.setWebViewClient(new FullscreenWebViewClient()); + + String bridgeJs = + "(function() {" + + " window.webview = window.webview || {};" + + " window.webview._callbacks = [];" + + " window.webview.onMessage = function(cb) { window.webview._callbacks.push(cb); };" + + " window.webview.postMessage = function(msg) {" + + " var data = typeof msg === 'string' ? msg : JSON.stringify(msg);" + + " window.AcodeWebViewNative.postMessage(data);" + + " };" + + " window.webview.offMessage = function(cb) {" + + " window.webview._callbacks = window.webview._callbacks.filter(function(c) { return c !== cb; });" + + " };" + + "})();"; + webView.evaluateJavascript(bridgeJs, null); + } - webView = new WebView(this); - webView.setWebViewClient(new FullscreenWebViewClient()); - webView.addJavascriptInterface(new JsBridge(), "AcodeWebViewNative"); - - WebSettings settings = webView.getSettings(); - settings.setJavaScriptEnabled(true); - settings.setDomStorageEnabled(true); - settings.setAllowContentAccess(true); - settings.setDisplayZoomControls(false); - settings.setLoadWithOverviewMode(true); - settings.setUseWideViewPort(true); - settings.setAllowFileAccess(true); - - String bridgeJs = - "(function() {" + - " window.webview = window.webview || {};" + - " window.webview._callbacks = [];" + - " window.webview.onMessage = function(cb) { window.webview._callbacks.push(cb); };" + - " window.webview.postMessage = function(msg) {" + - " var data = typeof msg === 'string' ? msg : JSON.stringify(msg);" + - " window.AcodeWebViewNative.postMessage(data);" + - " };" + - " window.webview.offMessage = function(cb) {" + - " window.webview._callbacks = window.webview._callbacks.filter(function(c) { return c !== cb; });" + - " };" + - "})();"; - - webView.evaluateJavascript(bridgeJs, null); - - FrameLayout webViewContainer = new FrameLayout(this); - webViewContainer.setBackgroundColor(Color.WHITE); - webViewContainer.addView(webView, new FrameLayout.LayoutParams( + FrameLayout container = new FrameLayout(this); + container.addView(webView, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT )); - - layout.addView(titleLayout); - layout.addView(webViewContainer, new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 0, 1 - )); - - setContentView(layout); + setContentView(container); if (Build.VERSION.SDK_INT >= 30) { getWindow().setDecorFitsSystemWindows(false); } - - WebViewInstance instance = plugin != null ? plugin.getInstance(webviewId) : null; - if (instance != null) { - instance.createWebView(this); - webView = instance.getWebView(); - webViewContainer.removeAllViews(); - webViewContainer.addView(webView, new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )); - } } @Override @@ -168,17 +98,10 @@ protected void onDestroy() { } } - private int dpToPx(int dp) { - return (int) (dp * getResources().getDisplayMetrics().density); - } - private class FullscreenWebViewClient extends WebViewClient { @Override public boolean shouldOverrideUrlLoading(WebView view, android.webkit.WebResourceRequest request) { - if (!allowNavigation) { - return true; - } - return false; + return !allowNavigation; } } diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java index e060d007f..17b7e51aa 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java @@ -126,7 +126,7 @@ public void run() { if (effectiveMode.equals("fullscreen")) { instances.put(id, instance); - launchFullscreenActivity(id, title, allowNavigation, allowDownloads); + launchFullscreenActivity(id, allowNavigation); callbackContext.success(id); return; } @@ -147,14 +147,10 @@ public void run() { }); } - private void launchFullscreenActivity( - String id, String title, boolean allowNavigation, boolean allowDownloads - ) { + private void launchFullscreenActivity(String id, boolean allowNavigation) { Intent intent = new Intent(cordova.getActivity(), WebViewActivity.class); intent.putExtra("webviewId", id); - intent.putExtra("title", title); intent.putExtra("allowNavigation", allowNavigation); - intent.putExtra("allowDownloads", allowDownloads); cordova.getActivity().startActivity(intent); } From f30398a48081c7a1115f0160983780f6ef15b4c4 Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Wed, 22 Jul 2026 19:10:12 +0530 Subject: [PATCH 4/6] fix: lifecycle events, hidden show, fullscreen disconnect, and security - Fix event dispatch: filter callbacks by event name instead of calling {event,callback} objects as functions (webview.js) - Make hidden WebViews showable: attachToActivity() on first show() - Remove universal file access (setAllowFileAccessFromFileURLs, setAllowUniversalAccessFromFileURLs) for untrusted content - Clean up fullscreen path: remove throwaway WebView in Activity, register instance cleanup in onDestroy - Add deprecated shouldOverrideUrlLoading(String) for compat - Use Handler(Looper.getMainLooper()) instead of View.post() so hidden WebViews can process loadURL/evaluate without a window - Remove unused constructor params (title, x, y, initiallyVisible) --- src/lib/webview.js | 284 +++++++++--------- .../com/foxdebug/webview/WebViewActivity.java | 68 +---- .../com/foxdebug/webview/WebViewInstance.java | 102 +++---- .../com/foxdebug/webview/WebViewPlugin.java | 22 +- 4 files changed, 203 insertions(+), 273 deletions(-) diff --git a/src/lib/webview.js b/src/lib/webview.js index 70965c27b..ae96cb662 100644 --- a/src/lib/webview.js +++ b/src/lib/webview.js @@ -5,153 +5,155 @@ const instances = new Map(); const eventCallbacks = new Map(); function ensureInit() { - if (!initialized) { - nativeBridge.setMessageCallback((payload) => { - const { id, message, event, data } = payload; - - if (event) { - const callbacks = eventCallbacks.get(id); - if (callbacks) { - callbacks.forEach((cb) => { - try { - cb(event, data); - } catch (e) { - console.error("WebView event callback error:", e); - } - }); - } - return; - } - - if (message !== undefined) { - const instance = instances.get(id); - if (instance && instance._messageCallbacks) { - let parsed = message; - try { - parsed = JSON.parse(message); - } catch (_) {} - instance._messageCallbacks.forEach((cb) => { - try { - cb(parsed); - } catch (e) { - console.error("WebView message callback error:", e); - } - }); - } - } - }); - initialized = true; - } + if (!initialized) { + nativeBridge.setMessageCallback((payload) => { + const { id, message, event, data } = payload; + + if (event) { + const callbacks = eventCallbacks.get(id); + if (callbacks) { + callbacks.forEach((entry) => { + if (entry.event === event) { + try { + entry.callback(event, data); + } catch (e) { + console.error("WebView event callback error:", e); + } + } + }); + } + return; + } + + if (message !== undefined) { + const instance = instances.get(id); + if (instance && instance._messageCallbacks) { + let parsed = message; + try { + parsed = JSON.parse(message); + } catch (_) {} + instance._messageCallbacks.forEach((cb) => { + try { + cb(parsed); + } catch (e) { + console.error("WebView message callback error:", e); + } + }); + } + } + }); + initialized = true; + } } class WebView { - constructor(id, options = {}) { - this.id = id; - this.options = options; - this._messageCallbacks = []; - this._eventCallbacks = []; - this._destroyed = false; - - instances.set(id, this); - eventCallbacks.set(id, this._eventCallbacks); - } - - async loadURL(url) { - this._checkDestroyed(); - await nativeBridge.loadURL(this.id, url); - } - - async loadHTML(html) { - this._checkDestroyed(); - await nativeBridge.loadHTML(this.id, html); - } - - async evaluate(js) { - this._checkDestroyed(); - return await nativeBridge.evaluate(this.id, js); - } - - onMessage(callback) { - this._checkDestroyed(); - if (typeof callback === "function") { - this._messageCallbacks.push(callback); - } - } - - offMessage(callback) { - this._messageCallbacks = this._messageCallbacks.filter( - (cb) => cb !== callback, - ); - } - - on(event, callback) { - this._checkDestroyed(); - if (typeof callback === "function") { - this._eventCallbacks.push({ event, callback }); - } - } - - off(event, callback) { - this._eventCallbacks = this._eventCallbacks.filter( - (cb) => !(cb.event === event && cb.callback === callback), - ); - } - - async postMessage(message) { - this._checkDestroyed(); - await nativeBridge.postMessage(this.id, message); - } - - async show() { - this._checkDestroyed(); - await nativeBridge.show(this.id); - } - - async hide() { - this._checkDestroyed(); - await nativeBridge.hide(this.id); - } - - async reload() { - this._checkDestroyed(); - await nativeBridge.reload(this.id); - } - - async destroy() { - this._checkDestroyed(); - this._destroyed = true; - await nativeBridge.destroy(this.id); - instances.delete(this.id); - eventCallbacks.delete(this.id); - this._messageCallbacks = []; - this._eventCallbacks = []; - } - - _checkDestroyed() { - if (this._destroyed) { - throw new Error("WebView has been destroyed"); - } - } + constructor(id, options = {}) { + this.id = id; + this.options = options; + this._messageCallbacks = []; + this._eventCallbacks = []; + this._destroyed = false; + + instances.set(id, this); + eventCallbacks.set(id, this._eventCallbacks); + } + + async loadURL(url) { + this._checkDestroyed(); + await nativeBridge.loadURL(this.id, url); + } + + async loadHTML(html) { + this._checkDestroyed(); + await nativeBridge.loadHTML(this.id, html); + } + + async evaluate(js) { + this._checkDestroyed(); + return await nativeBridge.evaluate(this.id, js); + } + + onMessage(callback) { + this._checkDestroyed(); + if (typeof callback === "function") { + this._messageCallbacks.push(callback); + } + } + + offMessage(callback) { + this._messageCallbacks = this._messageCallbacks.filter( + (cb) => cb !== callback, + ); + } + + on(event, callback) { + this._checkDestroyed(); + if (typeof callback === "function") { + this._eventCallbacks.push({ event, callback }); + } + } + + off(event, callback) { + this._eventCallbacks = this._eventCallbacks.filter( + (entry) => !(entry.event === event && entry.callback === callback), + ); + } + + async postMessage(message) { + this._checkDestroyed(); + await nativeBridge.postMessage(this.id, message); + } + + async show() { + this._checkDestroyed(); + await nativeBridge.show(this.id); + } + + async hide() { + this._checkDestroyed(); + await nativeBridge.hide(this.id); + } + + async reload() { + this._checkDestroyed(); + await nativeBridge.reload(this.id); + } + + async destroy() { + this._checkDestroyed(); + this._destroyed = true; + await nativeBridge.destroy(this.id); + instances.delete(this.id); + eventCallbacks.delete(this.id); + this._messageCallbacks = []; + this._eventCallbacks = []; + } + + _checkDestroyed() { + if (this._destroyed) { + throw new Error("WebView has been destroyed"); + } + } } const webviewAPI = { - async create(options = {}) { - ensureInit(); - - const id = await nativeBridge.create({ - title: options.title || "", - mode: options.mode || "hidden", - width: options.width || 0, - height: options.height || 0, - x: options.x || 0, - y: options.y || 0, - allowNavigation: options.allowNavigation !== false, - allowDownloads: options.allowDownloads === true, - visible: options.visible !== false, - }); - - return new WebView(id, options); - }, + async create(options = {}) { + ensureInit(); + + const id = await nativeBridge.create({ + title: options.title || "", + mode: options.mode || "hidden", + width: options.width || 0, + height: options.height || 0, + x: options.x || 0, + y: options.y || 0, + allowNavigation: options.allowNavigation !== false, + allowDownloads: options.allowDownloads === true, + visible: options.visible !== false, + }); + + return new WebView(id, options); + }, }; export default webviewAPI; diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java index 00ee0f07b..54b7304c6 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java @@ -5,10 +5,7 @@ import android.os.Build; import android.os.Bundle; import android.view.ViewGroup; -import android.webkit.JavascriptInterface; -import android.webkit.WebSettings; import android.webkit.WebView; -import android.webkit.WebViewClient; import android.widget.FrameLayout; public class WebViewActivity extends Activity { @@ -17,7 +14,6 @@ public class WebViewActivity extends Activity { private WebView webView; private String webviewId; - private boolean allowNavigation; public static void setPlugin(WebViewPlugin p) { plugin = p; @@ -29,46 +25,16 @@ public void onCreate(Bundle savedInstanceState) { Intent intent = getIntent(); webviewId = intent.getStringExtra("webviewId"); - allowNavigation = intent.getBooleanExtra("allowNavigation", true); WebViewInstance instance = plugin != null ? plugin.getInstance(webviewId) : null; - - if (instance != null && instance.getWebView() == null) { - instance.createWebView(this); - webView = instance.getWebView(); - } else if (instance != null) { - webView = instance.getWebView(); - } else { - webView = new WebView(this); - webView.addJavascriptInterface(new JsBridge(), "AcodeWebViewNative"); - - WebSettings settings = webView.getSettings(); - settings.setJavaScriptEnabled(true); - settings.setDomStorageEnabled(true); - settings.setAllowContentAccess(true); - settings.setDisplayZoomControls(false); - settings.setLoadWithOverviewMode(true); - settings.setUseWideViewPort(true); - settings.setAllowFileAccess(true); - - webView.setWebViewClient(new FullscreenWebViewClient()); - - String bridgeJs = - "(function() {" + - " window.webview = window.webview || {};" + - " window.webview._callbacks = [];" + - " window.webview.onMessage = function(cb) { window.webview._callbacks.push(cb); };" + - " window.webview.postMessage = function(msg) {" + - " var data = typeof msg === 'string' ? msg : JSON.stringify(msg);" + - " window.AcodeWebViewNative.postMessage(data);" + - " };" + - " window.webview.offMessage = function(cb) {" + - " window.webview._callbacks = window.webview._callbacks.filter(function(c) { return c !== cb; });" + - " };" + - "})();"; - webView.evaluateJavascript(bridgeJs, null); + if (instance == null) { + finish(); + return; } + instance.createWebView(this); + webView = instance.getWebView(); + FrameLayout container = new FrameLayout(this); container.addView(webView, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -95,27 +61,7 @@ protected void onDestroy() { super.onDestroy(); if (plugin != null) { plugin.sendEventToCordova(webviewId, "closed", null); - } - } - - private class FullscreenWebViewClient extends WebViewClient { - @Override - public boolean shouldOverrideUrlLoading(WebView view, android.webkit.WebResourceRequest request) { - return !allowNavigation; - } - } - - private class JsBridge { - @JavascriptInterface - public void postMessage(String message) { - if (plugin != null) { - plugin.sendMessageToCordova(webviewId, message); - } - } - - @JavascriptInterface - public String getWebViewId() { - return webviewId; + plugin.removeInstance(webviewId); } } } diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java index 6844c6b13..592ce2fdf 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java @@ -3,15 +3,12 @@ import android.app.Activity; import android.content.DialogInterface; import android.graphics.Color; -import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; import android.webkit.DownloadListener; import android.webkit.JavascriptInterface; import android.webkit.URLUtil; @@ -38,38 +35,32 @@ public class WebViewInstance { final String id; final String mode; - final String title; final int width; final int height; - final int x; - final int y; final boolean allowNavigation; final boolean allowDownloads; - final boolean initiallyVisible; final WebViewPlugin plugin; private WebView webView; private FrameLayout container; + private Activity activity; private boolean isDestroyed = false; + private boolean isAttached = false; WebViewInstance( - String id, String mode, String title, - int width, int height, int x, int y, + String id, String mode, + int width, int height, boolean allowNavigation, boolean allowDownloads, - boolean initiallyVisible, Activity activity, WebViewPlugin plugin ) { this.id = id; this.mode = mode; - this.title = title; this.width = width; this.height = height; - this.x = x; - this.y = y; this.allowNavigation = allowNavigation; this.allowDownloads = allowDownloads; - this.initiallyVisible = initiallyVisible; + this.activity = activity; this.plugin = plugin; } @@ -78,6 +69,7 @@ public WebView getWebView() { } void createWebView(Activity activity) { + this.activity = activity; webView = new WebView(activity); WebSettings settings = webView.getSettings(); @@ -87,9 +79,7 @@ void createWebView(Activity activity) { settings.setDisplayZoomControls(false); settings.setLoadWithOverviewMode(true); settings.setUseWideViewPort(true); - settings.setAllowFileAccess(true); - settings.setAllowFileAccessFromFileURLs(true); - settings.setAllowUniversalAccessFromFileURLs(true); + settings.setAllowFileAccess(false); webView.setWebViewClient(new InstanceWebViewClient()); webView.setWebChromeClient(new InstanceWebChromeClient()); @@ -119,34 +109,29 @@ void createWebView(Activity activity) { webView.evaluateJavascript(bridgeJs, null); } - void attachToActivity(Activity activity) { - container = new FrameLayout(activity); - - FrameLayout.LayoutParams webViewParams = new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ); + void attachToActivity() { + if (isAttached || isDestroyed || webView == null || activity == null) return; + container = new FrameLayout(activity); container.setBackgroundColor(Color.argb(180, 0, 0, 0)); + FrameLayout.LayoutParams webViewParams; if (mode.equals("window")) { - int w = width > 0 ? width : ViewGroup.LayoutParams.WRAP_CONTENT; - int h = height > 0 ? height : ViewGroup.LayoutParams.WRAP_CONTENT; - - webViewParams = new FrameLayout.LayoutParams( - w > 0 ? dpToPx(activity, w) : ViewGroup.LayoutParams.MATCH_PARENT, - h > 0 ? dpToPx(activity, h) : ViewGroup.LayoutParams.MATCH_PARENT - ); + int w = width > 0 ? dpToPx(activity, width) : ViewGroup.LayoutParams.MATCH_PARENT; + int h = height > 0 ? dpToPx(activity, height) : ViewGroup.LayoutParams.MATCH_PARENT; + webViewParams = new FrameLayout.LayoutParams(w, h); webViewParams.gravity = Gravity.CENTER; - webViewParams.setMargins(dpToPx(activity, 16), dpToPx(activity, 48), dpToPx(activity, 16), dpToPx(activity, 48)); - + webViewParams.setMargins( + dpToPx(activity, 16), dpToPx(activity, 48), + dpToPx(activity, 16), dpToPx(activity, 48) + ); container.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - hideInner(); + container.setVisibility(View.GONE); } }); - } else if (mode.equals("panel")) { + } else { webViewParams = new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, height > 0 ? dpToPx(activity, height) : (int)(getScreenHeight(activity) * 0.4) @@ -154,6 +139,9 @@ public void onClick(View v) { webViewParams.gravity = Gravity.BOTTOM; } + if (webView.getParent() != null) { + ((ViewGroup) webView.getParent()).removeView(webView); + } container.addView(webView, webViewParams); ViewGroup rootView = activity.findViewById(android.R.id.content); @@ -163,6 +151,8 @@ public void onClick(View v) { ViewGroup.LayoutParams.MATCH_PARENT )); } + + isAttached = true; } void loadURL(String url) { @@ -215,11 +205,6 @@ void postMessage(String message, CallbackContext callbackContext) { callbackContext.error("WebView destroyed"); return; } - String escaped = message - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\n", "\\n") - .replace("\r", "\\r"); String js = "if(window.webview&&window.webview._callbacks){" + "var msg=" + safeParseJSON(message) + ";" + @@ -253,12 +238,19 @@ void show(final CallbackContext callbackContext) { new Handler(Looper.getMainLooper()).post(new Runnable() { @Override public void run() { - if (isDestroyed || container == null) { - if (callbackContext != null) callbackContext.error("Cannot show"); + if (isDestroyed) { + if (callbackContext != null) callbackContext.error("WebView destroyed"); return; } - container.setVisibility(View.VISIBLE); - if (callbackContext != null) callbackContext.success(); + if (!isAttached) { + attachToActivity(); + } + if (container != null) { + container.setVisibility(View.VISIBLE); + if (callbackContext != null) callbackContext.success(); + } else { + if (callbackContext != null) callbackContext.error("Cannot show"); + } } }); } @@ -277,12 +269,6 @@ public void run() { }); } - private void hideInner() { - if (container != null) { - container.setVisibility(View.GONE); - } - } - void reload(CallbackContext callbackContext) { if (isDestroyed || webView == null) { callbackContext.error("WebView destroyed"); @@ -315,6 +301,7 @@ public void run() { } webView = null; container = null; + isAttached = false; } }); } @@ -339,12 +326,14 @@ private static int getScreenHeight(Activity activity) { } private class InstanceWebViewClient extends WebViewClient { + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + return !allowNavigation; + } + @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - if (!allowNavigation) { - return true; - } - return false; + return !allowNavigation; } @Override @@ -414,10 +403,5 @@ public class JsBridge { public void postMessage(String message) { plugin.sendMessageToCordova(id, message); } - - @JavascriptInterface - public String getWebViewId() { - return id; - } } } diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java index 17b7e51aa..22104bcfa 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java @@ -101,30 +101,24 @@ private String generateId() { private void create(JSONObject options, final CallbackContext callbackContext) throws JSONException { final String id = generateId(); final String mode = options.optString("mode", "hidden"); - final String title = options.optString("title", "WebView"); final int width = options.optInt("width", 0); final int height = options.optInt("height", 0); - final int x = options.optInt("x", 0); - final int y = options.optInt("y", 0); final boolean allowNavigation = options.optBoolean("allowNavigation", true); final boolean allowDownloads = options.optBoolean("allowDownloads", false); - final boolean visible = options.optBoolean("visible", true); - - final String effectiveMode = visible ? mode : "hidden"; cordova.getActivity().runOnUiThread(new Runnable() { @Override public void run() { try { WebViewInstance instance = new WebViewInstance( - id, effectiveMode, title, - width, height, x, y, + id, mode, + width, height, allowNavigation, allowDownloads, - visible, cordova.getActivity(), + cordova.getActivity(), WebViewPlugin.this ); - if (effectiveMode.equals("fullscreen")) { + if (mode.equals("fullscreen")) { instances.put(id, instance); launchFullscreenActivity(id, allowNavigation); callbackContext.success(id); @@ -133,8 +127,8 @@ public void run() { instance.createWebView(cordova.getActivity()); - if (effectiveMode.equals("window") || effectiveMode.equals("panel")) { - instance.attachToActivity(cordova.getActivity()); + if (mode.equals("window") || mode.equals("panel")) { + instance.attachToActivity(); } instances.put(id, instance); @@ -158,6 +152,10 @@ public WebViewInstance getInstance(String id) { return instances.get(id); } + public void removeInstance(String id) { + instances.remove(id); + } + private void loadURL(String id, String url, CallbackContext callbackContext) { WebViewInstance instance = getInstance(id); if (instance == null) { callbackContext.error("WebView not found: " + id); return; } From b8e35dd6fb13d66caffdbe9e980da768ffe14835 Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Sun, 26 Jul 2026 18:43:43 +0530 Subject: [PATCH 5/6] fixes --- src/index.d.ts | 27 ++ src/lib/webview.js | 292 ++++++------ .../com/foxdebug/webview/WebViewActivity.java | 43 +- .../com/foxdebug/webview/WebViewInstance.java | 438 ++++++++++++++---- .../com/foxdebug/webview/WebViewPlugin.java | 87 ++-- 5 files changed, 588 insertions(+), 299 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index 2b0c2b1e1..914103538 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -112,29 +112,56 @@ declare global { } interface WebViewOptions { + /** Title applied to the hosting activity in fullscreen mode. */ title?: string; + /** Display mode. Defaults to "hidden". */ mode?: "fullscreen" | "window" | "panel" | "hidden"; + /** Width in dp ("window" mode). */ width?: number; + /** Height in dp ("window"/"panel" modes). */ height?: number; + /** Left offset in dp ("window" mode, centered when unset). */ x?: number; + /** Top offset in dp ("window" mode, centered when unset). */ y?: number; + /** + * Allow in-WebView navigation. Defaults to true. Only http(s) targets + * ever load; other schemes are always blocked for isolation. + */ allowNavigation?: boolean; + /** Ask the user before downloading files. Defaults to false. */ allowDownloads?: boolean; + /** + * Show immediately after creation. Defaults to true. When false, window + * and panel instances stay detached and fullscreen instances defer their + * activity launch until show() is called. + */ visible?: boolean; } interface AcodeWebView { readonly id: string; readonly options: WebViewOptions; + /** Load an http(s) URL. Other schemes are rejected. */ loadURL(url: string): Promise; loadHTML(html: string): Promise; evaluate(js: string): Promise; onMessage(callback: (message: unknown) => void): void; offMessage(callback: (message: unknown) => void): void; + /** + * Subscribe to lifecycle events: "pageFinished", "titleChanged", + * "dismissed" (backdrop tap in "window" mode) and "closed" (fullscreen + * closed by the user or by hide()). After "closed" the instance is + * destroyed and cannot be reused. + */ on(event: string, callback: (event: string, data?: unknown) => void): void; off(event: string, callback: (event: string, data?: unknown) => void): void; postMessage(message: unknown): Promise; show(): Promise; + /** + * Hide the WebView. In fullscreen mode this closes the hosting activity, + * which destroys the instance and emits "closed". + */ hide(): Promise; reload(): Promise; destroy(): Promise; diff --git a/src/lib/webview.js b/src/lib/webview.js index ae96cb662..00f22ab0c 100644 --- a/src/lib/webview.js +++ b/src/lib/webview.js @@ -2,158 +2,162 @@ import nativeBridge from "../plugins/webview/www/webview"; let initialized = false; const instances = new Map(); -const eventCallbacks = new Map(); function ensureInit() { - if (!initialized) { - nativeBridge.setMessageCallback((payload) => { - const { id, message, event, data } = payload; - - if (event) { - const callbacks = eventCallbacks.get(id); - if (callbacks) { - callbacks.forEach((entry) => { - if (entry.event === event) { - try { - entry.callback(event, data); - } catch (e) { - console.error("WebView event callback error:", e); - } - } - }); - } - return; - } - - if (message !== undefined) { - const instance = instances.get(id); - if (instance && instance._messageCallbacks) { - let parsed = message; - try { - parsed = JSON.parse(message); - } catch (_) {} - instance._messageCallbacks.forEach((cb) => { - try { - cb(parsed); - } catch (e) { - console.error("WebView message callback error:", e); - } - }); - } - } - }); - initialized = true; - } + if (!initialized) { + nativeBridge.setMessageCallback((payload) => { + const { id, message, event, data } = payload; + const instance = instances.get(id); + if (!instance) return; + + if (event) { + // Iterate a copy so callbacks can unsubscribe during dispatch. + for (const entry of instance._eventCallbacks.slice()) { + if (entry.event === event) { + try { + entry.callback(event, data); + } catch (e) { + console.error("WebView event callback error:", e); + } + } + } + + // A fullscreen WebView closed by the user (or by hide()) is + // gone natively; mark it destroyed so later calls fail fast + // and its listeners can be garbage collected. + if (event === "closed") { + instance._destroyed = true; + instances.delete(id); + instance._messageCallbacks = []; + instance._eventCallbacks = []; + } + return; + } + + if (message !== undefined) { + let parsed = message; + try { + parsed = JSON.parse(message); + } catch (_) {} + for (const cb of instance._messageCallbacks.slice()) { + try { + cb(parsed); + } catch (e) { + console.error("WebView message callback error:", e); + } + } + } + }); + initialized = true; + } } class WebView { - constructor(id, options = {}) { - this.id = id; - this.options = options; - this._messageCallbacks = []; - this._eventCallbacks = []; - this._destroyed = false; - - instances.set(id, this); - eventCallbacks.set(id, this._eventCallbacks); - } - - async loadURL(url) { - this._checkDestroyed(); - await nativeBridge.loadURL(this.id, url); - } - - async loadHTML(html) { - this._checkDestroyed(); - await nativeBridge.loadHTML(this.id, html); - } - - async evaluate(js) { - this._checkDestroyed(); - return await nativeBridge.evaluate(this.id, js); - } - - onMessage(callback) { - this._checkDestroyed(); - if (typeof callback === "function") { - this._messageCallbacks.push(callback); - } - } - - offMessage(callback) { - this._messageCallbacks = this._messageCallbacks.filter( - (cb) => cb !== callback, - ); - } - - on(event, callback) { - this._checkDestroyed(); - if (typeof callback === "function") { - this._eventCallbacks.push({ event, callback }); - } - } - - off(event, callback) { - this._eventCallbacks = this._eventCallbacks.filter( - (entry) => !(entry.event === event && entry.callback === callback), - ); - } - - async postMessage(message) { - this._checkDestroyed(); - await nativeBridge.postMessage(this.id, message); - } - - async show() { - this._checkDestroyed(); - await nativeBridge.show(this.id); - } - - async hide() { - this._checkDestroyed(); - await nativeBridge.hide(this.id); - } - - async reload() { - this._checkDestroyed(); - await nativeBridge.reload(this.id); - } - - async destroy() { - this._checkDestroyed(); - this._destroyed = true; - await nativeBridge.destroy(this.id); - instances.delete(this.id); - eventCallbacks.delete(this.id); - this._messageCallbacks = []; - this._eventCallbacks = []; - } - - _checkDestroyed() { - if (this._destroyed) { - throw new Error("WebView has been destroyed"); - } - } + constructor(id, options = {}) { + this.id = id; + this.options = options; + this._messageCallbacks = []; + this._eventCallbacks = []; + this._destroyed = false; + + instances.set(id, this); + } + + async loadURL(url) { + this._checkDestroyed(); + await nativeBridge.loadURL(this.id, url); + } + + async loadHTML(html) { + this._checkDestroyed(); + await nativeBridge.loadHTML(this.id, html); + } + + async evaluate(js) { + this._checkDestroyed(); + return await nativeBridge.evaluate(this.id, js); + } + + onMessage(callback) { + this._checkDestroyed(); + if (typeof callback === "function") { + this._messageCallbacks.push(callback); + } + } + + offMessage(callback) { + this._messageCallbacks = this._messageCallbacks.filter( + (cb) => cb !== callback, + ); + } + + on(event, callback) { + this._checkDestroyed(); + if (typeof callback === "function") { + this._eventCallbacks.push({ event, callback }); + } + } + + off(event, callback) { + this._eventCallbacks = this._eventCallbacks.filter( + (entry) => !(entry.event === event && entry.callback === callback), + ); + } + + async postMessage(message) { + this._checkDestroyed(); + await nativeBridge.postMessage(this.id, message); + } + + async show() { + this._checkDestroyed(); + await nativeBridge.show(this.id); + } + + async hide() { + this._checkDestroyed(); + await nativeBridge.hide(this.id); + } + + async reload() { + this._checkDestroyed(); + await nativeBridge.reload(this.id); + } + + async destroy() { + this._checkDestroyed(); + this._destroyed = true; + await nativeBridge.destroy(this.id); + instances.delete(this.id); + this._messageCallbacks = []; + this._eventCallbacks = []; + } + + _checkDestroyed() { + if (this._destroyed) { + throw new Error("WebView has been destroyed"); + } + } } const webviewAPI = { - async create(options = {}) { - ensureInit(); - - const id = await nativeBridge.create({ - title: options.title || "", - mode: options.mode || "hidden", - width: options.width || 0, - height: options.height || 0, - x: options.x || 0, - y: options.y || 0, - allowNavigation: options.allowNavigation !== false, - allowDownloads: options.allowDownloads === true, - visible: options.visible !== false, - }); - - return new WebView(id, options); - }, + async create(options = {}) { + ensureInit(); + + const id = await nativeBridge.create({ + title: options.title || "", + mode: options.mode || "hidden", + width: options.width || 0, + height: options.height || 0, + x: options.x || 0, + y: options.y || 0, + allowNavigation: options.allowNavigation !== false, + allowDownloads: options.allowDownloads === true, + visible: options.visible !== false, + }); + + return new WebView(id, options); + }, }; export default webviewAPI; diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java index 54b7304c6..6e55a44cb 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java @@ -1,7 +1,6 @@ package com.foxdebug.webview; import android.app.Activity; -import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.view.ViewGroup; @@ -10,22 +9,20 @@ public class WebViewActivity extends Activity { - private static WebViewPlugin plugin; - private WebView webView; private String webviewId; - public static void setPlugin(WebViewPlugin p) { - plugin = p; - } - @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - Intent intent = getIntent(); - webviewId = intent.getStringExtra("webviewId"); + webviewId = getIntent().getStringExtra("webviewId"); + // The plugin registers itself in pluginInitialize(), so the singleton is + // always available while the app is alive. Previously this read a static + // field that was never set, which made every fullscreen WebView close + // instantly because the instance lookup returned null. + WebViewPlugin plugin = WebViewPlugin.getInstance(); WebViewInstance instance = plugin != null ? plugin.getInstance(webviewId) : null; if (instance == null) { finish(); @@ -34,7 +31,19 @@ public void onCreate(Bundle savedInstanceState) { instance.createWebView(this); webView = instance.getWebView(); + if (webView == null) { + finish(); + return; + } + String title = instance.getTitle(); + if (title != null && !title.isEmpty()) { + setTitle(title); + } + + if (webView.getParent() != null) { + ((ViewGroup) webView.getParent()).removeView(webView); + } FrameLayout container = new FrameLayout(this); container.addView(webView, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -59,9 +68,21 @@ public void onBackPressed() { @Override protected void onDestroy() { super.onDestroy(); - if (plugin != null) { + + WebViewPlugin plugin = WebViewPlugin.getInstance(); + if (plugin != null && webviewId != null) { + WebViewInstance instance = plugin.getInstance(webviewId); + if (instance != null) { + // Actually destroy the WebView instead of leaking it, then drop the + // instance so later calls fail instead of touching a dead WebView. + // destroy() is idempotent, so this is safe when the JS side already + // called destroy() and finishing this activity is what tore us down. + instance.destroy(); + plugin.removeInstance(webviewId); + } plugin.sendEventToCordova(webviewId, "closed", null); - plugin.removeInstance(webviewId); } + + webView = null; } } diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java index 592ce2fdf..c03991710 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java @@ -1,8 +1,14 @@ package com.foxdebug.webview; import android.app.Activity; +import android.app.AlertDialog; +import android.app.DownloadManager; +import android.content.Context; import android.content.DialogInterface; +import android.graphics.Bitmap; import android.graphics.Color; +import android.net.Uri; +import android.os.Environment; import android.os.Handler; import android.os.Looper; import android.util.Log; @@ -18,25 +24,50 @@ import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; -import android.app.AlertDialog; -import android.app.DownloadManager; -import android.content.Context; -import android.net.Uri; -import android.os.Environment; import android.widget.FrameLayout; import android.widget.Toast; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.cordova.CallbackContext; import org.json.JSONException; import org.json.JSONObject; +import org.json.JSONTokener; public class WebViewInstance { private static final String TAG = "WebViewInstance"; + /** + * Page-side messaging bridge. Injected on page started (best effort, runs + * before page scripts in most cases) and again on page finished (guaranteed). + * It is idempotent and non-destructive: the guard keeps callbacks registered + * by the page between the two injections intact. + */ + private static final String BRIDGE_JS = + "(function(){" + + "if(window.webview&&window.webview.__acodeBridge){return;}" + + "var callbacks=[];" + + "window.webview={" + + "__acodeBridge:true," + + "onMessage:function(cb){if(typeof cb==='function'){callbacks.push(cb);}}," + + "offMessage:function(cb){callbacks=callbacks.filter(function(c){return c!==cb;});}," + + "postMessage:function(msg){" + + "var data=(typeof msg==='string')?msg:JSON.stringify(msg);" + + "window.AcodeWebViewNative.postMessage(String(data));" + + "}," + + "_dispatch:function(msg){" + + "callbacks.slice().forEach(function(cb){try{cb(msg);}catch(e){console.error(e);}});" + + "}" + + "};" + + "})();"; + final String id; final String mode; + final String title; final int width; final int height; + final int x; + final int y; final boolean allowNavigation; final boolean allowDownloads; final WebViewPlugin plugin; @@ -46,18 +77,26 @@ public class WebViewInstance { private Activity activity; private boolean isDestroyed = false; private boolean isAttached = false; + /** Whether the hosting fullscreen activity has been launched. */ + private boolean launched = false; + /** Content requested before the fullscreen WebView exists yet. */ + private String pendingUrl = null; + private String pendingHtml = null; WebViewInstance( - String id, String mode, - int width, int height, + String id, String mode, String title, + int width, int height, int x, int y, boolean allowNavigation, boolean allowDownloads, Activity activity, WebViewPlugin plugin ) { this.id = id; this.mode = mode; + this.title = title; this.width = width; this.height = height; + this.x = x; + this.y = y; this.allowNavigation = allowNavigation; this.allowDownloads = allowDownloads; this.activity = activity; @@ -68,18 +107,40 @@ public WebView getWebView() { return webView; } + String getTitle() { + return title; + } + + boolean isFullscreen() { + return "fullscreen".equals(mode); + } + + void markLaunched() { + launched = true; + } + void createWebView(Activity activity) { + // Idempotent: a recreated hosting activity reuses the existing WebView + // (and its page state) instead of leaking one instance per recreation. + if (webView != null) { + this.activity = activity; + return; + } this.activity = activity; webView = new WebView(activity); WebSettings settings = webView.getSettings(); settings.setJavaScriptEnabled(true); settings.setDomStorageEnabled(true); - settings.setAllowContentAccess(true); + // Isolation: hosted content must not reach app/device data. File and + // content scheme access stay disabled, and loadURL()/navigation below + // only allow http(s), so these cannot be bypassed with a crafted URL. + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + setFileUrlAccessFlags(settings); settings.setDisplayZoomControls(false); settings.setLoadWithOverviewMode(true); settings.setUseWideViewPort(true); - settings.setAllowFileAccess(false); webView.setWebViewClient(new InstanceWebViewClient()); webView.setWebChromeClient(new InstanceWebChromeClient()); @@ -92,25 +153,33 @@ void createWebView(Activity activity) { webView.setDownloadListener(new InstanceDownloadListener(activity)); } - String bridgeJs = - "(function() {" + - " window.webview = window.webview || {};" + - " window.webview._callbacks = [];" + - " window.webview.onMessage = function(cb) { window.webview._callbacks.push(cb); };" + - " window.webview.postMessage = function(msg) {" + - " var data = typeof msg === 'string' ? msg : JSON.stringify(msg);" + - " window.AcodeWebViewNative.postMessage(data);" + - " };" + - " window.webview.offMessage = function(cb) {" + - " window.webview._callbacks = window.webview._callbacks.filter(function(c) { return c !== cb; });" + - " };" + - "})();"; + injectBridge(webView); + + // Apply content requested while the WebView did not exist yet + // (fullscreen instances are created lazily by WebViewActivity). + if (pendingUrl != null) { + webView.loadUrl(pendingUrl); + pendingUrl = null; + pendingHtml = null; + } else if (pendingHtml != null) { + webView.loadDataWithBaseURL(null, pendingHtml, "text/html", "UTF-8", null); + pendingHtml = null; + } + } - webView.evaluateJavascript(bridgeJs, null); + @SuppressWarnings("deprecation") + private static void setFileUrlAccessFlags(WebSettings settings) { + settings.setAllowFileAccessFromFileURLs(false); + settings.setAllowUniversalAccessFromFileURLs(false); + } + + private static void injectBridge(WebView view) { + view.evaluateJavascript(BRIDGE_JS, null); } void attachToActivity() { if (isAttached || isDestroyed || webView == null || activity == null) return; + if (activity.isFinishing()) return; container = new FrameLayout(activity); container.setBackgroundColor(Color.argb(180, 0, 0, 0)); @@ -120,21 +189,27 @@ void attachToActivity() { int w = width > 0 ? dpToPx(activity, width) : ViewGroup.LayoutParams.MATCH_PARENT; int h = height > 0 ? dpToPx(activity, height) : ViewGroup.LayoutParams.MATCH_PARENT; webViewParams = new FrameLayout.LayoutParams(w, h); - webViewParams.gravity = Gravity.CENTER; - webViewParams.setMargins( - dpToPx(activity, 16), dpToPx(activity, 48), - dpToPx(activity, 16), dpToPx(activity, 48) - ); + if (x > 0 || y > 0) { + webViewParams.gravity = Gravity.TOP | Gravity.START; + webViewParams.setMargins(dpToPx(activity, x), dpToPx(activity, y), 0, 0); + } else { + webViewParams.gravity = Gravity.CENTER; + webViewParams.setMargins( + dpToPx(activity, 16), dpToPx(activity, 48), + dpToPx(activity, 16), dpToPx(activity, 48) + ); + } container.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { container.setVisibility(View.GONE); + plugin.sendEventToCordova(id, "dismissed", null); } }); } else { webViewParams = new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - height > 0 ? dpToPx(activity, height) : (int)(getScreenHeight(activity) * 0.4) + height > 0 ? dpToPx(activity, height) : (int) (getScreenHeight(activity) * 0.4) ); webViewParams.gravity = Gravity.BOTTOM; } @@ -155,63 +230,142 @@ public void onClick(View v) { isAttached = true; } - void loadURL(String url) { - if (isDestroyed || webView == null) return; - final String safeUrl = (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) - ? url : "http://" + url; - new Handler(Looper.getMainLooper()).post(new Runnable() { + private static final Pattern SCHEME_PATTERN = + Pattern.compile("^([a-zA-Z][a-zA-Z0-9+\\-.]*)://"); + + /** + * Allows only http and https URLs, so hosted pages can never reach local + * files, app content providers or execute javascript: URLs. Input without + * a "scheme://" prefix ("example.com", "localhost:8080/page") is treated + * as a host and loaded over https; anything that is not clearly a URL + * degrades into a harmless failed https load. + */ + private static String sanitizeUrl(String url) { + if (url == null) return null; + String trimmed = url.trim(); + if (trimmed.isEmpty()) return null; + Matcher matcher = SCHEME_PATTERN.matcher(trimmed); + if (matcher.find()) { + String scheme = matcher.group(1); + if (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")) { + return trimmed; + } + return null; // file://, content://, intent://, etc. + } + return "https://" + trimmed; + } + + void loadURL(String url, final CallbackContext callbackContext) { + if (isDestroyed) { + callbackContext.error("WebView has been destroyed"); + return; + } + + final String safeUrl = sanitizeUrl(url); + if (safeUrl == null) { + callbackContext.error("Blocked URL: only http:// and https:// URLs are allowed"); + return; + } + + // Fullscreen instances create their WebView lazily in WebViewActivity. + if (webView == null) { + pendingUrl = safeUrl; + pendingHtml = null; + callbackContext.success(); + return; + } + + runOnUiThread(new Runnable() { @Override public void run() { webView.loadUrl(safeUrl); + callbackContext.success(); } }); } - void loadHTML(String html) { - if (isDestroyed || webView == null) return; - new Handler(Looper.getMainLooper()).post(new Runnable() { + void loadHTML(final String html, final CallbackContext callbackContext) { + if (isDestroyed) { + callbackContext.error("WebView has been destroyed"); + return; + } + + if (webView == null) { + pendingHtml = html; + pendingUrl = null; + callbackContext.success(); + return; + } + + runOnUiThread(new Runnable() { @Override public void run() { webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null); + callbackContext.success(); } }); } void evaluate(String js, final CallbackContext callbackContext) { - if (isDestroyed || webView == null) { - callbackContext.error("WebView destroyed"); + if (isDestroyed) { + callbackContext.error("WebView has been destroyed"); return; } - new Handler(Looper.getMainLooper()).post(new Runnable() { + if (webView == null) { + callbackContext.error("WebView is not ready"); + return; + } + runOnUiThread(new Runnable() { @Override public void run() { webView.evaluateJavascript(js, new ValueCallback() { @Override public void onReceiveValue(String value) { - if (value != null && value.startsWith("\"") && value.endsWith("\"")) { - value = value.substring(1, value.length() - 1) - .replace("\\\"", "\"") - .replace("\\\\", "\\"); - } - callbackContext.success(value); + callbackContext.success(decodeJsResult(value)); } }); } }); } - void postMessage(String message, CallbackContext callbackContext) { - if (isDestroyed || webView == null) { - callbackContext.error("WebView destroyed"); + /** + * evaluateJavascript() delivers the result as a JSON-encoded string. + * Decode it properly instead of stripping quotes by hand so escapes + * (newlines, unicode, quotes) survive the round trip. + */ + private static String decodeJsResult(String value) { + if (value == null) return null; + try { + Object parsed = new JSONTokener(value).nextValue(); + if (parsed == JSONObject.NULL) return null; + return String.valueOf(parsed); + } catch (JSONException e) { + return value; + } + } + + void postMessage(String message, final CallbackContext callbackContext) { + if (isDestroyed) { + callbackContext.error("WebView has been destroyed"); + return; + } + if (webView == null) { + callbackContext.error("WebView is not ready"); return; } - String js = "if(window.webview&&window.webview._callbacks){" + - "var msg=" + safeParseJSON(message) + ";" + - "window.webview._callbacks.forEach(function(cb){try{cb(msg)}catch(e){console.error(e)}});" + - "}"; + // JSONObject.quote() produces a safe JS string literal for any input, + // so a malicious or sloppy payload cannot break out of the string and + // inject code into the page context. The page receives a parsed value + // for JSON payloads and the raw string otherwise. + final String js = + "(function(){" + + "var raw=" + JSONObject.quote(message) + ";" + + "var msg;try{msg=JSON.parse(raw);}catch(e){msg=raw;}" + + "if(window.webview&&window.webview._dispatch){window.webview._dispatch(msg);}" + + "})();"; - new Handler(Looper.getMainLooper()).post(new Runnable() { + runOnUiThread(new Runnable() { @Override public void run() { webView.evaluateJavascript(js, null); @@ -220,26 +374,16 @@ public void run() { }); } - private String safeParseJSON(String message) { - try { - new JSONObject(message); - return message; - } catch (JSONException e1) { - try { - new org.json.JSONArray(message); - return message; - } catch (JSONException e2) { - return "\"" + message.replace("\"", "\\\"").replace("\n", "\\n") + "\""; - } - } - } - void show(final CallbackContext callbackContext) { - new Handler(Looper.getMainLooper()).post(new Runnable() { + if (isFullscreen()) { + showFullscreen(callbackContext); + return; + } + runOnUiThread(new Runnable() { @Override public void run() { if (isDestroyed) { - if (callbackContext != null) callbackContext.error("WebView destroyed"); + callbackContext.error("WebView has been destroyed"); return; } if (!isAttached) { @@ -247,34 +391,90 @@ public void run() { } if (container != null) { container.setVisibility(View.VISIBLE); - if (callbackContext != null) callbackContext.success(); + callbackContext.success(); } else { - if (callbackContext != null) callbackContext.error("Cannot show"); + callbackContext.error("Cannot show"); } } }); } + private void showFullscreen(CallbackContext callbackContext) { + if (isDestroyed) { + callbackContext.error("WebView has been destroyed"); + return; + } + if (launched) { + callbackContext.success(); + return; + } + launched = true; + runOnUiThread(new Runnable() { + @Override + public void run() { + plugin.launchFullscreenActivity(id); + } + }); + callbackContext.success(); + } + void hide(final CallbackContext callbackContext) { - new Handler(Looper.getMainLooper()).post(new Runnable() { + if (isFullscreen()) { + hideFullscreen(callbackContext); + return; + } + runOnUiThread(new Runnable() { @Override public void run() { - if (isDestroyed || container == null) { - if (callbackContext != null) callbackContext.error("Cannot hide"); + if (isDestroyed) { + callbackContext.error("WebView has been destroyed"); return; } - container.setVisibility(View.GONE); - if (callbackContext != null) callbackContext.success(); + // Hiding an already hidden (never attached) WebView is a no-op. + if (container != null) { + container.setVisibility(View.GONE); + } + callbackContext.success(); } }); } - void reload(CallbackContext callbackContext) { - if (isDestroyed || webView == null) { - callbackContext.error("WebView destroyed"); + /** + * A fullscreen WebView is hosted by its own activity, so hiding it means + * closing that activity. WebViewActivity.onDestroy() then destroys the + * instance and emits the "closed" event. + */ + private void hideFullscreen(CallbackContext callbackContext) { + if (isDestroyed) { + callbackContext.error("WebView has been destroyed"); return; } - new Handler(Looper.getMainLooper()).post(new Runnable() { + if (!launched) { + callbackContext.success(); + return; + } + launched = false; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (activity instanceof WebViewActivity && !activity.isFinishing()) { + activity.finish(); + } + } + }); + callbackContext.success(); + } + + void reload(final CallbackContext callbackContext) { + if (isDestroyed) { + callbackContext.error("WebView has been destroyed"); + return; + } + if (webView == null) { + callbackContext.error("WebView is not ready"); + return; + } + runOnUiThread(new Runnable() { @Override public void run() { webView.reload(); @@ -284,18 +484,29 @@ public void run() { } void destroy() { + if (isDestroyed) return; isDestroyed = true; - new Handler(Looper.getMainLooper()).post(new Runnable() { + // Finish the hosting fullscreen activity, if any. Its onDestroy() calls + // destroy() again, which is a no-op now that isDestroyed is set. + if (activity instanceof WebViewActivity && !activity.isFinishing()) { + activity.finish(); + } + + runOnUiThread(new Runnable() { @Override public void run() { if (container != null && container.getParent() != null) { ((ViewGroup) container.getParent()).removeView(container); } if (webView != null) { + if (webView.getParent() != null) { + ((ViewGroup) webView.getParent()).removeView(webView); + } webView.removeJavascriptInterface("AcodeWebViewNative"); - webView.setWebViewClient(null); + webView.setDownloadListener(null); webView.setWebChromeClient(null); + webView.setWebViewClient(null); webView.loadUrl("about:blank"); webView.destroy(); } @@ -306,17 +517,26 @@ public void run() { }); } - void onPageFinished() { + void onPageFinished(WebView view) { + // Navigation replaced the page's JS context, so the bridge injected into + // the previous document is gone. Re-inject (no-op if already present). + injectBridge(view); try { JSONObject data = new JSONObject(); - data.put("url", webView.getUrl()); - data.put("title", webView.getTitle()); + String url = view.getUrl(); + String pageTitle = view.getTitle(); + data.put("url", url != null ? url : ""); + data.put("title", pageTitle != null ? pageTitle : ""); plugin.sendEventToCordova(id, "pageFinished", data); - } catch (Exception e) { + } catch (JSONException e) { Log.e(TAG, "onPageFinished error", e); } } + private static void runOnUiThread(Runnable runnable) { + new Handler(Looper.getMainLooper()).post(runnable); + } + private static int dpToPx(Context context, int dp) { return (int) (dp * context.getResources().getDisplayMetrics().density); } @@ -326,20 +546,41 @@ private static int getScreenHeight(Activity activity) { } private class InstanceWebViewClient extends WebViewClient { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return shouldBlockNavigation(request.getUrl()); + } + + @SuppressWarnings("deprecation") @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { - return !allowNavigation; + return shouldBlockNavigation(Uri.parse(url)); + } + + /** + * Blocks all navigation when allowNavigation is false. Even when it is + * true, only http(s) targets may load inside the WebView; other schemes + * (file:, content:, intent:, javascript:, tel:, ...) are blocked so + * hostile pages cannot escape the sandbox or launch other apps. + */ + private boolean shouldBlockNavigation(Uri uri) { + if (!allowNavigation) return true; + String scheme = uri.getScheme(); + return scheme == null + || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")); } @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - return !allowNavigation; + public void onPageStarted(WebView view, String url, Bitmap favicon) { + super.onPageStarted(view, url, favicon); + // Best effort: gets the bridge in before the page's own scripts run. + injectBridge(view); } @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); - WebViewInstance.this.onPageFinished(); + WebViewInstance.this.onPageFinished(view); } } @@ -349,9 +590,9 @@ public void onReceivedTitle(WebView view, String pageTitle) { super.onReceivedTitle(view, pageTitle); try { JSONObject data = new JSONObject(); - data.put("title", pageTitle); + data.put("title", pageTitle != null ? pageTitle : ""); plugin.sendEventToCordova(id, "titleChanged", data); - } catch (Exception e) { + } catch (JSONException e) { Log.e(TAG, "onReceivedTitle error", e); } } @@ -365,10 +606,11 @@ private class InstanceDownloadListener implements DownloadListener { } @Override - public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) { + public void onDownloadStart(final String url, final String userAgent, String contentDisposition, final String mimeType, long contentLength) { + if (context instanceof Activity && ((Activity) context).isFinishing()) return; final String fileName = URLUtil.guessFileName(url, contentDisposition, mimeType); - new Handler(Looper.getMainLooper()).post(new Runnable() { + runOnUiThread(new Runnable() { @Override public void run() { new AlertDialog.Builder(context) @@ -387,8 +629,10 @@ public void onClick(DialogInterface dialog, int which) { request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); DownloadManager dm = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - dm.enqueue(request); - Toast.makeText(context, "Download started...", Toast.LENGTH_SHORT).show(); + if (dm != null) { + dm.enqueue(request); + Toast.makeText(context, "Download started...", Toast.LENGTH_SHORT).show(); + } } }) .setNegativeButton("Cancel", null) diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java index 22104bcfa..1f2f48f2b 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java @@ -1,25 +1,8 @@ package com.foxdebug.webview; -import android.app.Activity; import android.content.Intent; -import android.graphics.Color; -import android.net.Uri; -import android.os.Handler; -import android.os.Looper; import android.util.Log; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.webkit.JavascriptInterface; -import android.webkit.ValueCallback; -import android.webkit.WebChromeClient; -import android.webkit.WebResourceRequest; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.FrameLayout; +import java.util.ArrayList; import java.util.HashMap; import java.util.UUID; import org.apache.cordova.CallbackContext; @@ -101,39 +84,50 @@ private String generateId() { private void create(JSONObject options, final CallbackContext callbackContext) throws JSONException { final String id = generateId(); final String mode = options.optString("mode", "hidden"); + final String title = options.optString("title", ""); final int width = options.optInt("width", 0); final int height = options.optInt("height", 0); + final int x = options.optInt("x", 0); + final int y = options.optInt("y", 0); final boolean allowNavigation = options.optBoolean("allowNavigation", true); final boolean allowDownloads = options.optBoolean("allowDownloads", false); + final boolean visible = options.optBoolean("visible", true); cordova.getActivity().runOnUiThread(new Runnable() { @Override public void run() { - try { - WebViewInstance instance = new WebViewInstance( - id, mode, - width, height, - allowNavigation, allowDownloads, - cordova.getActivity(), - WebViewPlugin.this - ); - - if (mode.equals("fullscreen")) { - instances.put(id, instance); - launchFullscreenActivity(id, allowNavigation); - callbackContext.success(id); - return; - } + WebViewInstance instance = new WebViewInstance( + id, mode, title, + width, height, x, y, + allowNavigation, allowDownloads, + cordova.getActivity(), + WebViewPlugin.this + ); + instances.put(id, instance); - instance.createWebView(cordova.getActivity()); - - if (mode.equals("window") || mode.equals("panel")) { - instance.attachToActivity(); + try { + if (instance.isFullscreen()) { + // The WebView is created lazily by WebViewActivity. When the + // caller asked for an initially hidden instance, the launch is + // deferred until show() is called. + if (visible) { + instance.markLaunched(); + launchFullscreenActivity(id); + } + } else { + instance.createWebView(cordova.getActivity()); + // Keep visibility separate from mode: window/panel instances that + // should start hidden stay detached until show() attaches them. + if (visible && (mode.equals("window") || mode.equals("panel"))) { + instance.attachToActivity(); + } } - - instances.put(id, instance); callbackContext.success(id); } catch (Exception e) { + instances.remove(id); + try { + instance.destroy(); + } catch (Exception ignored) {} Log.e(TAG, "Create error: " + e.getMessage(), e); callbackContext.error(e.getMessage()); } @@ -141,10 +135,9 @@ public void run() { }); } - private void launchFullscreenActivity(String id, boolean allowNavigation) { + void launchFullscreenActivity(String id) { Intent intent = new Intent(cordova.getActivity(), WebViewActivity.class); intent.putExtra("webviewId", id); - intent.putExtra("allowNavigation", allowNavigation); cordova.getActivity().startActivity(intent); } @@ -159,15 +152,13 @@ public void removeInstance(String id) { private void loadURL(String id, String url, CallbackContext callbackContext) { WebViewInstance instance = getInstance(id); if (instance == null) { callbackContext.error("WebView not found: " + id); return; } - instance.loadURL(url); - callbackContext.success(); + instance.loadURL(url, callbackContext); } private void loadHTML(String id, String html, CallbackContext callbackContext) { WebViewInstance instance = getInstance(id); if (instance == null) { callbackContext.error("WebView not found: " + id); return; } - instance.loadHTML(html); - callbackContext.success(); + instance.loadHTML(html, callbackContext); } private void evaluate(String id, String js, CallbackContext callbackContext) { @@ -200,7 +191,7 @@ private void reload(String id, CallbackContext callbackContext) { instance.reload(callbackContext); } - private void destroy(String id, CallbackContext callbackContext) { + private void destroy(String id, final CallbackContext callbackContext) { final WebViewInstance instance = instances.remove(id); if (instance == null) { callbackContext.error("WebView not found: " + id); return; } @@ -251,7 +242,9 @@ public void sendEventToCordova(String id, String event, JSONObject data) { @Override public void onDestroy() { super.onDestroy(); - for (WebViewInstance instance : instances.values()) { + // Copy: destroying a fullscreen instance finishes its activity, whose + // onDestroy() removes it from the map. + for (WebViewInstance instance : new ArrayList<>(instances.values())) { try { instance.destroy(); } catch (Exception ignored) {} } instances.clear(); From 035844b36ba3b45a7bff4ed48a281b52d27c7163 Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Sun, 26 Jul 2026 19:21:01 +0530 Subject: [PATCH 6/6] improvements --- src/index.d.ts | 42 +-- src/lib/webview.js | 13 +- .../com/foxdebug/webview/WebViewActivity.java | 5 + .../com/foxdebug/webview/WebViewInstance.java | 266 +++++++----------- .../com/foxdebug/webview/WebViewPlugin.java | 66 ++++- 5 files changed, 189 insertions(+), 203 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index 914103538..12e76f127 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -114,27 +114,25 @@ declare global { interface WebViewOptions { /** Title applied to the hosting activity in fullscreen mode. */ title?: string; - /** Display mode. Defaults to "hidden". */ - mode?: "fullscreen" | "window" | "panel" | "hidden"; - /** Width in dp ("window" mode). */ - width?: number; - /** Height in dp ("window"/"panel" modes). */ - height?: number; - /** Left offset in dp ("window" mode, centered when unset). */ - x?: number; - /** Top offset in dp ("window" mode, centered when unset). */ - y?: number; + /** + * "fullscreen" displays the WebView in its own activity, "hidden" runs it + * headless (never displayed). Defaults to "hidden". + */ + mode?: "fullscreen" | "hidden"; /** * Allow in-WebView navigation. Defaults to true. Only http(s) targets * ever load; other schemes are always blocked for isolation. */ allowNavigation?: boolean; - /** Ask the user before downloading files. Defaults to false. */ + /** + * Ask the user with a confirmation dialog before downloading files via + * the system DownloadManager. Defaults to false. + */ allowDownloads?: boolean; /** - * Show immediately after creation. Defaults to true. When false, window - * and panel instances stay detached and fullscreen instances defer their - * activity launch until show() is called. + * Show immediately after creation. Defaults to true. Only meaningful for + * fullscreen mode: when false, the activity launch is deferred until + * show() is called. */ visible?: boolean; } @@ -149,18 +147,22 @@ interface AcodeWebView { onMessage(callback: (message: unknown) => void): void; offMessage(callback: (message: unknown) => void): void; /** - * Subscribe to lifecycle events: "pageFinished", "titleChanged", - * "dismissed" (backdrop tap in "window" mode) and "closed" (fullscreen - * closed by the user or by hide()). After "closed" the instance is - * destroyed and cannot be reused. + * Subscribe to lifecycle events: "pageFinished", "titleChanged" and + * "closed" (fullscreen closed by the user or the system). After "closed" + * the instance is destroyed and cannot be reused. */ on(event: string, callback: (event: string, data?: unknown) => void): void; off(event: string, callback: (event: string, data?: unknown) => void): void; postMessage(message: unknown): Promise; + /** + * Show the WebView. Only fullscreen instances can be shown; rejects for + * "hidden" mode. + */ show(): Promise; /** - * Hide the WebView. In fullscreen mode this closes the hosting activity, - * which destroys the instance and emits "closed". + * Hide the WebView. Fullscreen instances are moved to the background; + * show() brings the same WebView back with its page state intact. + * No-op for "hidden" mode. */ hide(): Promise; reload(): Promise; diff --git a/src/lib/webview.js b/src/lib/webview.js index 00f22ab0c..26a3bb7c3 100644 --- a/src/lib/webview.js +++ b/src/lib/webview.js @@ -144,13 +144,16 @@ const webviewAPI = { async create(options = {}) { ensureInit(); + const mode = options.mode || "hidden"; + if (mode !== "fullscreen" && mode !== "hidden") { + throw new Error( + `Unsupported WebView mode: "${mode}". Use "fullscreen" or "hidden".`, + ); + } + const id = await nativeBridge.create({ title: options.title || "", - mode: options.mode || "hidden", - width: options.width || 0, - height: options.height || 0, - x: options.x || 0, - y: options.y || 0, + mode, allowNavigation: options.allowNavigation !== false, allowDownloads: options.allowDownloads === true, visible: options.visible !== false, diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java index 6e55a44cb..6090979a7 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java @@ -35,6 +35,7 @@ public void onCreate(Bundle savedInstanceState) { finish(); return; } + instance.setHostingActivity(this); String title = instance.getTitle(); if (title != null && !title.isEmpty()) { @@ -45,6 +46,9 @@ public void onCreate(Bundle savedInstanceState) { ((ViewGroup) webView.getParent()).removeView(webView); } FrameLayout container = new FrameLayout(this); + // Edge-to-edge is enforced on newer Android versions, so pad the content + // out from under the status and navigation bars. + WebViewInstance.applySystemBarInsets(container); container.addView(webView, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT @@ -73,6 +77,7 @@ protected void onDestroy() { if (plugin != null && webviewId != null) { WebViewInstance instance = plugin.getInstance(webviewId); if (instance != null) { + instance.clearHostingActivity(this); // Actually destroy the WebView instead of leaking it, then drop the // instance so later calls fail instead of touching a dead WebView. // destroy() is idempotent, so this is safe when the JS side already diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java index c03991710..72ecefc67 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java @@ -2,19 +2,18 @@ import android.app.Activity; import android.app.AlertDialog; -import android.app.DownloadManager; import android.content.Context; import android.content.DialogInterface; import android.graphics.Bitmap; -import android.graphics.Color; +import android.graphics.Insets; import android.net.Uri; -import android.os.Environment; +import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Log; -import android.view.Gravity; import android.view.View; import android.view.ViewGroup; +import android.view.WindowInsets; import android.webkit.DownloadListener; import android.webkit.JavascriptInterface; import android.webkit.URLUtil; @@ -24,8 +23,8 @@ import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; -import android.widget.FrameLayout; import android.widget.Toast; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.cordova.CallbackContext; @@ -64,42 +63,28 @@ public class WebViewInstance { final String id; final String mode; final String title; - final int width; - final int height; - final int x; - final int y; final boolean allowNavigation; final boolean allowDownloads; final WebViewPlugin plugin; private WebView webView; - private FrameLayout container; - private Activity activity; + /** The activity hosting this instance in fullscreen mode, while alive. */ + private WebViewActivity hostingActivity = null; private boolean isDestroyed = false; - private boolean isAttached = false; - /** Whether the hosting fullscreen activity has been launched. */ - private boolean launched = false; /** Content requested before the fullscreen WebView exists yet. */ private String pendingUrl = null; private String pendingHtml = null; WebViewInstance( String id, String mode, String title, - int width, int height, int x, int y, boolean allowNavigation, boolean allowDownloads, - Activity activity, WebViewPlugin plugin ) { this.id = id; this.mode = mode; this.title = title; - this.width = width; - this.height = height; - this.x = x; - this.y = y; this.allowNavigation = allowNavigation; this.allowDownloads = allowDownloads; - this.activity = activity; this.plugin = plugin; } @@ -115,18 +100,24 @@ boolean isFullscreen() { return "fullscreen".equals(mode); } - void markLaunched() { - launched = true; + WebViewActivity getHostingActivity() { + return hostingActivity; + } + + void setHostingActivity(WebViewActivity activity) { + hostingActivity = activity; + } + + void clearHostingActivity(WebViewActivity activity) { + if (hostingActivity == activity) { + hostingActivity = null; + } } void createWebView(Activity activity) { // Idempotent: a recreated hosting activity reuses the existing WebView // (and its page state) instead of leaking one instance per recreation. - if (webView != null) { - this.activity = activity; - return; - } - this.activity = activity; + if (webView != null) return; webView = new WebView(activity); WebSettings settings = webView.getSettings(); @@ -177,57 +168,34 @@ private static void injectBridge(WebView view) { view.evaluateJavascript(BRIDGE_JS, null); } - void attachToActivity() { - if (isAttached || isDestroyed || webView == null || activity == null) return; - if (activity.isFinishing()) return; - - container = new FrameLayout(activity); - container.setBackgroundColor(Color.argb(180, 0, 0, 0)); - - FrameLayout.LayoutParams webViewParams; - if (mode.equals("window")) { - int w = width > 0 ? dpToPx(activity, width) : ViewGroup.LayoutParams.MATCH_PARENT; - int h = height > 0 ? dpToPx(activity, height) : ViewGroup.LayoutParams.MATCH_PARENT; - webViewParams = new FrameLayout.LayoutParams(w, h); - if (x > 0 || y > 0) { - webViewParams.gravity = Gravity.TOP | Gravity.START; - webViewParams.setMargins(dpToPx(activity, x), dpToPx(activity, y), 0, 0); - } else { - webViewParams.gravity = Gravity.CENTER; - webViewParams.setMargins( - dpToPx(activity, 16), dpToPx(activity, 48), - dpToPx(activity, 16), dpToPx(activity, 48) - ); - } - container.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - container.setVisibility(View.GONE); - plugin.sendEventToCordova(id, "dismissed", null); + /** + * Pads the view so content stays clear of the status and navigation bars. + * Required on API 35+ where edge-to-edge is enforced for the app; on older + * versions the window usually consumes the insets first, making the padding + * zero and this a no-op. + */ + @SuppressWarnings("deprecation") + static void applySystemBarInsets(final View view) { + view.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + int left, top, right, bottom; + if (Build.VERSION.SDK_INT >= 30) { + Insets bars = insets.getInsets(WindowInsets.Type.systemBars()); + left = bars.left; + top = bars.top; + right = bars.right; + bottom = bars.bottom; + } else { + left = insets.getSystemWindowInsetLeft(); + top = insets.getSystemWindowInsetTop(); + right = insets.getSystemWindowInsetRight(); + bottom = insets.getSystemWindowInsetBottom(); } - }); - } else { - webViewParams = new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - height > 0 ? dpToPx(activity, height) : (int) (getScreenHeight(activity) * 0.4) - ); - webViewParams.gravity = Gravity.BOTTOM; - } - - if (webView.getParent() != null) { - ((ViewGroup) webView.getParent()).removeView(webView); - } - container.addView(webView, webViewParams); - - ViewGroup rootView = activity.findViewById(android.R.id.content); - if (rootView instanceof FrameLayout) { - rootView.addView(container, new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )); - } - - isAttached = true; + v.setPadding(left, top, right, bottom); + return insets; + } + }); } private static final Pattern SCHEME_PATTERN = @@ -375,90 +343,57 @@ public void run() { } void show(final CallbackContext callbackContext) { - if (isFullscreen()) { - showFullscreen(callbackContext); - return; - } - runOnUiThread(new Runnable() { - @Override - public void run() { - if (isDestroyed) { - callbackContext.error("WebView has been destroyed"); - return; - } - if (!isAttached) { - attachToActivity(); - } - if (container != null) { - container.setVisibility(View.VISIBLE); - callbackContext.success(); - } else { - callbackContext.error("Cannot show"); - } - } - }); - } - - private void showFullscreen(CallbackContext callbackContext) { if (isDestroyed) { callbackContext.error("WebView has been destroyed"); return; } - if (launched) { - callbackContext.success(); + if (!isFullscreen()) { + callbackContext.error("Hidden WebViews cannot be shown; use mode \"fullscreen\" to display content"); return; } - launched = true; + // Launches the hosting activity, or brings it back to the front if it + // is still alive (e.g. after hide() backgrounded it). runOnUiThread(new Runnable() { @Override public void run() { - plugin.launchFullscreenActivity(id); + plugin.showFullscreenActivity(id); } }); callbackContext.success(); } void hide(final CallbackContext callbackContext) { - if (isFullscreen()) { - hideFullscreen(callbackContext); + if (isDestroyed) { + callbackContext.error("WebView has been destroyed"); return; } - runOnUiThread(new Runnable() { - @Override - public void run() { - if (isDestroyed) { - callbackContext.error("WebView has been destroyed"); - return; - } - // Hiding an already hidden (never attached) WebView is a no-op. - if (container != null) { - container.setVisibility(View.GONE); - } - callbackContext.success(); - } - }); + if (!isFullscreen()) { + // A hidden (headless) WebView is already hidden. + callbackContext.success(); + return; + } + hideFullscreen(callbackContext); } /** - * A fullscreen WebView is hosted by its own activity, so hiding it means - * closing that activity. WebViewActivity.onDestroy() then destroys the - * instance and emits the "closed" event. + * Hiding a fullscreen WebView moves its hosting activity (and its task) + * to the background. Nothing is destroyed, so show() brings the same + * WebView back with its page state intact. The instance is only destroyed + * when the user actually closes it (back button/task removal) or when + * destroy() is called. */ private void hideFullscreen(CallbackContext callbackContext) { if (isDestroyed) { callbackContext.error("WebView has been destroyed"); return; } - if (!launched) { - callbackContext.success(); - return; - } - launched = false; runOnUiThread(new Runnable() { @Override public void run() { - if (activity instanceof WebViewActivity && !activity.isFinishing()) { - activity.finish(); + if (hostingActivity != null + && !hostingActivity.isFinishing() + && !hostingActivity.isDestroyed()) { + hostingActivity.moveTaskToBack(true); } } }); @@ -489,16 +424,15 @@ void destroy() { // Finish the hosting fullscreen activity, if any. Its onDestroy() calls // destroy() again, which is a no-op now that isDestroyed is set. - if (activity instanceof WebViewActivity && !activity.isFinishing()) { - activity.finish(); + WebViewActivity hosting = hostingActivity; + hostingActivity = null; + if (hosting != null && !hosting.isFinishing()) { + hosting.finish(); } runOnUiThread(new Runnable() { @Override public void run() { - if (container != null && container.getParent() != null) { - ((ViewGroup) container.getParent()).removeView(container); - } if (webView != null) { if (webView.getParent() != null) { ((ViewGroup) webView.getParent()).removeView(webView); @@ -511,8 +445,6 @@ public void run() { webView.destroy(); } webView = null; - container = null; - isAttached = false; } }); } @@ -537,14 +469,6 @@ private static void runOnUiThread(Runnable runnable) { new Handler(Looper.getMainLooper()).post(runnable); } - private static int dpToPx(Context context, int dp) { - return (int) (dp * context.getResources().getDisplayMetrics().density); - } - - private static int getScreenHeight(Activity activity) { - return activity.getResources().getDisplayMetrics().heightPixels; - } - private class InstanceWebViewClient extends WebViewClient { @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { @@ -607,32 +531,38 @@ private class InstanceDownloadListener implements DownloadListener { @Override public void onDownloadStart(final String url, final String userAgent, String contentDisposition, final String mimeType, long contentLength) { + if (isDestroyed) return; if (context instanceof Activity && ((Activity) context).isFinishing()) return; + + // DownloadManager can only fetch http(s) URLs. + String scheme = Uri.parse(url).getScheme(); + if (scheme == null + || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(context, "This download type is not supported", Toast.LENGTH_SHORT).show(); + } + }); + return; + } + final String fileName = URLUtil.guessFileName(url, contentDisposition, mimeType); + String size = formatSize(contentLength); + final String message = size.isEmpty() + ? "Do you want to download \"" + fileName + "\"?" + : "Do you want to download \"" + fileName + "\" (" + size + ")?"; runOnUiThread(new Runnable() { @Override public void run() { new AlertDialog.Builder(context) .setTitle("Download file") - .setMessage("Do you want to download \"" + fileName + "\"?") - .setPositiveButton("Yes", new DialogInterface.OnClickListener() { + .setMessage(message) + .setPositiveButton("Download", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); - request.setMimeType(mimeType); - request.addRequestHeader("User-Agent", userAgent); - request.setDescription("Downloading file..."); - request.setTitle(fileName); - request.allowScanningByMediaScanner(); - request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); - request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); - - DownloadManager dm = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - if (dm != null) { - dm.enqueue(request); - Toast.makeText(context, "Download started...", Toast.LENGTH_SHORT).show(); - } + plugin.download(url, userAgent, mimeType, fileName); } }) .setNegativeButton("Cancel", null) @@ -642,6 +572,14 @@ public void onClick(DialogInterface dialog, int which) { } } + private static String formatSize(long bytes) { + if (bytes <= 0) return ""; + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format(Locale.US, "%.1f KB", bytes / 1024.0); + if (bytes < 1024L * 1024 * 1024) return String.format(Locale.US, "%.1f MB", bytes / (1024.0 * 1024)); + return String.format(Locale.US, "%.1f GB", bytes / (1024.0 * 1024 * 1024)); + } + public class JsBridge { @JavascriptInterface public void postMessage(String message) { diff --git a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java index 1f2f48f2b..db6adb85c 100644 --- a/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java @@ -1,7 +1,13 @@ package com.foxdebug.webview; +import android.app.DownloadManager; +import android.content.Context; import android.content.Intent; +import android.net.Uri; +import android.os.Environment; import android.util.Log; +import android.webkit.CookieManager; +import android.widget.Toast; import java.util.ArrayList; import java.util.HashMap; import java.util.UUID; @@ -85,10 +91,6 @@ private void create(JSONObject options, final CallbackContext callbackContext) t final String id = generateId(); final String mode = options.optString("mode", "hidden"); final String title = options.optString("title", ""); - final int width = options.optInt("width", 0); - final int height = options.optInt("height", 0); - final int x = options.optInt("x", 0); - final int y = options.optInt("y", 0); final boolean allowNavigation = options.optBoolean("allowNavigation", true); final boolean allowDownloads = options.optBoolean("allowDownloads", false); final boolean visible = options.optBoolean("visible", true); @@ -98,9 +100,7 @@ private void create(JSONObject options, final CallbackContext callbackContext) t public void run() { WebViewInstance instance = new WebViewInstance( id, mode, title, - width, height, x, y, allowNavigation, allowDownloads, - cordova.getActivity(), WebViewPlugin.this ); instances.put(id, instance); @@ -111,16 +111,11 @@ public void run() { // caller asked for an initially hidden instance, the launch is // deferred until show() is called. if (visible) { - instance.markLaunched(); - launchFullscreenActivity(id); + showFullscreenActivity(id); } } else { + // "hidden" mode: a headless WebView that is never displayed. instance.createWebView(cordova.getActivity()); - // Keep visibility separate from mode: window/panel instances that - // should start hidden stay detached until show() attaches them. - if (visible && (mode.equals("window") || mode.equals("panel"))) { - instance.attachToActivity(); - } } callbackContext.success(id); } catch (Exception e) { @@ -135,9 +130,19 @@ public void run() { }); } - void launchFullscreenActivity(String id) { + /** + * Shows a fullscreen WebView: launches its hosting activity, or brings the + * existing one back to the front if it is still alive (e.g. after hide() + * backgrounded it), preserving the WebView's page state. + */ + void showFullscreenActivity(String id) { Intent intent = new Intent(cordova.getActivity(), WebViewActivity.class); intent.putExtra("webviewId", id); + WebViewInstance target = getInstance(id); + WebViewActivity hosting = target != null ? target.getHostingActivity() : null; + if (hosting != null && !hosting.isFinishing() && !hosting.isDestroyed()) { + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + } cordova.getActivity().startActivity(intent); } @@ -204,6 +209,39 @@ public void run() { }); } + /** + * Starts a confirmed download via the system DownloadManager into the + * public Downloads directory. The WebView's cookies are forwarded so + * authenticated downloads work. + */ + void download(String url, String userAgent, String mimeType, String fileName) { + Context context = cordova.getActivity(); + try { + DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); + request.setMimeType(mimeType); + request.addRequestHeader("User-Agent", userAgent); + String cookie = CookieManager.getInstance().getCookie(url); + if (cookie != null && !cookie.isEmpty()) { + request.addRequestHeader("Cookie", cookie); + } + request.setDescription("Downloading file..."); + request.setTitle(fileName); + request.allowScanningByMediaScanner(); + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); + + DownloadManager dm = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + if (dm == null) { + throw new IllegalStateException("DownloadManager unavailable"); + } + dm.enqueue(request); + Toast.makeText(context, "Download started...", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "Download failed", e); + Toast.makeText(context, "Download failed", Toast.LENGTH_SHORT).show(); + } + } + public void sendMessageToCordova(String id, String message) { if (messageCallback == null) return;