Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/lib/webview.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ class WebView {
await nativeBridge.reload(this.id);
}

async setUserAgent(userAgent) {
this._checkDestroyed();
await nativeBridge.setUserAgent(this.id, userAgent);
}

async destroy() {
this._checkDestroyed();
if (!this._destroyPromise) {
Expand Down Expand Up @@ -168,6 +173,8 @@ const webviewAPI = {
allowNavigation: options.allowNavigation !== false,
allowDownloads: options.allowDownloads === true,
visible: options.visible !== false,
incognito: options.incognito !== false,
userAgent: options.userAgent || null,
});

return new WebView(id, options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import android.webkit.URLUtil;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
Expand Down Expand Up @@ -65,8 +66,15 @@ public class WebViewInstance {
final String title;
final boolean allowNavigation;
final boolean allowDownloads;
final boolean incognito;
final WebViewPlugin plugin;

/**
* Custom User-Agent override, or null for the system default. Applied when
* the WebView is created and updated via {@link #setUserAgent}.
*/
String userAgent;

private WebView webView;
/** The activity hosting this instance in fullscreen mode, while alive. */
private WebViewActivity hostingActivity = null;
Expand All @@ -78,13 +86,16 @@ public class WebViewInstance {
WebViewInstance(
String id, String mode, String title,
boolean allowNavigation, boolean allowDownloads,
boolean incognito, String userAgent,
WebViewPlugin plugin
) {
this.id = id;
this.mode = mode;
this.title = title;
this.allowNavigation = allowNavigation;
this.allowDownloads = allowDownloads;
this.incognito = incognito;
this.userAgent = (userAgent == null || userAgent.isEmpty()) ? null : userAgent;
this.plugin = plugin;
}

Expand Down Expand Up @@ -133,6 +144,20 @@ void createWebView(Activity activity) {
settings.setLoadWithOverviewMode(true);
settings.setUseWideViewPort(true);

if (incognito) {
// Never serve from the HTTP cache. Note: Android WebView exposes no
// per-view "don't store" mode, and its disk cache is shared with the
// host app, so we deliberately do NOT clear it here — wiping it would
// evict the main app WebView's cache too. History and form data are
// per-view and are cleared on destroy() below. Cookies live in the
// app-wide CookieManager singleton (shared with the host app), so
// per-instance cookie incognito is not possible on this platform.
settings.setCacheMode(WebSettings.LOAD_NO_CACHE);
}
if (userAgent != null) {
settings.setUserAgentString(userAgent);
}

webView.setWebViewClient(new InstanceWebViewClient());
webView.setWebChromeClient(new InstanceWebChromeClient());
webView.setFocusable(true);
Expand Down Expand Up @@ -274,6 +299,34 @@ public void run() {
});
}

/**
* Overrides the User-Agent for this instance. Takes effect immediately
* when the WebView exists; otherwise it is stored and applied when the
* WebView is created (fullscreen instances are created lazily).
*/
void setUserAgent(final String ua, final CallbackContext callbackContext) {
if (isDestroyed) {
callbackContext.error("WebView has been destroyed");
return;
}
if (ua == null || ua.trim().isEmpty()) {
callbackContext.error("User agent must not be empty");
return;
}
userAgent = ua;
if (webView == null) {
callbackContext.success();
return;
}
runOnUiThread(new Runnable() {
@Override
public void run() {
webView.getSettings().setUserAgentString(userAgent);
callbackContext.success();
}
});
}

void evaluate(String js, final CallbackContext callbackContext) {
if (isDestroyed) {
callbackContext.error("WebView has been destroyed");
Expand Down Expand Up @@ -441,6 +494,13 @@ public void run() {
webView.setDownloadListener(null);
webView.setWebChromeClient(null);
webView.setWebViewClient(null);
// Incognito cleanup: per-view state only. The shared disk cache
// and the app-wide cookies are intentionally left alone (see
// createWebView() for why).
if (incognito) {
webView.clearHistory();
webView.clearFormData();
}
webView.loadUrl("about:blank");
webView.destroy();
}
Expand Down Expand Up @@ -499,16 +559,85 @@ 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);
try {
JSONObject data = new JSONObject();
data.put("url", url != null ? url : "");
plugin.sendEventToCordova(id, "pageStarted", data);
} catch (JSONException e) {
Log.e(TAG, "onPageStarted error", e);
}
}

@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
WebViewInstance.this.onPageFinished(view);
}

@Override
public void onReceivedError(
WebView view, WebResourceRequest request, WebResourceError error
) {
// Do NOT call super.onReceivedError(view, request, error): the base
// implementation forwards main-frame errors to the deprecated
// onReceivedError() below via virtual dispatch, which would emit a
// second, duplicate loadError for the same failure. This modern
// callback is the canonical path (minSdk is 26, so API 23+ is
// guaranteed); it emits directly and exactly once.
// Sub-resource failures are noise here; only the main frame matters.
if (request != null && !request.isForMainFrame()) return;
Uri url = request != null ? request.getUrl() : null;
int code = error != null ? error.getErrorCode() : -1;
CharSequence description = error != null ? error.getDescription() : null;
sendLoadError(
url != null ? url.toString() : null,
code,
description != null ? description.toString() : null
);
}

@SuppressWarnings("deprecation")
@Override
public void onReceivedError(
WebView view, int errorCode, String description, String failingUrl
) {
super.onReceivedError(view, errorCode, description, failingUrl);
// Fallback for API < 23 only. On current targets the modern callback
// above is the entry point and never delegates here, so this path
// cannot double-emit.
// Legacy API cannot tell sub-resource failures apart, so only report
// when the failing URL matches the page being loaded.
String current = view.getUrl();
if (failingUrl == null || !failingUrl.equals(current)) return;
sendLoadError(failingUrl, errorCode, description);
}

private void sendLoadError(String url, int code, String description) {
try {
JSONObject data = new JSONObject();
data.put("url", url != null ? url : "");
data.put("code", code);
data.put("description", description != null ? description : "");
plugin.sendEventToCordova(id, "loadError", data);
} catch (JSONException e) {
Log.e(TAG, "onReceivedError error", e);
}
}
}

private class InstanceWebChromeClient extends WebChromeClient {
@Override
public void onProgressChanged(WebView view, int newProgress) {
super.onProgressChanged(view, newProgress);
try {
JSONObject data = new JSONObject();
data.put("progress", newProgress);
plugin.sendEventToCordova(id, "progressChanged", data);
} catch (JSONException e) {
Log.e(TAG, "onProgressChanged error", e);
}
}

@Override
public void onReceivedTitle(WebView view, String pageTitle) {
super.onReceivedTitle(view, pageTitle);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo
case "reload":
reload(args.getString(0), callbackContext);
return true;
case "setUserAgent":
setUserAgent(args.getString(0), args.getString(1), callbackContext);
return true;
case "destroy":
destroy(args.getString(0), callbackContext);
return true;
Expand All @@ -94,13 +97,18 @@ private void create(JSONObject options, final CallbackContext callbackContext) t
final boolean allowNavigation = options.optBoolean("allowNavigation", true);
final boolean allowDownloads = options.optBoolean("allowDownloads", false);
final boolean visible = options.optBoolean("visible", true);
final boolean incognito = options.optBoolean("incognito", true);
// optString() turns an explicit JSON null into the literal "null" string;
// isNull() covers both a missing key and a real null.
final String userAgent = options.isNull("userAgent") ? null : options.optString("userAgent", null);

cordova.getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
WebViewInstance instance = new WebViewInstance(
id, mode, title,
allowNavigation, allowDownloads,
incognito, userAgent,
WebViewPlugin.this
);
instances.put(id, instance);
Expand Down Expand Up @@ -196,6 +204,12 @@ private void reload(String id, CallbackContext callbackContext) {
instance.reload(callbackContext);
}

private void setUserAgent(String id, String userAgent, CallbackContext callbackContext) {
WebViewInstance instance = getInstance(id);
if (instance == null) { callbackContext.error("WebView not found: " + id); return; }
instance.setUserAgent(userAgent, 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; }
Expand Down
32 changes: 32 additions & 0 deletions src/plugins/webview/www/webview.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,37 @@ function destroy(id) {
});
}

/**
* Overrides the User-Agent for an existing WebView instance.
* @param {string} id
* @param {string} userAgent non-empty User-Agent string
*/
function setUserAgent(id, userAgent) {
return new Promise((resolve, reject) => {
cordova.exec(resolve, reject, SERVICE, "setUserAgent", [id, userAgent]);
});
}

/**
* create() options:
* mode "hidden" | "fullscreen" (default "hidden")
* title string (default "")
* allowNavigation boolean (default true)
* allowDownloads boolean (default false)
* visible boolean (default true)
* incognito boolean (default true) — never serve from the HTTP cache;
* clears navigation history and form data on destroy.
* Cookies stay shared with the host app (platform limit).
* userAgent string (optional) — custom User-Agent for the instance
*
* Events delivered via setMessageCallback payloads ({ id, event, data }):
* pageStarted { url }
* pageFinished { url, title }
* progressChanged { progress } // 0..100
* loadError { url, code, description }
* titleChanged { title }
*/

export default {
setMessageCallback,
create,
Expand All @@ -85,4 +116,5 @@ export default {
hide,
reload,
destroy,
setUserAgent,
Comment thread
xaniexane marked this conversation as resolved.
};