Advanced Uses of WebView

Android uses the WebKit browser engine as the foundation for both its Browser application and the WebView embeddable browsing widget. The Browser application, of course, is something Android users can interact with directly; the WebView widget is something you can integrate into your own applications for places where an HTML interface might be useful.

Earlier in this book, we saw a simple integration of a WebView into an Android activity, with the activity dictating what the browsing widget displayed and how it responded to links.

Here, we will expand on this theme, and show how to more tightly integrate the Java environment of an Android application with the JavaScript environment of WebKit.

Prerequisites

Understanding this chapter requires that you have read the core chapters, particularly the one covering WebView. Some of the samples use LocationManager for obtaining a GPS fix.

Friends with Benefits

When you integrate a WebView into your activity, you can control what Web pages are displayed, whether they are from a local provider or come from over the Internet, what should happen when a link is clicked, and so forth. And between WebView, WebViewClient, and WebSettings, you can control a fair bit about how the embedded browser behaves. Yet, by default, the browser itself is just a browser, capable of showing Web pages and interacting with Web sites, but otherwise gaining nothing from being hosted by an Android application.

Except for one thing: addJavascriptInterface().

The addJavascriptInterface() method on WebView allows you to inject a Java object into the WebView, exposing its methods, so they can be called by JavaScript loaded by the Web content in the WebView itself.

Now you have the power to provide access to a wide range of Android features and capabilities to your WebView-hosted content. If you can access it from your activity, and if you can wrap it in something convenient for use by JavaScript, your Web pages can access it as well.

For example, HTML5 offers geolocation, whereby the Web page can find out where the device resides, by browser-supplied means. We can do much of the same thing ourselves via addJavascriptInterface().

In the WebKit/GeoWeb1 project, you will find a fairly simple layout (main.xml):

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:orientation="vertical"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  >
  <WebView android:id="@+id/webkit"
    android:layout_width="match_parent" 
    android:layout_height="match_parent" 
  />
</LinearLayout>
(from WebKit/GeoWeb1/app/src/main/res/layout/main.xml)

All this does is host a full-screen WebView widget.

Next, take a look at the GeoWebOne activity class:

package com.commonsware.android.geoweb;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import org.json.JSONException;
import org.json.JSONObject;

public class GeoWebOne extends Activity {
  private static String PROVIDER=LocationManager.GPS_PROVIDER;
  private WebView browser;
  private LocationManager myLocationManager=null;
  
  @SuppressLint("SetJavaScriptEnabled")
  @Override
  public void onCreate(Bundle icicle) {
    super.onCreate(icicle);
    
    setContentView(R.layout.main);
    browser=(WebView)findViewById(R.id.webkit);
    
    myLocationManager=(LocationManager)getSystemService(Context.LOCATION_SERVICE);
    
    browser.getSettings().setJavaScriptEnabled(true);
    browser.addJavascriptInterface(new Locater(), "locater");
    browser.loadUrl("file:///android_asset/geoweb1.html");
  }
  
  @Override
  public void onResume() {
    super.onResume();
    myLocationManager.requestLocationUpdates(PROVIDER, 10000,
                                              100.0f,
                                              onLocation);
  }
  
  @Override
  public void onPause() {
    super.onPause();
    myLocationManager.removeUpdates(onLocation);
  }
  
  LocationListener onLocation=new LocationListener() {
    public void onLocationChanged(Location location) {
      // ignore...for now
    }
    
    public void onProviderDisabled(String provider) {
      // required for interface, not used
    }
    
    public void onProviderEnabled(String provider) {
      // required for interface, not used
    }
    
    public void onStatusChanged(String provider, int status,
                                  Bundle extras) {
      // required for interface, not used
    }
  };
  
  public class Locater {
    @JavascriptInterface
    public String getLocation() throws JSONException {
      Location loc=myLocationManager.getLastKnownLocation(PROVIDER);
      
      if (loc==null) {
        return(null);
      }
      
      JSONObject json=new JSONObject();

      json.put("lat", loc.getLatitude());
      json.put("lon", loc.getLongitude());
      
      return(json.toString());
    }
  }
}
(from WebKit/GeoWeb1/app/src/main/java/com/commonsware/android/geoweb/GeoWebOne.java)

This looks a bit like some of the WebView examples from earlier in this book. However, it adds three key bits of code:

The Locater API uses JSON to return both a latitude and a longitude at the same time. We are limited to using data types that are in common between JavaScript and Java, so we cannot pass back the Location object we get from the LocationManager. Hence, we convert the key Location data into a simple JSON structure that the JavaScript on the Web page can parse.

Note that the getLocation() method on Locater has the @JavascriptInterface annotation. This is required of apps with android:targetSdkVersion set to 17 or higher, though it is a good idea to start using it anyway. With such an android:targetSdkVersion, in an app running on an Android 4.2 or higher device, only public methods with the @JavascriptInterface annotation will be accessible by JavaScript code. On earlier devices, or with an earlier android:targetSdkVersion, all public methods on the Locater object would be accessible by JavaScript, including those inherited from superclasses like Object. Note that your build target (i.e., compileSdkVersion in Android Studio) will need to be Android 4.2 or higher in order to reference the @JavascriptInterface annotation.

Also note that onCreate() has the @SuppressLint("SetJavaScriptEnabled") annotation. This overrides a Lint warning about the use of setJavaScriptEnabled(true), where Lint wants to make sure that you understand the risks of allowing arbitrary JavaScript to execute inside your app. In this case, the JavaScript is code that we wrote, and so we can ensure that it is safe and sane.

Later in this chapter, we will cover security issues with WebView and put those annotations into context.

The Web page itself is referenced in the source code as file:///android_asset/geoweb1.html, so the GeoWeb1 project has a corresponding assets/ directory containing geoweb1.html:

<html>
<head>
<title>Android GeoWebOne Demo</title>
<script language="javascript">
  function whereami() {
    var location=JSON.parse(locater.getLocation());
    
    document.getElementById("lat").innerHTML=location.lat;
    document.getElementById("lon").innerHTML=location.lon;
  }
</script>
</head>
<body>
<p>
You are at: <br/> <span id="lat">(unknown)</span> latitude and <br/>
<span id="lon">(unknown)</span> longitude.
</p>
<p><a onClick="whereami()">Update Location</a></p>
</body>
</html>


(from WebKit/GeoWeb1/app/src/main/assets/geoweb1.html)

When you click the “Update Location” link, the page calls a whereami() JavaScript function, which in turn uses the locater object to update the latitude and longitude, initially shown as “(unknown)” on the page.

If you run the application, initially, the page is pretty boring:

The GeoWebOne sample application, as initially launched
Figure 559: The GeoWebOne sample application, as initially launched

However, if you wait a bit for a GPS fix, and click the “Update Location” link… the page is still pretty boring, but it at least knows where you are:

The GeoWebOne sample application, after clicking the Update Location link
Figure 560: The GeoWebOne sample application, after clicking the Update Location link

Turnabout is Fair Play

Now that we have seen how JavaScript can call into Java, it would be nice if Java could somehow call out to JavaScript. In our example, it would be helpful if we could expose automatic location updates to the Web page, so it could proactively update the position as the user moves, rather than wait for a click on the “Update Location” link.

Well, as luck would have it, we can do that too. This is a good thing, otherwise, this would be a really weak section of the book.

What is unusual is how you call out to JavaScript. One might imagine there would be an evaluateJavaScript() counterpart to addJavascriptInterface(), where you could supply some JavaScript source and have it executed within the context of the currently-loaded Web page.

Actually, there is such a method on Android 4.4. However, earlier versions of Android lacked that method. Instead, on older versions of Android, given your snippet of JavaScript source to execute, you call loadUrl() on your WebView, as if you were going to load a Web page, but you put javascript: in front of your code and use that as the “address” to load.

If you have ever created a “bookmarklet” for a desktop Web browser, you will recognize this technique as being the Android analogue – the javascript: prefix tells the browser to treat the rest of the address as JavaScript source, injected into the currently-viewed Web page.

So, armed with this capability, let us modify the previous example to continuously update our position on the Web page.

The layout for the WebKit/GeoWeb2 sample project is the same as before. The Java source for our activity changes a bit:

package com.commonsware.android.geoweb2;

import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Build;
import android.os.Bundle;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import org.json.JSONException;
import org.json.JSONObject;

public class GeoWebTwo extends Activity {
  private static String PROVIDER="gps";
  private WebView browser;
  private LocationManager myLocationManager=null;

  @SuppressLint("SetJavaScriptEnabled")
  @Override
  public void onCreate(Bundle icicle) {
    super.onCreate(icicle);
    setContentView(R.layout.main);
    browser=(WebView)findViewById(R.id.webkit);

    myLocationManager=
        (LocationManager)getSystemService(Context.LOCATION_SERVICE);

    browser.getSettings().setJavaScriptEnabled(true);
    browser.addJavascriptInterface(new Locater(), "locater");
    browser.loadUrl("file:///android_asset/geoweb2.html");
  }

  @Override
  public void onResume() {
    super.onResume();
    myLocationManager.requestLocationUpdates(PROVIDER, 0, 0, onLocation);
  }

  @Override
  public void onPause() {
    super.onPause();
    myLocationManager.removeUpdates(onLocation);
  }

  LocationListener onLocation=new LocationListener() {
    @TargetApi(Build.VERSION_CODES.KITKAT)
    public void onLocationChanged(Location location) {
      StringBuilder buf=new StringBuilder("whereami(");

      buf.append(String.valueOf(location.getLatitude()));
      buf.append(",");
      buf.append(String.valueOf(location.getLongitude()));
      buf.append(")");

      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        browser.evaluateJavascript(buf.toString(), null);
      }
      else {
        browser.loadUrl("javascript:" + buf.toString());
      }
    }

    public void onProviderDisabled(String provider) {
      // required for interface, not used
    }

    public void onProviderEnabled(String provider) {
      // required for interface, not used
    }

    public void onStatusChanged(String provider, int status,
                                Bundle extras) {
      // required for interface, not used
    }
  };

  public class Locater {
    @JavascriptInterface
    public String getLocation() throws JSONException {
      Location loc=myLocationManager.getLastKnownLocation(PROVIDER);

      if (loc == null) {
        return(null);
      }

      JSONObject json=new JSONObject();

      json.put("lat", loc.getLatitude());
      json.put("lon", loc.getLongitude());

      return(json.toString());
    }
  }
}
(from WebKit/GeoWeb2/app/src/main/java/com/commonsware/android/geoweb2/GeoWebTwo.java)

Before, the onLocationChanged() method of our LocationListener callback did nothing. Now, it builds up a call to a whereami() JavaScript function, providing the latitude and longitude as parameters to that call. So, for example, if our location were 40 degrees latitude and –75 degrees longitude, the call would be whereami(40,-75).

What happens then depends upon the version of Android the device is running.

The result is that a whereami() function in the Web page gets called with the new location.

That Web page, of course, also needed a slight revision, to accommodate the option of having the position be passed in:

<html>
<head>
<title>Android GeoWebTwo Demo</title>
<script language="javascript">
  function whereami(lat, lon) {
    document.getElementById("lat").innerHTML=lat;
    document.getElementById("lon").innerHTML=lon;
  }
  
  function pull() {
    var location=JSON.parse(locater.getLocation());
    
    whereami(location.lat, location.lon);
  }
</script>
</head>
<body>
<p>
You are at: <br/> <span id="lat">(unknown)</span> latitude and <br/>
<span id="lon">(unknown)</span> longitude.
</p>
<p><a onClick="pull()">Update Location</a></p>
</body>
</html>


(from WebKit/GeoWeb2/app/src/main/assets/geoweb2.html)

The basics are the same, and we can even keep our “Update Location” link, albeit with a slightly different onClick attribute.

If you build, install, and run this revised sample on a GPS-equipped Android device, the page will initially display with “(unknown)” for the current position. After a fix is ready, though, the page will automatically update to reflect your actual position. And, as before, you can always click “Update Location” if you wish.

Navigating the Waters

There is no navigation toolbar with the WebView widget. This allows you to use it in places where such a toolbar would be pointless and a waste of screen real estate. That being said, if you want to offer navigational capabilities, you can, but you have to supply the UI. WebView offers ways to perform garden-variety browser navigation, including:

Settings, Preferences, and Options (Oh, My!)

With your favorite desktop Web browser, you have some sort of “settings” or “preferences” or “options” window. Between that and the toolbar controls, you can tweak and twiddle the behavior of your browser, from preferred fonts to the behavior of JavaScript.

Similarly, you can adjust the settings of your WebView widget as you see fit, via the WebSettings instance returned from calling the widget’s getSettings() method.

There are lots of options on WebSettings to play with. Most appear fairly esoteric (e.g., setFantasyFontFamily()). However, here are some that you may find more useful:

Security and Your WebView

More so than normal widgets, WebView opens up potential security issues, just as a Web browser could. If all you are doing is displaying your own content, the risks are minimal. If, on the other hand, you are displaying content from third parties, it is possible that their content is malicious in a way that can compromise your app’s security, to your users’ detriment.

Rogue JavaScript Risks

If you call setJavaScriptEnabled(true) on your WebSettings, you are allowing JavaScript code to be loaded and executed by WebView. In many cases, this is essential to get your content to render properly (e.g., the JavaScript is issuing AJAX calls). However, if you did not write the scripts, you do not know what they might be doing. If there are flaws in WebView — such as those discussed in the next sections — then your users may be at risk.

Even in the absence of such bugs, JavaScript can always:

The addJavascriptInterface() Bugs

Another way that rogue JavaScript can attack users is if you use addJavascriptInterface() to allow JavaScript code to call out to a Java object that you supply.

As was noted earlier in this chapter, when addJavascriptInterface() was introduced, there is this @JavascriptInterface annotation that we should apply to the methods we want JavaScript to be able to call on the object we supply via addJavascriptInterface(). This is because of a bug in the addJavascriptInterface() implementation, whereby on 4.1 and below any method on the Java object could be called by JavaScript. This includes methods like getClass()… which in turn would allow JavaScript to use Class.forName() to get at arbitrary stuff. This was used by various bits of malware.

Hence, using addJavascriptInterface() on Android 4.1 and below is rather risky, if you are loading arbitrary third-party JavaScript. If you have the means of examining that JavaScript (e.g., you are loading the scripts yourself), you might perform some simple scans of it to see if they appear to be doing anything unfortunate with your Java object that you injected into JavaScript via addJavascriptInterface().

Worse, Android sometimes also injects its own objects, without our requesting them.

In particular, this security bug points out that, through Android 4.3, if users have enabled an accessibility service, Android automatically injects objects into WebView, using addJavascriptInterface(), named accessibility and accessibilityTraversal. So, even if you do not inject any objects yourself via addJavascriptInterface(), your WebView may be at risk. The security researchers who uncovered this attack vector suggest using removeJavascriptInterface() to specifically get rid of those objects.

The Same-Origin Policy Bug

Due to a bizarre bug in the parsing of URLs, it is possible for JavaScript code to violate the “same-origin policy” of a WebView on Android 4.3 and earlier.

Quoting Wikipedia from September 2014:

the same-origin policy is an important concept in the web application security model. The policy permits scripts running on pages originating from the same site — a combination of scheme, hostname, and port number — to access each other’s DOM with no specific restrictions, but prevents access to DOM on different sites… This mechanism bears a particular significance for modern web applications that extensively depend on HTTP cookies to maintain authenticated user sessions, as servers act based on the HTTP cookie information to reveal sensitive information or take state-changing actions. A strict separation between content provided by unrelated sites must be maintained on the client side to prevent the loss of data confidentiality or integrity.

All modern Web browsers implement the same-origin policy (SOP)… but there can be bugs. A security researcher disclosed that the original AOSP Browser application failed to implement the SOP properly, when a javascript: URL has a null byte before the j in javascript. And while the report was focused on the AOSP Browser app, the problem really lies with WebView.

To see this in action, load https://commonsware.com/misc/sop-demo.html in the AOSP Browser app on Android 4.3 or lower. This Web page consists of:


<html>
<head>
<title>WebView SOP Test</title>
</head>
<body>
<h1>WebView SOP Test</h1>
<iframe name="test" src="http://developer.android.com"></iframe>
<input type=button value="test" onclick="window.open('\u0000javascript:alert(document.domain)','test')">
</body>
</html>

It is derived from a similar example found in the blog post outlining the security flaw.

In an SOP-compliant browser, clicking the button will have no effect. In the AOSP Browser app, clicking the button shows the domain name of the document in the iframe. And, loading this HTML into a WebView has the same effect.

Part of the WebView overhaul in Android 4.4 — replacing the original implementation with a new one backed by Chromium — had the effect of fixing this bug, whether intentionally or inadvertently.

There is no obvious mitigation approach for this bug, insofar as for the attack shown above, none of the callbacks on WebViewClient or WebChromeClient seem to allow us to intercept this URL before its JavaScript is executed. If you are loading HTML yourself from a third party, you might consider scanning that HTML for obvious signs of the attack (e.g., regular expression check for \u0000javascript), but that will be limited at best. Beyond that, try to limit the content in a WebView to be from only one origin, so that there is nothing for attackers to obtain via this bug.

Also note that the security researcher who found this bug has also found another SOP violation, suggesting that mitigation strategies may be impractical.

Chrome Custom Tabs

Chrome custom tabs serve as middle ground between using a WebView in your own app and launching a URL into a separate Web browser.

With a WebView, you have complete control over the overall user experience within your app. However, your WebView is decoupled from any other browser the user may be using on the device. Conversely, launching URLs into the user’s chosen browser gives the user their normal browsing experience, but you have no control over the user experience, as the user is now in the browser app, not your app.

With Chrome custom tabs, while Chrome is handling the URL, it will allow you limited control over the action bar (color and custom actions). It also simplifies some things that you might otherwise have had to handle yourself, such as pre-fetching a Web page to be able to quickly switch to it. Basic integration is also fairly easy, coming in the form of extras on the same sort of ACTION_VIEW Intent that you might have used for launching the URL in a standalone browser.

At the same time, there are some concerns: