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..3e0ecf694 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -74,6 +74,7 @@ import helpers from "utils/helpers"; import KeyboardEvent from "utils/keyboardEvent"; import Url from "utils/Url"; import config from "./config"; +import webview from "./webview"; class Acode { #modules = {}; @@ -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..ae96cb662 --- /dev/null +++ b/src/lib/webview.js @@ -0,0 +1,159 @@ +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; + } +} + +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"); + } + } +} + +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..54b7304c6 --- /dev/null +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewActivity.java @@ -0,0 +1,67 @@ +package com.foxdebug.webview; + +import android.app.Activity; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.view.ViewGroup; +import android.webkit.WebView; +import android.widget.FrameLayout; + +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"); + + WebViewInstance instance = plugin != null ? plugin.getInstance(webviewId) : 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, + ViewGroup.LayoutParams.MATCH_PARENT + )); + setContentView(container); + + if (Build.VERSION.SDK_INT >= 30) { + getWindow().setDecorFitsSystemWindows(false); + } + } + + @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); + 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 new file mode 100644 index 000000000..592ce2fdf --- /dev/null +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewInstance.java @@ -0,0 +1,407 @@ +package com.foxdebug.webview; + +import android.app.Activity; +import android.content.DialogInterface; +import android.graphics.Color; +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.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 int width; + final int height; + final boolean allowNavigation; + final boolean allowDownloads; + 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, + int width, int height, + boolean allowNavigation, boolean allowDownloads, + Activity activity, + WebViewPlugin plugin + ) { + this.id = id; + this.mode = mode; + this.width = width; + this.height = height; + this.allowNavigation = allowNavigation; + this.allowDownloads = allowDownloads; + this.activity = activity; + this.plugin = plugin; + } + + public WebView getWebView() { + return webView; + } + + void createWebView(Activity activity) { + this.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(false); + + 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() { + 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 ? 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) + ); + container.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + container.setVisibility(View.GONE); + } + }); + } 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; + } + + 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 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) { + if (callbackContext != null) callbackContext.error("WebView destroyed"); + return; + } + if (!isAttached) { + attachToActivity(); + } + if (container != null) { + container.setVisibility(View.VISIBLE); + if (callbackContext != null) callbackContext.success(); + } else { + if (callbackContext != null) callbackContext.error("Cannot show"); + } + } + }); + } + + 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(); + } + }); + } + + 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; + isAttached = false; + } + }); + } + + 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, String url) { + return !allowNavigation; + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return !allowNavigation; + } + + @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); + } + } +} 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..22104bcfa --- /dev/null +++ b/src/plugins/webview/src/android/com/foxdebug/webview/WebViewPlugin.java @@ -0,0 +1,260 @@ +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 int width = options.optInt("width", 0); + final int height = options.optInt("height", 0); + final boolean allowNavigation = options.optBoolean("allowNavigation", true); + final boolean allowDownloads = options.optBoolean("allowDownloads", false); + + 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; + } + + instance.createWebView(cordova.getActivity()); + + if (mode.equals("window") || mode.equals("panel")) { + instance.attachToActivity(); + } + + 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, boolean allowNavigation) { + Intent intent = new Intent(cordova.getActivity(), WebViewActivity.class); + intent.putExtra("webviewId", id); + intent.putExtra("allowNavigation", allowNavigation); + cordova.getActivity().startActivity(intent); + } + + 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; } + 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, +};