For a while I thought this was just bad implementation on my part. How could I get it so wrong? I mean, I'm literally just copy-pasting the example from the documentation and it's STILL not working!
Normally, you'd just call this and expect it to work:
m_webview.getSettings().setJavaScriptEnabled(true);
m_webview.addJavascriptInterface(new JSInterface(), "Android");
And normally you'd be right, except on Gingerbread. When you trigger some JS calls to Android 2.3, you'll get this:
JNI WARNING: jarray 0xb5d256f0 points to non-array object (Ljava/lang/String;)
"WebViewCoreThread" prio=5 tid=8 NATIVE
| group="main" sCount=0 dsCount=0 obj=0xb5cfc348 self=0x8234e98
| sysTid=2023 nice=0 sched=0/0 cgrp=[fopen-error:2] handle=136531904
at android.webkit.WebViewCore.nativeTouchUp(Native Method)
at android.webkit.WebViewCore.nativeTouchUp(Native Method)
at android.webkit.WebViewCore.access$3300(WebViewCore.java:53)
at android.webkit.WebViewCore$EventHub$1.handleMessage(WebViewCore.java:1162)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:130)
at android.webkit.WebViewCore$WebCoreThread.run(WebViewCore.java:633)
at java.lang.Thread.run(Thread.java:1019)
VM aborting
I ain't done nothing wrong here! Same code works perfectly on other versions of Android.
Well, it turns out that this bug has existed since December 2010 "Issue 12987 - android - Javascript to Java Bridge Throws Exception", and even after 3 years developers still have to jump through hoops to get JavaScript bridging working on 2.3 devices.
Gingerbread still holds up to 30% of all device market share on the Play Store at time of writing.
The solution
I'm not gonna lie, this is only a half-baked solution but AWESOME and it's the best I can find so far. It's a method first discovered (or published) by Jason Shah of PhoneGap, then tweaked by Mr S (StackOverflow) to detect multiple versions of Gingerbread.
Unfortunately, there are a few flaws with his implementation which I've taken upon to fix (red ones are still unresolved)
Not synchronous, so we can't return values between JS/Java (without callbacks) Fixed 17/9/13 - Unable to access interface from iframes
Required each methods in JSInterface to tokenize a single String argument to accommodate for lack of bridging support Required you to manually map out all methods in the interface Issues with commas/double quotes in strings breaking JS String separator is cumbersome in case data had break string in it It wasn't clear on how to change the interface name
I then took this code and modified it so most of the issues have been ironed out (to a degree).
I wasn't too keen on subclassing the WebView, so here's the helper function. (Don't worry, all the code will be made available for easy copy-pasting in GitHub along with more detailed comments).
private WebView m_webview = null;
private boolean javascriptInterfaceBroken = false;
/**
* @see http://twigstechtips.blogspot.com.au/2013/09/android-webviewaddjavascriptinterface.html
*/
protected void fixWebViewJSInterface(WebView webview, Object jsInterface, String jsInterfaceName, String jsSignature) {
// Gingerbread specific code
if (Build.VERSION.RELEASE.startsWith("2.3")) {
javascriptInterfaceBroken = true;
}
// Everything else is fine
else {
webview.addJavascriptInterface(jsInterface, jsInterfaceName);
}
webview.setWebViewClient(new GingerbreadWebViewClient(jsInterface, jsInterfaceName, jsSignature));
webview.setWebChromeClient(new GingerbreadWebViewChrome(jsInterface, jsSignature));
}
The function fixWebViewJSInterface() simply initiates the WebView with the right implementation depending on the Android build version it's running on.
So replace your addJavascriptInterface() call:
m_webview.addJavascriptInterface(new JSInterface(), "Android");
With the fix:
fixWebViewJSInterface(webview, new JSInterface(), "Android", "_gbjsfix:");
GingerbreadWebViewClient and GingerbreadWebViewChrome contains pretty much all the workaround logic, so prepare yourself for a lengthy snippet!
How it works
Normally, JavaScript in the WebView is able to call Android functions via the interface name "Android" or whatever name we gave it. For example:
Android.showToast("Hello! Is it me you're looking for?");
At the fixWebViewJSInterface() level, the fix is relatively simple. Anything other than Gingerbread 2.3 will use the normal JavaScript interface implementation addJavascriptInterface().
However anyone on Android 2.3 we'll have to treat a little differently, but be glad to know that the JS code itself is (mostly) the same. We DON'T actually set the interface, but instead mark the interface as broken and let GingerbreadWebViewClient/GingerbreadWebViewChrome handle the rest as it'll:
- Wait until the page has finished loading
- Generate JS code based off your JSInterface class methods
- Inject our own "Android" interface object into the page
- Call android_init() at the end of it all (even on non-Gingerbread Android)
GingerbreadWebViewClient is responsible for the JS code injection and re-injection into the broken WebView.
Regarding the JS code generated:
- Declares an "Android" object so we don't have to change the JS code we write in the WebView
- Replicate the method names into the "Android" interface object
- All the methods will wrap Android._gbFix()
- _gbFix() is a function which seals the function arguments (and some meta data) into JSON
- The JS signature is appended to the generated JSON and passed to GingerbreadWebViewChrome via prompt()
- prompt() will wait for a response, thus making it a synchronous call
Calls by prompt() from the WebView's JS to the Android interface will trigger these events within GingerbreadWebViewChrome:
- onJsPrompt() picks up the prompt() call and checks the message for the JS signature
- Decode the meta data from the JSON bubble-wrap
- Attempt to map the method back to the JSInterface class we've defined and invoke it
Well, I promised you a lengthy snippet. Here it is!
private class GingerbreadWebViewClient extends WebViewClient {
private Object jsInterface;
private String jsInterfaceName;
private String jsSignature;
public GingerbreadWebViewClient(Object jsInterface, String jsInterfaceName, String jsSignature) {
this.jsInterface = jsInterface;
this.jsInterfaceName = jsInterfaceName;
this.jsSignature = jsSignature;
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (javascriptInterfaceBroken) {
StringBuilder gbjs = new StringBuilder();
gbjs.append("javascript: ");
gbjs.append(generateJS());
view.loadUrl(gbjs.toString());
}
// Initialise the page
view.loadUrl("javascript: android_init();");
}
/**
* What this JS wrapper function does is convert all the arguments to strings,
* in JSON format before sending it to Android in the form of a prompt() alert.
*
* JSON data is returned by Android and unwrapped as the result.
*/
public String generateJS() {
StringBuilder gbjs = new StringBuilder();
if (javascriptInterfaceBroken) {
StringBuilder sb;
gbjs.append("var "); gbjs.append(jsInterfaceName); gbjs.append(" = { " +
" _gbFix: function(fxname, xargs) {" +
" var args = new Array();" +
" for (var i = 0; i < xargs.length; i++) {" +
" args.push(xargs[i].toString());" +
" };" +
" var data = { name: fxname, len: args.length, args: args };" +
" var json = JSON.stringify(data);" +
" var res = prompt('"); gbjs.append(jsSignature); gbjs.append("' + json);" +
" return JSON.parse(res)['result'];" +
" }" +
"};");
// Build methods for each method in the JSInterface class.
for (Method m : jsInterface.getClass().getMethods()) {
sb = new StringBuilder();
// Output = "Android.showToast = function() { return this._gbFix('showToast', arguments); };"
sb.append(jsInterfaceName);
sb.append(".");
sb.append(m.getName());
sb.append(" = function() { return this._gbFix('");
sb.append(m.getName());
sb.append("', arguments); };");
gbjs.append(sb);
}
}
return gbjs.toString();
}
}
private class GingerbreadWebViewChrome extends WebChromeClient {
private Object jsInterface;
private String jsSignature;
public GingerbreadWebViewChrome(Object jsInterface, String jsSignature) {
this.jsInterface = jsInterface;
this.jsSignature = jsSignature;
}
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
if (!javascriptInterfaceBroken || TextUtils.isEmpty(message) || !message.startsWith(jsSignature)) {
return false;
}
// We've hit some code through _gbFix()
JSONObject jsonData;
String functionName;
String encodedData;
try {
encodedData = message.substring(jsSignature.length());
jsonData = new JSONObject(encodedData);
encodedData = null; // no longer needed, clear memory
functionName = jsonData.getString("name");
for (Method m : jsInterface.getClass().getMethods()) {
if (m.getName().equals(functionName)) {
JSONArray jsonArgs = jsonData.getJSONArray("args");
Object[] args = new Object[jsonArgs.length()];
for (int i = 0; i < jsonArgs.length(); i++) {
args[i] = jsonArgs.get(i);
}
Object ret = m.invoke(jsInterface, args);
JSONObject res = new JSONObject();
res.put("result", ret);
result.confirm(res.toString());
return true;
}
}
// No matching method name found, should throw an exception.
throw new RuntimeException("shouldOverrideUrlLoading: Could not find method '" + functionName + "()'.");
}
catch (IllegalArgumentException e) {
Log.e("GingerbreadWebViewClient", "shouldOverrideUrlLoading: Please ensure your JSInterface methods only have String as parameters.");
throw new RuntimeException(e);
}
catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
catch (InvocationTargetException e) {
throw new RuntimeException(e);
}
catch (JSONException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
}
If you're using a custom WebViewClient and/or WebChromeClient, simply change the subclasses for your one or merge the code somehow. I'll assume you're capable enough as no support will be given here.
Maybe this guy's a little TOO capable...
Things you should be aware of
android_init()
Due to the delay between page load and JS injection, you have to assume that the JS interface is NOT available to you until android_init() has been called. Because of this, you must always declare android_init(), even if it's not used.
If you have any code in $(document).ready() or onload() which uses the Android interface, you may want to move it into android_init().
I think this is a fairly small trade-off for the convenience this method provides.
JSInterface method parameters
The JavaScript calls can be a mixture of numbers and strings, but all parameters in JSInterface Android class must be of type String.
This could probably be fixed later, but I was short on time and chose to convert all the arguments into strings when invoking the method.
Limitations
Not synchronous, so we can't return values between JS/Java (without callbacks) Fixed 17/9/13 - Unable to access interface from iframes
Required each methods in JSInterface to tokenize a single String argument to accommodate for lack of bridging support See android_init() limitation and JSInterface method parameters Required you to manually map out all methods in the interface Now automatic Issues with commas/double quotes in strings breaking JS Fixed with JSON String separator is cumbersome in case data had break string in it Fixed with JSON It wasn't clear on how to change the interface name Now an argument to fixWebViewJSInterface()
Here's the link to the code on GitHub. It includes a few more comments to clarify some points here and there.
That's all folks! I feel like I've jumped through enough hoops for now.
Update
17/9/2013: Rewrote the tutorial so the calls are synchronous! No more messy callbacks, yay!
Sources
Big thanks to Jason Shah for sharing his findings and making this work-around possible.