alpaca0984.log

Android WebView — Intercept Download

alpaca0984

In general, embedding a WebView inside native Android app is not so difficult. However, if you want to intercept interactions inside the WebView, it can be very tricky.

Did you ever try intercepting download for like a pdf file? It's quite common that creating object URL with Blob data and assign it to <a> tag, and performing click.

const pdf = /* Get PDF data as ArrayBuffer or something */;
 
const blobStore = new Blob([pdf], { type: 'application/pdf' });
const data = window.URL.createObjectURL(blobStore);
const link = document.createElement('a');
link.href = data;
link.download = "sample.pdf";
document.body.appendChild(link);
 
link.click();

When it's triggered in Android's WebView, nothing happens. We have to hook the download and open or save the file in Android's code.


Download a file in the public "Download" folder

In Android, when we instantiate a WebView, we need to pass JavaScript Interface so that the Android app can communicate with WebView inside JS files. I will show the implementation of the interface later. The setDownloadListener() allows us to intercept the download inside the WebView. In the above example, when the link.click() is performed, the listener is invoked.

private const val JS_INTERFACE_NAME = "android"
 
// Inside `Activity.onCreate()`
val javaScriptInterface = MyJavaScriptInterface()
AndroidView(factory = { context ->
    WebView(context).apply {
        settings.javaScriptEnabled = true
        webViewClient = WebViewClient()
        addJavascriptInterface(javaScriptInterface, JS_INTERFACE_NAME)
        setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->
            // e.g. "blob:http://10.0.2.2:3000/f2be4eb7-a85d-4458-8f13-1deb3db307d4"
            if (url.startsWith("blob:")) {
                val script = JavaScriptInterfaceImpl.fetchBlobScript(
                    blobUrl = url,
                    contentDisposition = contentDisposition,
                    mimetype = mimetype,
                )
                evaluateJavascript(script, null)
            }
        }
 
        // As an example, I serve a web site from local host
        loadUrl("http://10.0.2.2:3000")
    }
})

And, this is how the JavaScript Interface looks (detail is following).

class MyJavaScriptInterface() {
 
    @JavascriptInterface
    fun receiveBase64(
        base64: String,
        url: String,
        contentDisposition: String,
        mimetype: String,
    ) {
        val content = Base64.decode(base64, Base64.DEFAULT)
        val fileName = URLUtil.guessFileName(url, contentDisposition, mimetype)
        val file = File(
            Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
            fileName
        )
 
        file.writeBytes(content)
    }
 
    companion object {
 
        fun fetchBlobScript(
            blobUrl: String,
            contentDisposition: String,
            mimetype: String,
        ): String {
            return """
                (async () => {
                  const response = await fetch('${blobUrl}', {
                    headers: {
                      'Content-Type': '${mimetype}',
                    }
                  });
                  const blob = await response.blob();
 
                  const reader = new FileReader();
                  reader.addEventListener('load', () => {
                    const base64 = reader.result.replace(/^data:.+;base64,/, '');
                    ${JS_INTERFACE_NAME}.receiveBase64(
                      base64,
                      '${blobUrl}',
                      '${contentDisposition}',
                      '${mimetype}'
                    );
                  });
                  reader.readAsDataURL(blob);
                })();
            """.trimIndent()
        }
    }
}

When the link is clicked, it injects JS doing:

  1. Fetch Blob data from the given URL
  2. Convert the Blob to data URL using FileReader
  3. Extract base64 from the data URL
  4. Pass the base64 data to the JavaScript Interface calling receiveBase64()

FileReader can also get ArrayBuffer from Blob but unfortunately, JavaScript's ArrayBuffer and Kotlin's ByteArray are not compatible. Also, as noted in the official doc, to get base64 string, we remove the metadata:

Note: The blob's result cannot be directly decoded as Base64 without first removing the Data-URL declaration preceding the Base64-encoded data. To retrieve only the Base64 encoded string, first remove data:/;base64, from the result.

Please note that to write data in the public directory, you have to grant permission in manifest.

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />

[Optional] Open the file after download

To open a file inside public directory, we need several steps. At first, create res/xml/file_paths.xml containing paths you want to read or write. In this case, I add external-path because that's where public Download folder is located.

<?xml version="1.0" encoding="utf-8"?>
<paths>
 
    <external-path
        name="external_path"
        path="/" />
</paths>

Next, add <provider> node inside <application>. FileProvider converts file to content that we can show to the user on the app.

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="com.example.downloadinterceptionsample"
    android:exported="false"
    android:grantUriPermissions="true">
 
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

At last, update JavaScript Interface to have context and open the content through Intent.

// Reference the value defined in `<provider>` in manifest.
private const val FILE_AUTHORITY = "com.example.downloadinterceptionsample"
 
class MyJavaScriptInterface(private val context: Context) {
 
    @JavascriptInterface
    fun receiveBase64(
        base64: String,
        url: String,
        contentDisposition: String,
        mimetype: String,
    ) {
        val content = Base64.decode(base64, Base64.DEFAULT)
        val fileName = URLUtil.guessFileName(url, contentDisposition, mimetype)
        val file = File(
            Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
            fileName
        )
 
        file.writeBytes(content)
 
        val uri = FileProvider.getUriForFile(context, FILE_AUTHORITY, file)
        val intent = Intent(Intent.ACTION_VIEW).apply {
            setDataAndType(uri, mimetype)
            flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
        }
        context.startActivity(intent)
    }

Full codebase can be found at https://github.com/alpaca0984/android-download-interception-sample. It also contains web page served by local host. You can see how the above solution work on your side!