The Android widgets provided by Google gets more powerful every day. But regardless of how advanced they would become, sometimes we need to use a WebView to display HTML formatted content. In fact, the main use case for this class is to display web content (HTML, CSS and perhaps some JavaScript) that is downloaded locally. For all other cases where we could use a WebView, you are probably looking for a different and better solution.

However, loading locally stored content into a WebView is not as straight forward as we might think. In this post we will explore the different approaches, their challenges, and finally present the best way to do it on Android today.


There are actually four ways to load locally stored web content in Android, ranging from good to very, very bad. I’ll start with the three less optimal ways to do it and explain why they are bad.

Locally running HTTP server

One way to load the content is to embed a HTTP server in your application and load the content into your WebView through that. This would basically mean that you call WebView.loadUrl() with a URL like http://localhost:8080 or similar. The first problem with this is that our content is not loaded over a secure connection, which will then require your application to add a special flag in your manifest to indicate that you’re sending and receiving data over an unsecured HTTP URL. Secondly, even if you only bind to the localhost interface, all content served through your HTTP server is available to all other applications on your device. Thirdly, since the content is not loaded over a secure connection, some Web APIs in Chrome will not work properly.

Finally, this will create a pointless performance loss since you need to transfer data over a TCP socket, even though the content is available on the same filesystem.

An embedded HTTP server for loading local web content is bad, really bad. Don’t do this.

Loading from file:// URLs

Another approach is to load your content using simple file:// URLs. This is actually not a completely bad idea, depending on your content. One problem is that some Web APIs won’t work properly since you’re not on a secure connection. Also, if the content is bundled in something like a ZIP-file, as in the case of most e-books, this method doesn’t work unless you first unzip everything. This method might be ok if you are loading very simple content.

To enable loading content from file:// URLs, you need to configure your WebView accordingly.

val webView = findViewById(R.id.webView)

// Enable the WebView to access content through file: URLs
webView.settings.apply {
    allowFileAccess = true
    allowFileAccessFromFileURLs = true
    allowUniversalAccessFromFileURLs = true
}

Loading from content:// URLs

This is a slightly different way of loading content that would have been ok a couple of years ago (before Lollipop). You basically load your web content through a ContentProvider. First allow your WebView to load from content:// URLs:

webView.settings.apply {
    allowContentAccess = true
}

Next, simply create a ContentProvider that serves all your local web content. This would be a great idea if it wasn’t for the fact that you would have to write a ContentProvider, which usually requires a few weeks of therapy afterwards.

Luckily there is a much better solution today.

WebViewClientCompat.shouldInterceptRequest()

The correct way to load local content for your WebView today is by extending WebViewClientCompat and override shouldInterceptRequest(). This function lets you intercept all requests that the WebView makes and return your own response instead. The code to set this up is very simple:

webView.webViewClient = object : WebViewClientCompat() {
    override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
        return interceptRequest(request)
    }
}

Our interceptRequest() function will simply return a WebResourceResponse for the intercepted request. This class basically wraps a HTTP response that would otherwise come from the server, containing things like HTTP status code and reason phrase, MIME type and encoding, and the response headers and data. You have all the freedom to load the content as you wish, from files in your local storage, data that you generate in your app, or even files located inside ZIP archives (as is the case for e-books).

Now when we want to load some local content, we can use any URL we want since the actual network request will never happen. A good practice is to use URLs based on the domain androidplatform.net as Google has reserved that for this purpose. Since we now can use any URL, we can also load from a https:// URL, which means the WebView will believe this is a secure site.

So to load the local file index.html into a WebView, we simply do the following after having implemented shouldInterceptRequest():

val webView = findViewById(R.id.webView)
webView.loadUrl("https://kittens.androidplatform.net/index.html")

WebViewServer.kt

Since I know we’re always looking to save time, I’ve created a very simple “web server” to be used for this. It is just a single Kotlin file and you can find it in this gist. It lets you add one or more RequestHandlers that lets you match against the incoming request. If the handler returns true on a match, the “server” will call handleRequest() where you return the correct response for that URL.

For instance, if you would like to load files from a ZIP archive into a WebView, you would use a RequestHandler looking something like this:

class ZipRequestHandler(val zipFile: ZipFile) : RequestHandler {
    override fun shouldHandleRequest(request: Request): Boolean {
        val path = Uri.parse(request.url).path
        return zipFile.getEntry(path) != null
    }

    override fun handleRequest(request: Request): Response {
        val uri = Uri.parse(request.url)
        val path = uri.path
        val zipEntry = zipFile.getEntry(path)
        val bytes = zipFile.getInputStream(zipEntry).use { 
            return@use it.readBytes()
        }
        val (mimeType, encoding) = mimeTypeAndEncoding(path)
        return Response(200, "OK", emptyMap(), 
            mimeType, encoding, ByteArrayInputStream(bytes))
    }
}

Adding this to the “web server” will have it return the matching file when found inside the ZIP archive. I’m leaving the function mimeTypeAndEncoding() as an exercise to the reader.

This way of loading local content has several advantages. You can cache things in memory when needed, you can trick the WebView into believing that it is loading content from a secure HTTPS site, and you can even load dynamic content based on the request from the client.

Since you now also have a free “server” to start playing with, you really have no excuse to use any of the other solutions for loading your content.

Again, the code for this WebViewServer can be found here: https://gist.github.com/ErikHellman/3d131596a8d6a10eb78c418a64281cf5

Hope it helps! :)