22

Google's security guidelines for Android app developers has the following:

WebViews do not use addJavaScriptInterface() with untrusted content.

On Android M and above, HTML message channels can be used instead.

Near as I can tell, "HTML message channels" refers to things like createWebMessageChannel(), WebMessagePort, WebMessage, and kin.

However, they do not provide any examples. All they do is link to a WhatWG specification, which is rather unclear. And, based on a Google search for createWebMessageChannel, it appears that this has not been used much yet — my blog post describing changes in the Android 6.0 SDK makes the top 10 search results, and I just mention it in passing.

addJavascriptInterface() is used to allow JavaScript in a WebView to call into Java code supplied by the app using the WebView. How would we use "HTML message channels" as a replacement for that?

Community
  • 1
  • 1
CommonsWare
  • 986,068
  • 189
  • 2,389
  • 2,491
  • What is "untrusted content"? Content served over http in contrast to https? – Nilzor Jan 24 '17 at 20:12
  • 1
    @Nilzor: I assume that "untrusted content" means "stuff you didn't write yourself". IOW, a Web browser, RSS feed reader, and similar sorts of apps should not use `addJavascriptInterface()`. – CommonsWare Jan 24 '17 at 20:16

4 Answers4

19

OK, I have this working, though it kinda sucks.

Step #1: Populate your WebView using loadDataWithBaseURL(). loadUrl() will not work, because bugs. You need to use an http or https URL for the first parameter to loadDataWithBaseURL() (or, at least, not file, because bugs). And you will need that URL later, so hold onto it (e.g., private static final String value).

Step #2: Decide when you want to initialize the communications from the JavaScript into Java. With addJavascriptInterface(), this is available immediately. However, using WebMessagePort is not nearly so nice. In particular, you cannot attempt to initialize the communications until the page is loaded (e.g., onPageFinished() on a WebViewClient).

Step #3: At the time that you want to initialize those communications, call createWebMessageChannel() on the WebView, to create a WebMessagePort[]. The 0th element in that array is your end of the communications pipe, and you can call setWebMessageCallback() on it to be able to respond to messages sent to you from JavaScript.

Step #4: Hand the 1st element in that WebMessagePort[] to the JavaScript by wrapping it in a WebMessage and calling postWebMessage() on the WebView. postWebMessage() takes a Uri as the second parameter, and this Uri must be derived from the same URL that you used in Step #1 as the base URL for loadDataWithBaseURL().

  @TargetApi(Build.VERSION_CODES.M)
  private void initPort() {
    final WebMessagePort[] channel=wv.createWebMessageChannel();

    port=channel[0];
    port.setWebMessageCallback(new WebMessagePort.WebMessageCallback() {
      @Override
      public void onMessage(WebMessagePort port, WebMessage message) {
        postLux();
      }
    });

    wv.postWebMessage(new WebMessage("", new WebMessagePort[]{channel[1]}),
          Uri.parse(THIS_IS_STUPID));
  }

(where wv is the WebView and THIS_IS_STUPID is the URL used with loadDataWithBaseURL())

Step #5: Your JavaScript can assign a function to the global onmessage event, which will be called when postWebMessage() is called. The 0th element of the ports array that you get on the event will be the JavaScript end of the communications pipe, and you can stuff that in a variable somewhere. If desired, you can assign a function to onmessage for that port, if the Java code will use the WebMessagePort for sending over future data.

Step #6: When you want to send a message from JavaScript to Java, call postMessage() on the port from Step #5, and that message will be delivered to the callback that you registered with setWebMessageCallback() in step #3.

var port;

function pull() {
    port.postMessage("ping");
}

onmessage = function (e) {
    port = e.ports[0];

    port.onmessage = function (f) {
        parse(f.data);
    }
}

This sample app demonstrates the technique. It has a WebView that shows the current light level based on the ambient light sensor. That sensor data is fed into the WebView either on a push basis (as the sensor changes) or on a pull basis (user taps the "Light Level" label on the Web page). This app uses WebMessagePort for these on Android 6.0+ devices, though the push option is commented out so you can confirm that the pull approach is working through the port. I will have more detailed coverage of the sample app in an upcoming edition of my book.

CommonsWare
  • 986,068
  • 189
  • 2,389
  • 2,491
  • Will there be a support version of WebMessageChannel for Android version older than 6.0? – Gohan Jan 24 '17 at 02:21
  • 2
    @Gohan: I doubt it, though it's not out of the question for Android 4.4+. My guess is that the Android System WebView app has the Web side of this. I suspect that there is no good way to create a support library that somehow makes the Java side work. Older than 4.4 is very unlikely to happen, as I doubt that the `WebView` baked into those versions has any support for this. But, the ways of Google are mysterious, and so, `¯\_(ツ)_/¯`. – CommonsWare Jan 24 '17 at 12:35
  • Thank you for doing all the related research and sharing it! Have you compared performance of using this instead of a `@JavascriptInterface` for binary data? – Benjamin Gruenbaum Jul 30 '17 at 12:48
  • @BenjaminGruenbaum: No, sorry. Particularly on Android 8.0, you should assume that inter-process communication goes on under the covers for both APIs, though, and so I would aim to have a coarse-grained API, one that does not need to be invoked with great frequency. – CommonsWare Jul 30 '17 at 13:03
  • @CommonsWare thanks, I'll try to do that research myself then - I'd like to pass large (1mb-8mb) buffers from JavaScript to Java without copying. I know this API well from its JavaScript side - but webviews really baffle me and [seem very opaque generally](https://stackoverflow.com/questions/45382757/detect-available-memory-inside-of-a-webview). Thanks a lot for this answer anyway - it has saved me a considerable amount of time. – Benjamin Gruenbaum Jul 30 '17 at 13:33
  • @BenjaminGruenbaum: "I'd like to pass large (1mb-8mb) buffers from JavaScript to Java without copying" -- it's pretty much guaranteed to be doing some amount of copying, even prior to Android 8.0's out-of-process rendering implementation. The question is not "will there be copies?" but rather "how many copies?" and "how many copies that affect the heap limit?". "webviews really baffle me and seem very opaque generally" -- use the alpha channel. ::rimshot:: :-) – CommonsWare Jul 30 '17 at 13:39
  • @CommonsWare haha. Well, I'm better with the web side than the Android side - but one of the purposes of Web channels ([as specced here](https://html.spec.whatwg.org/multipage/comms.html#messagechannel)) is to facilitate transferring memory from one place to another (transferable:true is built to avoid copies). Now - I know how that would work with an iFrame (since two `v8::Isolate`s can share a transferable array buffer) - but in this case since it's another process - I have no idea how 0 copies might be achieved. The only response I got from Google dev advocates I talked to about it was ":(" – Benjamin Gruenbaum Jul 30 '17 at 14:16
  • So, fun story @CommonsWare - this takes over a second to pass 1mb of binary data - I guess I'm going to try data channels after all https://gist.github.com/benjamingr/c61648e99c58347e84f8e0d95d0f36a3 – Benjamin Gruenbaum Aug 02 '17 at 17:59
  • @BenjaminGruenbaum: Well, that could be including a bunch of `WebView` setup overhead, if you are literally basing your analysis on that code snippet. Remember that a lot of what we do with widgets does not happen immediately, but rather gets queued until we return control of the main application thread back to the framework (in your case, returning fro `onCreate()`). – CommonsWare Aug 02 '17 at 18:11
  • @CommonsWare I'm not basing my analysis on that single snippet but it's certainly enough to prove a point and it turned out to be a real bottleneck. The overhead of setup is under 10% of the measured result here (I did measure it separately). If you pass 10mb (which takes between 8.4 to 10 seconds on my emulator (median 9.0)) - you'll see it's even more drastic. I have no idea why this is happening and what's going on. – Benjamin Gruenbaum Aug 02 '17 at 18:23
  • @BenjaminGruenbaum: I have never tried passing huge stuff between a `WebView` and its hosting activity, on any Android version, so I don't have a basis for comparison. If you haven't done so already, repeat your tests with just the `new Uint8Array(1024 * 1024)` and no `proxy.buffer(b)`. If that's fast(ish), then it's definitely in the cross-environment passing, and perhaps they're just doing something silly in copying the bytes around (e.g., loop in Java). However, if it's still slow, that points to the `Uint8Array` allocation and is more purely a `WebView` thing. – CommonsWare Aug 02 '17 at 18:38
  • Everything but actually passing the data is fast. I'm a little lost currently followed https://github.com/android/platform_frameworks_base/blob/android-4.0.1_r1/core/java/android/webkit/WebViewCore.java#L1405 https://android.googlesource.com/platform/frameworks/base/+/56a2301/core/java/android/webkit/BrowserFrame.java#1263 from https://android.googlesource.com/platform/external/webkit/+/f10585d69aaccf4c1b021df143ee0f08e338cf31/WebKit/android/jni/WebCoreFrameBridge.cpp#1122 from https://chromium.googlesource.com/chromium/blink/+/master/Source/bindings/core/v8/ScriptController.cpp#247 – Benjamin Gruenbaum Aug 02 '17 at 19:09
  • This looks like the actual meat https://github.com/adobe/webkit/blob/master/Source/WebCore/bindings/v8/V8NPObject.cpp#L68 – Benjamin Gruenbaum Aug 02 '17 at 19:10
  • @CommonsWare we figured it out - if you encode the string with 8859 instead of passing it as byte[] and then decode as 8859 you can pass 10mb with reasonable (70ms) latency. – Benjamin Gruenbaum Aug 03 '17 at 11:30
  • 1
    @BenjaminGruenbaum" OK, so it sounds like they screwed up the `byte[]`-passing code somewhere. Good to know -- thanks! – CommonsWare Aug 03 '17 at 11:31
  • @CommonsWare I reported this to chromium on IRC and will file a proper bug report. Also MessageChannel is _really_ slow and imposes a 2 second latency (in addition to passing byte[] slowly) so it was not a viable solution too. It looks like encoding the string in 1 byte encoding that doesn't actually modify it and then reading it back in Java is the only way to do fast IPC between a chromium child process to Java. Anyway - thanks for the support. – Benjamin Gruenbaum Aug 03 '17 at 11:33
  • @CommonsWare I figured it out https://stackoverflow.com/a/45506857/1348195 – Benjamin Gruenbaum Aug 04 '17 at 12:33
  • "loadUrl() will not work, because bugs." - it seems loadUrl does work actually with http/https URLs. If you are using a file: URL, that is probably trusted content anyway and you would be using addJavascriptInterface. – Adam Burley Sep 29 '21 at 14:56
  • Shouldn't it be `parse(f.data);` not `parse(e.data);`? As the way you have set it up, `e.data` will just be an empty string? – Adam Burley Sep 30 '21 at 11:45
  • 1
    @AdamBurley: Correct! I had it right in [the sample project's JavaScript](https://github.com/commonsguy/cw-omnibus/blob/master/WebKit/SensorPort/app/src/main/assets/index.html). I have fixed the answer to match it. Thanks! – CommonsWare Sep 30 '21 at 11:55
7

There's a test for it in CTS

// Create a message channel and make sure it can be used for data transfer to/from js.
public void testMessageChannel() throws Throwable {
    if (!NullWebViewUtils.isWebViewAvailable()) {
        return;
    }
    loadPage(CHANNEL_MESSAGE);
    final WebMessagePort[] channel = mOnUiThread.createWebMessageChannel();
    WebMessage message = new WebMessage(WEBVIEW_MESSAGE, new WebMessagePort[]{channel[1]});
    mOnUiThread.postWebMessage(message, Uri.parse(BASE_URI));
    final int messageCount = 3;
    final CountDownLatch latch = new CountDownLatch(messageCount);
    runTestOnUiThread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < messageCount; i++) {
                channel[0].postMessage(new WebMessage(WEBVIEW_MESSAGE + i));
            }
            channel[0].setWebMessageCallback(new WebMessagePort.WebMessageCallback() {
                @Override
                public void onMessage(WebMessagePort port, WebMessage message) {
                    int i = messageCount - (int)latch.getCount();
                    assertEquals(WEBVIEW_MESSAGE + i + i, message.getData());
                    latch.countDown();
                }
            });
        }
    });
    // Wait for all the responses to arrive.
    boolean ignore = latch.await(TIMEOUT, java.util.concurrent.TimeUnit.MILLISECONDS);
}

file: cts/tests/tests/webkit/src/android/webkit/cts/PostMessageTest.java. At least some starting point.

CommonsWare
  • 986,068
  • 189
  • 2,389
  • 2,491
Diego Torres Milano
  • 65,697
  • 9
  • 111
  • 134
6

Here's a solution using the compat lib: Download Full Solution in Android Studio format

This example uses an index.html and an index.js file stored in the assets folder.

Here is the JS:

const channel = new MessageChannel();
var nativeJsPortOne = channel.port1;
var nativeJsPortTwo = channel.port2;
window.addEventListener('message', function(event) {
    if (event.data != 'capturePort') {
        nativeJsPortOne.postMessage(event.data)
    } else if (event.data == 'capturePort') {
        /* The following three lines form Android class 'WebViewCallBackDemo' capture the port and assign it to nativeJsPortTwo
        var destPort = arrayOf(nativeToJsPorts[1])
        nativeToJsPorts[0].setWebMessageCallback(nativeToJs!!)
        WebViewCompat.postWebMessage(webView, WebMessageCompat("capturePort", destPort), Uri.EMPTY) */
        if (event.ports[0] != null) {
            nativeJsPortTwo = event.ports[0]
        }
    }
}, false);

nativeJsPortOne.addEventListener('message', function(event) {
    alert(event.data);
}, false);

nativeJsPortTwo.addEventListener('message', function(event) {
    alert(event.data);
}, false);
nativeJsPortOne.start();
nativeJsPortTwo.start();

And here is the HTML:

<!DOCTYPE html>
<html lang="en-gb">
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebView Callback Demo</title>
    <script src="js/index.js"></script>
</head>
<body>
    <div style="font-size: 24pt; text-align: center;">
        <input type="button" value="Test" onclick="nativeJsPortTwo.postMessage(msgFromJS.value);" style="font-size: inherit;" /><br />
        <input id="msgFromJS" type="text" value="JavaScript To Native" style="font-size: 16pt; text-align: inherit; width: 80%;" />
    </div>
</body>
</html>

And finally Here is the native Android code:

class PostMessageHandler(webView: WebView) {
    private val nativeToJsPorts = WebViewCompat.createWebMessageChannel(webView)
    private var nativeToJs: WebMessagePortCompat.WebMessageCallbackCompat? = null
    init {
        if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_CALLBACK_ON_MESSAGE)) {
            nativeToJs = object : WebMessagePortCompat.WebMessageCallbackCompat() {
                override fun onMessage(port: WebMessagePortCompat, message: WebMessageCompat?) {
                    super.onMessage(port, message)
                    Toast.makeText(webView.context, message!!.data, Toast.LENGTH_SHORT).show()
                }
            }
        }
        var destPort = arrayOf(nativeToJsPorts[1])
        nativeToJsPorts[0].setWebMessageCallback(nativeToJs!!)
        WebViewCompat.postWebMessage(webView, WebMessageCompat("capturePort", destPort), Uri.EMPTY)
    }
}

It is important the the native code be executed from the 'WebViewClient.onPageFinished(webView: WebView, url: String)' callback. See download link above for full details. This project shows postMessage working both ways (Native to JS and JS to Native) Hope this helps.

user2288580
  • 2,210
  • 23
  • 16
  • Thank you so much - I implemented your solution in Kotlin/JS here: https://github.com/darran-kelinske-fivestars/cordova-alternative-pattern/blob/master/BluetoothSerialJs/src/main/kotlin/BluetoothSerial.kt#L202 – dazza5000 Mar 07 '20 at 20:53
2

@CommonsWare I have tried your solution and it worked for me. Just one little addition. You can use loadUrl() too, by setting Uri argument to Uri.EMPTY. Working on Nexus 7 (MOB30J).

    getWebView().postWebMessage(new WebMessage("MESSAGE", new WebMessagePort[]{
            channel[1]
    }), Uri.EMPTY);
user2319066
  • 195
  • 18
  • 1
    That's almost scarier. I thought that the point behind that `Uri` parameter is to ensure that I am posting the message to the expected page. Anyway, good to know -- thanks! – CommonsWare Jan 25 '17 at 16:49
  • Is it really true that the page has to be fully loaded before the WebMessagingChannel can be opened? That's not mentioned in the postMessage API. – Tom Jan 02 '19 at 11:43
  • @Tom Sorry for the late answer. I can't remember exactly, but it sounds plausible. – user2319066 Jan 07 '19 at 15:35
  • @user2319066 yes I found a couple of confirmations. WebMessagingChannel effectively useless for my purposes then :( – Tom Jan 07 '19 at 16:44