Safe Java-JS Bridge In Android WebView(转)

找了几种Android 中java 与 js 交互的方法,先记录下:

1,

我们可以通过WebViewClient在WebView中加钩子来获取触发事件和相 应。下面是一个简单的例子,浏览器中显示目前的时间,并作为一个链接,当我们点击这个链接时,更新为当前最新时间。

 package com.iteedu.webview;

import java.util.Date;
import android.app.Activity;
import android.os.Bundle;
import android.webkit.WebView;
import android.webkit.WebViewClient;


public class WebViewExample extends Activity {
	private WebView browser= null;
	 
	protected void onCreate(Bundle savedInstanceState) {
	        super.onCreate(savedInstanceState);
	        setContentView(R.layout.main);
	        browser = (WebView)findViewById(R.id.WebView01);
	        browser.setWebViewClient (new WebViewClient() {
	               /* 这个事件,将在用户点击链接时触发。
	                * 通过判断url,可确定如何操作,
	                * 如果返回true,表示我们已经处理了这个request,
	                * 如果返回false,表 示没有处理,
	                * 那么浏览器将会根据url获取网页*/
	                public boolean shouldOverrideUrlLoading (WebView view, String url) {
	                      loadTime(); 
	                      return true ; //表 示已经处理了这次URL的请求
	                }
	        });
	        
	        loadTime();
	 }
	 
	private void loadTime(){
	        String page="<html><body><a href=\"clock\">" + 
	        new Date().toString()  + "</a></body></html>";
	        /* 这里我们使用了loadDataWithBaseUrl的方式。
	         * 浏览器可以通过"file:///android_asset/"来获得映用中asset/的资源。
	         * 但是如果我们没有设置baseUrl,或者为空,这无法获取,
	         * 想获取这些资源,baseUrl不能设置为http(s)/ftp(s) /about/javascript
	         * 这些已有专属的定义。
	         * 在例子中,我们baseUrl设置为"wei://base". 
	         * 对于上面的那个链接地址为wei://base/clock */
	        browser.loadDataWithBaseURL ("wei://base", page, "text/html","UTF-8", null);
	 }
}
 
 
 
2,

如何让WebView中JS与Java安全地相互调用

在现在安卓应用原生开发中,为了追求开发的效率以及移植的便利性,使用WebView作为业务内容展示与交互的主要载体是个不错的折中方案。那么在这种Hybrid(混合式) App中,难免就会遇到页面JS需要与Java相互调用,调用Java方法去做那部分网页JS不能完成的功能。

网上的方法可以告诉我们这个时候我们可以使用addjavascriptInterface来注入原生接口到JS中,但是在安卓4.2以下的系统中,这种方案却我们的应用带来了很大的安全风险。攻击者如果在页面执行一些非法的JS(诱导用户打开一些钓鱼网站以进入风险页面),极有可能反弹拿到用户手机的shell权限。接下来攻击者就可以在后台默默安装木马,完全洞穿用户的手机。详细的攻击过程可以见乌云平台的这份报告:WebView中接口隐患与手机挂马利用

安卓4.2及以上版本(API >= 17),在注入类中为可调用的方法添加@JavascriptInterface注解,无注解的方法不能被调用,这种方式可以防范注入漏洞。那么有没一种安全的方式,可以完全兼顾安卓4.2以下版本呢?答案就是使用prompt,即WebChromeClient 输入框弹出模式。

我们参照 Android WebView的Js对象注入漏洞解决方案 这篇文章给出的解决方案, 但它JS下的方法有点笨拙, 动态生成JS文件过程也并没有清晰,且加载JS文件的时机也没有准确把握。那么如何改造才能便利地在JS代码中调用Java方法,并且安全可靠呢?

一、动态地生成将注入的JS代码

这个时候,我们利用Java反射机制,将要注入的类(cn.pedant.SafeJava4WebviewJS.webview.bridge.HostJsScope)的public且static方法拿出来,并获取该方法的参数个数,结合静态的HostApp Javascript代码动态生成一段将要注入到webview中的字符串。

JsCallJava.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static void init () {
    try {
        Class<?> SCOPECLASS = Class.forName("cn.pedant.SafeJava4WebviewJS.webview.bridge.HostJsScope");
        mMethodMap = new HashMap<String, Method>();
        //获取自身声明的所有方法(包括public private protected), getMethods会获得所有继承与非继承的方法
        Method[] methods = SCOPECLASS.getDeclaredMethods();
        StringBuilder sb = new StringBuilder("javascript:(function(e){console.log(\"HostApp initialization begin\");var f={queue:[],callback:function(){var a=Array.prototype.slice.call(arguments,0);var b=a.shift();var c = a.shift();this.queue[b].apply(this,a);if(!c)delete this.queue[b]}};");
        String methodName;
        for (Method method : methods) {
            //非公用静态方法,直接跳过
            if (method.getModifiers() != (Modifier.PUBLIC | Modifier.STATIC)) {
                continue;
            }
            methodName = method.getName();
            mMethodMap.put(keyConcat(methodName, method.getParameterTypes().length), method);
            sb.append(String.format("f.%s=", methodName));
        }
        sb.append("function(){var a=Array.prototype.slice.call(arguments,0);if(a.length<1){throw\"HostApp call error, message:miss method name\";}for(var i=1;i<a.length;i++){var b=a[i];if(typeof b==\"function\"){var c=f.queue.length;f.queue[c]=b;a[i]=c}}var d=JSON.parse(prompt(JSON.stringify({method:a.shift(),args:a})));if(d.code!=200){throw\"HostApp call error, code:\"+d.code+\", message:\"+d.result;}return d.result};Object.getOwnPropertyNames(f).forEach(function(a){var b=f[a];if(typeof b==='function'&&a!=='callback'){f[a]=function(){return b.apply(f,[a].concat(Array.prototype.slice.call(arguments,0)))}}});e.HostApp=f;console.log(\"HostApp initialization end\")})(window);");
        PRELOAD_INTERFACE_JS = sb.toString();
    } catch(Exception e){
        Log.e("init js error:" + e.getMessage());
    }
}

private static String keyConcat (String name, int len) {
    return name + "_" + len;
}

从上面可以看出,类的方法及参数信息被拼接到前后两段静态JS代码当中,那么这样生成的JS完整片段会是怎样的呢? 我们假设HostJsScope类中目前只定义了toast、alert、getIMSI这三个公开静态方法。

HostApp JS片段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
(function(global){
    console.log("HostApp initialization begin");
    var hostApp = {
        queue: [],
        callback: function () {
            var args = Array.prototype.slice.call(arguments, 0);
            var index = args.shift();
            var isPermanent = args.shift();
            this.queue[index].apply(this, args);
            if (!isPermanent) {
                delete this.queue[index];
            }
        }
    };
    hostApp.toast = hostApp.alert = hostApp.getIMSI = function () {
        var args = Array.prototype.slice.call(arguments, 0);
        if (args.length < 1) {
            throw "HostApp call error, message:miss method name";
        }
        for (var i = 1;i < args.length;i++) {
            var arg = args[i];
            if (typeof arg == "function") {
                var index = hostApp.queue.length;
                hostApp.queue[index] = arg;
                args[i] = index;
            }
        }
        var res = JSON.parse(prompt(JSON.stringify({
            method: args.shift(),
            args: args
        })));

        if (res.code != 200) {
            throw "HostApp call error, code:" + res.code + ", message:" + res.result;
        }
        return res.result;
    };

    //有时候,我们希望在该方法执行前插入一些其他的行为用来检查当前状态或是监测
    //代码行为,这就要用到拦截(Interception)或者叫注入(Injection)技术了
    /**
     * Object.getOwnPropertyName 返回一个数组,内容是指定对象的所有属性
     *
     * 其后遍历这个数组,分别做以下处理:
     * 1. 备份原始属性;
     * 2. 检查属性是否为 function(即方法);
     * 3. 若是重新定义该方法,做你需要做的事情,之后 apply 原来的方法体。
     */
    Object.getOwnPropertyNames(hostApp).forEach(function (property) {
        var original = hostApp[property];

        if (typeof original === 'function'&&property!=="callback") {
            hostApp[property] = function () {
                return original.apply(hostApp,  [property].concat(Array.prototype.slice.call(arguments, 0)));
            };
        }
    });
    global.HostApp = hostApp;
    console.log("HostApp initialization end");
})(window);

其实初始化时我们拼动态生成的只是上面第15行 hostApp.toast = hostApp.alert = hostApp.getIMSI = function () 这段。目的是将所有JS层调用函数嫁接到一个匿名函数1中,而后利用拦截技术,遍历hostApp下所有的函数,拿出对应的函数名,然后将函数嫁接到另一个匿名函数2,这样做的目的是hostApp下函数调用时首先执行匿名函数2,匿名函数2将对应的函数名作为第一个参数然后再调用匿名函数1,这样匿名函数1中就能区分执行时调用来源。实现了JS层调用入口统一,返回出口统一的结构体系。

二、HostApp JS片段注入时机

步骤一说明了HostApp JS片段的拼接方法,而HostApp JS片段拼接的时机是在Application启动时。

MainApp.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MainApp extends Application {
    @Override
    public void onCreate() {
        if (gMainApp != null) {
            return;
        }
        super.onCreate();
        TaskExecutor.executeTask(new Runnable() {
            @Override
            public void run() {
                // 拼接HostApp JS片段
                JsCallJava.init();
            }
        });
    }
}

从上面的代码,我们知道HostApp JS拼接出来的字符串暂时被放到PRELOAD_INTERFACE_JS静态变量中的。那么我们何时把这段拼接好的字符串注入到Webview的执行空间内呢?答案是页面加载进度变化的过程中。
BaseWebChromeClient.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void onProgressChanged (WebView view, int newProgress) {
    BaseWebView baseWebView = (BaseWebView)view;
    //为什么要在这里注入JS
    //1 OnPageStarted中注入有可能全局注入不成功,导致页面脚本上所有接口任何时候都不可用
    //2 OnPageFinished中注入,虽然最后都会全局注入成功,但是完成时间有可能太晚,当页面在初始化调用接口函数时会等待时间过长
    //3 在进度变化时注入,刚好可以在上面两个问题中得到一个折中处理
    //为什么是进度大于25%才进行注入,因为从测试看来只有进度大于这个数字页面才真正得到框架刷新加载,保证100%注入成功
    if (newProgress > 25 && !baseWebView.isInjectedJS()) {
        baseWebView.loadJS(JsCallJava.PRELOAD_INTERFACE_JS);
        baseWebView.setIsInjectedJS(true);
        Log.d(" inject js interface completely on progress " + newProgress);
    }
    super.onProgressChanged(view, newProgress);
}

从上面我们可以看出,注入的时机是准确把握在进度大于25%时。如果在OnPageFinished注入,页面document.ready的初始回调会等待时间过长,详细的原因我们会在后面讲到。

三、页面调用执行Java方法的过程

OK,上面两步解决了动态生成与成功注入的两大问题,接下来就要处理JS具体的调用过程。上面,我们知道页面调用Java方法时,匿名js函数在拼接好参数后prompt json数据。prompt消息被Java层的WebChromeClient.onJsPrompt拦截到。

BaseWebChromeClient.java
1
2
3
4
5
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
    result.confirm(JsCallJava.call(view, message));
    return true;
}

而JsCallJava.call的具体实现如下。

JsCallJava.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public static String call(WebView webView, String jsonStr) {
    if (!TextUtils.isEmpty(jsonStr)) {
        try {
            JSONObject callJson = new JSONObject(jsonStr);
            String methodName = callJson.getString("method");
            JSONArray argsJson = callJson.getJSONArray("args");
            //带上默认的第一个参数WebView
            int argsLen = argsJson.length() + 1;
            Method currMethod = mMethodMap.get(keyConcat(methodName, argsLen));
            if (currMethod == null) {
                return getReturn(jsonStr, 500, "not found method " + methodName + " with " + argsLen + " parameters");
            }
            Object[] args = new Object[argsLen];
            Class[] types = currMethod.getParameterTypes();
            int defValue = 0;
            for (int k = 0;k < argsLen;k++) {
                Class currType = types[k];
                if (currType == WebView.class) {
                    args[k] = webView;
                    defValue = -1;
                } else if (currType == int.class) {
                    args[k] = argsJson.getInt(k + defValue);
                } else if (currType == long.class) {
                    //WARN: argsJson.getLong(k + defValue) will return a bigger incorrect number
                    args[k] = Long.parseLong(argsJson.getString(k + defValue));
                } else if (currType == boolean.class) {
                    args[k] = argsJson.getBoolean(k + defValue);
                } else if (currType == Double.class) {
                    args[k] = argsJson.getDouble(k + defValue);
                } else if (currType == JSONObject.class) {
                    args[k] = argsJson.getJSONObject(k + defValue);
                } else if (currType == JsCallback.class) {
                    args[k] = new JsCallback(webView, argsJson.getInt(k + defValue));
                } else {    //其他类型统一转换为字符串
                    args[k] = argsJson.getString(k + defValue);
                }
            }
            return getReturn(jsonStr, 200, currMethod.invoke(null, args));
        } catch (Exception e) {
            //优先返回详细的错误信息
            if (e.getCause() != null) {
                return getReturn(jsonStr, 500, "method execute error:" + e.getCause().getMessage());
            }
            return getReturn(jsonStr, 500, "method execute error:" + e.getMessage());
        }
    } else {
        return getReturn(jsonStr, 500, "call data empty");
    }
}

private static String keyConcat (String name, int len) {
    return name + "_" + len;
}

private static String getReturn (String reqJson, int stateCode, Object result) {
    String insertRes = String.valueOf(result);
    if (result instanceof String) {
        insertRes = "\"" + insertRes + "\"";
    }
    String resStr = String.format(RETURN_RESULT_FORMAT, stateCode, insertRes);
    Log.d("HostApp call json: " + reqJson + " result:" + resStr);
    return resStr;
}

这是一个完整的解析匹配过程,会将js层传入的参数个数、参数次序及类型与HostJsScope类的方法匹配,匹配成功则反射执行java方法,并返回函数执行的结果。这里有几点需要注意:

  • 方法反射执行时会将当前调用来源WebView的实例放到第一个参数,方便在HostJsScope静态方法拿到一些Context相关上下文;
  • JS层可传入的参数类型是多样的,可以是number、string、boolean、json、function,其中number数字为长整型时(如时间戳),必须先转为string类型,以避免数字传递误差;
  • Java方法的返回值可以是void 或 能被String.valueOf转换的类型(如int、long、String、double、float等);
  • 如果执行失败或找不到调用方法时,Java层会将异常信息传递到JS层, JS匿名函数中会throw抛出错误;

四、HostApp在页面的使用

有了上面的准备工作,现在我们在页面中就可以很方便地使用HostApp了,而不需要加载任何依赖文件。如li标签的点击:

test.html
1
2
3
4
5
6
<ul class="entry">
    <li onclick="HostApp.alert('HostApp.alert');">HostApp.alert</li>
    <li onclick="HostApp.toast('HostApp.toast');">HostApp.toast</li>
    <li onclick="HostApp.testLossTime(new Date().getTime() + '');">HostApp.testLossTime</li> <!-- 时间戳长整型调用前先转换为string -->
    <li onclick="HostApp.toast(HostApp.getIMSI());">HostApp.getIMSI</li>
</ul>

但同时有一种业务情景时,页面初始加载完备时就应立即触发的调用,如果我们这样写:

test.html
1
2
3
document.addEventListener('DOMContentLoaded', function() {
    HostApp.toast('document ready now');;
}, false);

那么HostApp的调用极有可能不成功,因为端注入HostApp JS片段的时机可能在document.ready前也可能在其后。那么如何解决这个矛盾的问题呢?

如果document.ready的时候HostApp JS已经注入成功,这种情况OK没有问题。当document.ready的时候HostApp JS还未开始注入,这种情景下我们的js脚本层就需要做出变动,即轮询状态,直到端注入成功或者超时(1.5s),再发生回调。具体实现如下(下面的是以zepto.js的$.ready()函数改造为例)。

zepto.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//针对DOM的一些操作
// Define methods that will be available on all
// Zepto collections
$.fn = {
    //DOM Ready
    ready: function(callback, jumpHostAppInject) {
        var originCb = callback;
        var mcounter = 0;
        //尝试等待(1500ms超时)让端注入HostApp Js
        callback = function () {
            if(!window.HostApp && mcounter++ < 150)setTimeout(callback, 10);else originCb($);
        };
        //是否跳过等待HostApp的注入
        if (jumpHostAppInject) {
            callback = originCb;
        }
        if (readyRE.test(document.readyState)) callback($); else document.addEventListener('DOMContentLoaded', function() {
                callback($)
            }, false);
        return this
    },
    ...
    ...
};

这样的机制也就解释了为什么不把Java层的JS注入放在OnPageFinish了,如果那样页面轮询的次数就会上升等待的时间就会变长,而且有可能会超时。好了,有了上面的改动,页面初始加载完备时需要立即触发HostApp的调用,如下:

test.html
1
2
3
4
5
6
7
8
9
10
<script type="text/javascript">
    $(function () {
        HostApp.alert("document ready now");
        if (HostApp.existVerticalScrollbar()) {
            HostApp.setOnScrollBottomListener (40, function (contentHeight) {
                HostApp.toast("reach content(height " + contentHeight + ") bottom");
            });
        }
    });
</script>


3,
在android的开发过程中,有很多时候需要用到本地java代码和javascript进行交互。android对交互进行了很好的封装, 在开发中我们可以很简单的用java代码调用webview中的js,也可以用webview中的js来调用本地的java代码, 这样我们可以实现很多原来做不了的功能,比如点击网页上的电话号码后,手机自动拨打电话,点击网页中的笑话,自动发送短信等.

废话不多说,这次教程的目标如下
  1. android 中的java代码调用webview里面的js脚本
  2. webview中的js脚本调用本地的java代码
  3. java调用js并传递参数
  4. js调用java并传递参数
功能一

android中调用webview中的js脚本非常方便,只需要调用webview的loadUrl方法即可( 注意开启js支持

[html]  view plain copy
  1. // 启用javascript  
  2. contentWebView.getSettings().setJavaScriptEnabled(true);  
  3. // 从assets目录下面的加载html  
  4. contentWebView.loadUrl("file:///android_asset/wst.html");  
  5.   
  6.               // 无参数调用  
  7.        contentWebView.loadUrl("javascript:javacalljs()");  

功能二
webview中js调用本地java方法,这个功能实现起来稍微有点麻烦,不过也不怎么复杂,首先要对webview绑定javascriptInterface,js脚本通过这个接口来调用java代码。
[java]  view plain copy
  1. contentWebView.addJavascriptInterface(this"wst");  
javainterface实际就是一个普通的java类,里面是我们本地实现的java代码, 将object 传递给webview,并指定别名,这样js脚本就可以通过我们给的这个别名来调用我们的方法,在上面的代码中,this是实例化的对象,wst是这个对象在js中的别名

功能三
java代码调用js并传递参数
只需要在待用js函数的时候加入参数即可,下面是传递一个参数的情况,需要多个参数的时候自己拼接及行了,注意str类型在传递的时候参数要用单引号括起来

[java]  view plain copy
  1. mWebView.loadUrl("javascript:test('" + aa+ "')"); //aa是js的函数test()的参数  
功能四
js调用java函数并传参,java函数正常书写,在js脚本中调用的时候稍加注意
然后在html页面中,利用如下代码,即可实现调用
[html]  view plain copy
  1. <div id='b'><a onclick="window.wst.clickOnAndroid(2)">b.c</a></div>  
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值