The background,

Recently, when exploring JSBridge, I learned of Tencent’s scheme, which realizes communication with JS without injecting objects into WebView. In order to verify the scheme, I wrote a demo for practice.

2. Demonstration of the scheme

The idea of the scheme is that JS and Native agree on a specific Invoke protocol in advance and passwindow.prompt()To pass the protocol to WebChromeClientonJSPrompt()In method, after obtaining the protocol, native parses the protocol and initiates the call. Why not just addJavascriptInterface() to inject native objects instead of doing all the work?

This is because direct injection of objects on the Android side will lead to XSS vulnerability: after obtaining the injected object, JS can use the object’s class loader to maliciously load and call Android projects and system methods. For example, reflection calls the runtime.exec () method to execute commands to query data in the phone’s storage.

<! -- The Web side uses native instance objects provided by the Android side, Function illegalInvokeJavaMethod(Android){var CLZ = android.getClass().getClassLoader().loadClass("java.lang.Runtime"); clz.getDeclaredMethod("exec").invoke("sh"); }Copy the code

In android4.4, methods annotated by @javascriptinterface can only be called by js. However, if your project still supports android4.4 or lower, you will need to address this vulnerability, which is not present in the jsbridge solution above.

Protocol analysis

Agreement,

jsbridge://className/functionName? params=xxCopy the code

All classes and methods called by JS are specified in an interface table h5api.js,

H5BusinessPlugin.trackJSMessage
H5DevicePlugin.deviceInfo
...
Copy the code

The interface table H5api.js is parsed during app initialization,

Private final static List<String> pluginForm = new ArrayList >(); private static void initPluginForm(Context context) { String apiStr = IOUtils.readStringFromAssets(context, "H5API.js");  String[] apiArray = apiStr.split("\n|\r\n|\r"); for (String api : apiArray) { String[] comps = api.split("\\."); if (comps.length ! = 2) { continue; } String className = comps[0]; String methodName = comps[1]; if (TextUtils.isEmpty(className) || TextUtils.isEmpty(methodName)) { continue; } pluginForm.add(api); }}Copy the code

The lazy loading strategy assembles the PluginHandler, and the successful assembly is stored in a HashMap. Before using the HashMap, check whether the corresponding PluginHandler already exists in the HashMap. If so, fetch it directly; if not, assemble it first.

HashMap<String, PluginHandler> pluginHandlers = new HashMap<>();
Copy the code
/ / by name or take corresponding plugin public static PluginHandler getPluginHandler (String pluginName) {if (TextUtils. IsEmpty (pluginName) | |! pluginForm.contains(pluginName)) { return null; } PluginHandler handler = pluginHandlers.get(pluginName); if (handler ! = null) { return handler; } return registerHandlers(pluginName); Private static PluginHandler registerHandlers(String pluginName) {try {String[] comps = pluginName.split("\\."); if (comps.length ! = 2) { return null; } String className = "com.example.myjsbridge.plugin." + comps[0]; Class<? > handlerClass = Class.forName(className); Method handlerMethod = handlerClass.getDeclaredMethod(comps[1]); handlerMethod.setAccessible(true); PluginHandler handler = (PluginHandler) handlerMethod.invoke(null); pluginHandlers.put(pluginName, handler); return handler; } catch (Exception e) { e.printStackTrace(); return null; }}Copy the code

Using the strategy design pattern, when a JS call is received, the PluginHandler is retrieved from the hashMap and execute(), where the specific business logic is completed.

private void parseInvokeUrl(String invokeUrl) { try { Uri uri = Uri.parse(invokeUrl); String scheme = uri.getScheme(); if (!" jsbridge".equals(scheme)) { reportInvokeError(); return; } String host = uri.getHost(); if (TextUtils.isEmpty(host)) { reportInvokeError(); return; } host = "H5" + host + "Plugin"; String path = uri.getPath(); if (TextUtils.isEmpty(path)) { reportInvokeError(); return; } path = path.replace("/", ""); PluginHandler handler = H5PluginFactory.pluginHandlers.get(host + "." + path); if (handler ! = null) { handler.h5WebView = this; handler.execute(uri.getQueryParameter("params")); } } catch (Exception e) { e.printStackTrace(); }}Copy the code

Take the example of trackJSMessge() of the H5BusinessPlugin.

private void parseInvokeUrl(String invokeUrl) { try { Uri uri = Uri.parse(invokeUrl); String scheme = uri.getScheme(); if (!" jsbridge".equals(scheme)) { reportInvokeError(); return; } String host = uri.getHost(); if (TextUtils.isEmpty(host)) { reportInvokeError(); return; } host = "H5" + host + "Plugin"; String path = uri.getPath(); if (TextUtils.isEmpty(path)) { reportInvokeError(); return; } path = path.replace("/", ""); PluginHandler handler = H5PluginFactory.getPluginHandler(host + "." + path); if (handler ! = null) { handler.h5WebView = this; handler.execute(uri.getQueryParameter("params")); } else { reportInvokeError(); } } catch (Exception e) { e.printStackTrace(); reportInvokeError(); }} private void reportInvokeError() {toast.maketext (mContext, "unreadable call ", toast.length_short).show(); }Copy the code

3. JS calls Native

For debugging purposes, you can have the WebView load a single local page with the HTML file in assets/web/.

webView.loadUrl("file:///android_asset/web/test.html");
Copy the code
<! DOCTYPE html> <html> <script> <! -- call H5BusinessPlugin. TrackJSMessage () method -- > function sayHello () {invoke (" Business ", "trackJSMessage", {"message":"Hello Native!" }); } <! -- call H5DevicePlugin. DeviceInfo () method -- > var CallBack = function (res) {alert (JSON. Stringify (res)} function getDeviceInfo () {  invoke("Device","deviceInfo",{"callback":"CallBack"}); } function notifyWebViewReady(param) { var tip = param + new Date(); console.log(tip); document.getElementById("result").innerHTML = tip; } <! Function invoke(object, func, params) {windod. prompt("jsbridge://" + object + "/"+ func + "? + "params="+JSON.stringify(params)); } </script> <head> <meta charset="utf-8"> <meta name="viewport" content="initial-scale=1"> <meta name="description" content="Html5 hello world page!" > <title>MyJSBridge</title> </head> <body class="blueBody"> <h1>JSBridge </h1> < HR > <p> No JS object injection, webView and native two-way communication! </p> <br> <div> <button onclick="sayHello()">say hello to native</button> <button onclick="getDeviceInfo()">get native device info</button> </div> <br> <span id="result" style="font-size: 20px;" ></span> </body> </html>Copy the code

Override the onJSPrompt() method of WebChromeClient to receive the JS call and parse the protocol to execute the corresponding PluginHandler call.

private void initWebViewClient() { this.setWebChromeClient(new WebChromeClient() { @Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { result.cancel(); parseInvokeUrl(message); return true; }}}Copy the code

4. Native calls JS

A Webview can call JS in two ways,

webview.loadUrl("javascript:jsfunction()")
Copy the code
webView.evaluateJavascript("javascript:(function() {" +
                        "notifyWebViewReady(\"WebView onCreated at \");" +
                        "})()", null);
Copy the code

Sometimes JS needs to return a value when invoking, so it can pass the return listening method callback to Native. When Native finishes processing the call logic, it can execute the callback method and return the processing result.

public void execute(String params) { try { JSONObject paramJSON = new JSONObject(params); String callback = null; if (paramJSON.has("callback")) { callback = paramJSON.getString("callback"); } JSONObject response = new JSONObject(); response.put("platform", "Android"); response.put("deviceId", "1233456"); // Return the result to js if (h5WebView! = null && ! TextUtils.isEmpty(callback)) { String func = "javascript:(function() {" + callback + "(JSON.stringify(" + response.toString() + "));" () "+"}); h5WebView.evaluateJavascript(func, null); } } catch (JSONException e) { e.printStackTrace(); }Copy the code

Github code

Attached demo complete code, has been uploaded to Github, please take a look.