Download Blob file from Website inside Android WebViewClient
Asked Answered
N

2

22

I have an HTML Web page with a button that triggers a POST request when the user clicks on. When the request is done, the following code is fired:

window.open(fileUrl);

Everything works great in the browser, but when implement that inside of a Webview Component, the new tab doesn't is opened.

FYI: On my Android App, I have set the followings things:

webview.getSettings().setJavaScriptEnabled(true);
webview.getSettings().setSupportMultipleWindows(true);
webview.getSettings().setJavaScriptCanOpenWindowsAutomatically(true);

On the AndroidManifest.xml I have the following permissions:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_DOWNLOAD_MANAGER"/>

I try too with a setDownloadListener to catch the download. Another approach was replaced the WebViewClient() for WebChromeClient() but the behavior was the same.

Narrows answered 20/2, 2018 at 18:58 Comment(0)
O
55

Ok I had the same problem working with webviews, I realized that WebViewClient can't load "blob URLs" as Chrome Desktop client does. I solved it using Javascript Interfaces. You can do this by following the steps below and it works fine with minSdkVersion: 17. First, transform the Blob URL data in Base64 string using JS. Second, send this string to a Java Class and finally convert it in an available format, in this case I converted it in a ".pdf" file.

Before continue you can download the source code here :). The app is developed in Kotlin and Java. If you find any error, please let me know and I will fix it:

https://github.com/JaegerCodes/AmazingAndroidWebview

First things first. You have to setup your webview. In my case I'm loading the webpages in a fragment:

public class WebviewFragment extends Fragment {
    WebView browser;
    ...
 
    // invoke this method after set your WebViewClient and ChromeClient
    private void browserSettings() {
        browser.getSettings().setJavaScriptEnabled(true);
        browser.setDownloadListener(new DownloadListener() {
            @Override
            public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) {
                browser.loadUrl(JavaScriptInterface.getBase64StringFromBlobUrl(url));
            }
        });
        browser.getSettings().setAppCachePath(getActivity().getApplicationContext().getCacheDir().getAbsolutePath());
        browser.getSettings().setCacheMode(WebSettings.LOAD_DEFAULT);
        browser.getSettings().setDatabaseEnabled(true);
        browser.getSettings().setDomStorageEnabled(true);
        browser.getSettings().setUseWideViewPort(true);
        browser.getSettings().setLoadWithOverviewMode(true);
        browser.addJavascriptInterface(new JavaScriptInterface(getContext()), "Android");
        browser.getSettings().setPluginState(PluginState.ON);
    }
}

Finally, create a JavaScriptInterface class. This class contains the script that is going to be executed in our webpage.

public class JavaScriptInterface {
    private Context context;
    public JavaScriptInterface(Context context) {
        this.context = context;
    }

    @JavascriptInterface
    public void getBase64FromBlobData(String base64Data) throws IOException {
        convertBase64StringToPdfAndStoreIt(base64Data);
    }
    public static String getBase64StringFromBlobUrl(String blobUrl) {
        if(blobUrl.startsWith("blob")){
            return "javascript: var xhr = new XMLHttpRequest();" +
                    "xhr.open('GET', '"+ blobUrl +"', true);" +
                    "xhr.setRequestHeader('Content-type','application/pdf');" +
                    "xhr.responseType = 'blob';" +
                    "xhr.onload = function(e) {" +
                    "    if (this.status == 200) {" +
                    "        var blobPdf = this.response;" +
                    "        var reader = new FileReader();" +
                    "        reader.readAsDataURL(blobPdf);" +
                    "        reader.onloadend = function() {" +
                    "            base64data = reader.result;" +
                    "            Android.getBase64FromBlobData(base64data);" +
                    "        }" +
                    "    }" +
                    "};" +
                    "xhr.send();";
        }
        return "javascript: console.log('It is not a Blob URL');";
    }
    private void convertBase64StringToPdfAndStoreIt(String base64PDf) throws IOException {
        final int notificationId = 1;
        String currentDateTime = DateFormat.getDateTimeInstance().format(new Date());
        final File dwldsPath = new File(Environment.getExternalStoragePublicDirectory(
                Environment.DIRECTORY_DOWNLOADS) + "/YourFileName_" + currentDateTime + "_.pdf");
        byte[] pdfAsBytes = Base64.decode(base64PDf.replaceFirst("^data:application/pdf;base64,", ""), 0);
        FileOutputStream os;
        os = new FileOutputStream(dwldsPath, false);
        os.write(pdfAsBytes);
        os.flush();

        if (dwldsPath.exists()) {
            Intent intent = new Intent();
            intent.setAction(android.content.Intent.ACTION_VIEW);
            Uri apkURI = FileProvider.getUriForFile(context,context.getApplicationContext().getPackageName() + ".provider", dwldsPath);
            intent.setDataAndType(apkURI, MimeTypeMap.getSingleton().getMimeTypeFromExtension("pdf"));
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
            PendingIntent pendingIntent = PendingIntent.getActivity(context,1, intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE);
            String CHANNEL_ID = "MYCHANNEL";
            final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);

            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
                NotificationChannel notificationChannel= new NotificationChannel(CHANNEL_ID,"name", NotificationManager.IMPORTANCE_LOW);
                Notification notification = new Notification.Builder(context,CHANNEL_ID)
                        .setContentText("You have got something new!")
                        .setContentTitle("File downloaded")
                        .setContentIntent(pendingIntent)
                        .setChannelId(CHANNEL_ID)
                        .setSmallIcon(android.R.drawable.sym_action_chat)
                        .build();
                if (notificationManager != null) {
                    notificationManager.createNotificationChannel(notificationChannel);
                    notificationManager.notify(notificationId, notification);
                }

            } else {
                NotificationCompat.Builder b = new NotificationCompat.Builder(context, CHANNEL_ID)
                        .setDefaults(NotificationCompat.DEFAULT_ALL)
                        .setWhen(System.currentTimeMillis())
                        .setSmallIcon(android.R.drawable.sym_action_chat)
                        //.setContentIntent(pendingIntent)
                        .setContentTitle("MY TITLE")
                        .setContentText("MY TEXT CONTENT");

                if (notificationManager != null) {
                    notificationManager.notify(notificationId, b.build());
                    Handler h = new Handler();
                    long delayInMilliseconds = 1000;
                    h.postDelayed(new Runnable() {
                        public void run() {
                            notificationManager.cancel(notificationId);
                        }
                    }, delayInMilliseconds);
                }
            }
        }
        Toast.makeText(context, "PDF FILE DOWNLOADED!", Toast.LENGTH_SHORT).show();
    }
}
 

EXTRA: If you want to share these downloaded files with other Apps create an xml file in: ..\res\xml\provider_paths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path name="external_files" path="."/>
</paths>

Finally add this provider to your AndroidManifest.xml file

<application ...>
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.provider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/provider_paths"/>
        </provider>
        <!-- some code below ->

Another approach is by using "Chrome Custom Tabs"

Java:

CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
    CustomTabsIntent customTabsIntent = builder.build();
    customTabsIntent.launchUrl(context, Uri.parse("https://stackoverflow.com"));

Kotlin:

val url = "https://stackoverflow.com/"
            val builder = CustomTabsIntent.Builder()
            val customTabsIntent = builder.build()
            customTabsIntent.launchUrl(this, Uri.parse(url))

Sources:

https://mcmap.net/q/587272/-how-to-read-the-data-in-a-blob-in-webview-on-android-duplicate

https://mcmap.net/q/66099/-how-to-get-a-file-or-blob-from-an-object-url

https://mcmap.net/q/576812/-how-to-convert-base64-to-pdf

https://developer.android.com/training/secure-file-sharing/setup-sharing

Octarchy answered 23/2, 2018 at 19:30 Comment(23)
Thank you so much for this. The notification code is wrong, but the Blob is actually downloaded.Trolley
Hi Cbr. Just in case, can you tell me the error that you have with the notification code ? So after that I will correct it. Nice that this helped you :)Octarchy
Hey. It was about Android Oreo (8.0) not showing the notification without configuring notification channels in advance. Sorry if it wasn't clear :)Trolley
Blob is not downloading in my case. Nothing happens when i click download in webview of android. Any help please.Chaliapin
Inner class cannot have static declarations error on function getBase64StringFromBlobUrl . Any way to solve it ?Y
Can you copy and paste your code? Seems like your Class has an inner class. Try to separete them in differents files or in other hand, just delete the word "static" for that method :)Octarchy
@Y move it to a new classAcidosis
Has anyone else able to achieve the same, using the above code?Ought
I am unable to download filpkart invoice because of there are blob url(blob:flipkart.com/89a1e099-8979-4ca8-a81b-c301e9381c4f). I am getting "Refused to connect to 'blob:flipkart.com/26632bef-54fb-4f62-b1ea-80bc08193cb8' because it violates the following Content Security Policy directive: "connect-src 'self' *" in console.Log(). Anyone can help me ASAPConnoisseur
Can confirm this answer still works in 2020 (although I didn't copy and paste it directly but took the approach from it). You can also pass the mime type and download attribute from javascript to the Javascript interface if you need to use it for more than PDF's e.g. fileName = document.querySelector('a[href=\""+ blobUrl +"\"]').getAttribute('download');Leshalesher
The script inside getBase64StringFromBlobUrl method should be the script that is on webpage?Doura
Yeah it could be ^^. But in this case I did it like this because my customer hadn't control of the website's source code : )Octarchy
Why do we need to create provider path in XML and add that to android manifest even after the file is downloaded? is it necessary?Leisured
It is only necessary if you want to share these downloaded files with other Apps like Whatsapp or Telegram. I really don't know why I wrote this. I wrote this answer like three years ago : ) developer.android.com/training/secure-file-sharing/…Octarchy
thank you for a comprehensive answer I followed it but nothing happen not anything in logs as well.Filide
Hi, did you download the source code? github.com/JaegerCodes/AmazingAndroidWebviewOctarchy
This still works in 2021. I'm using android 10 but with additional setting in manifest, under application, I add android:requestLegacyExternalStorage="true"Finstad
do you have a full example of the project? i am new to RN... i didnt undestand most of things and copy-paste didnt work so...Northeastwards
I am getting below error for the blob url Uncaught ReferenceError: Android is not defined"Wamsley
@SurajSahijwani, did you set this... browser.addJavascriptInterface(new JavaScriptInterface(getContext()), "Android") ? If you've given the interface a different name, you'll need to change the Javascript.Mathamathe
i am getting error like :Not allowed to load local resource: blob:https://....."Typewriting
THank you. After trying so many things. Finally! this worked.Exponible
Github demo working for me and added android:requestLegacyExternalStorage="true" and for Android 13 support I removed val currentDateTime = DateFormat.getDateTimeInstance().format(Date()) and added val dateFormat = SimpleDateFormat("yyyyMMddHHmmss") val currentDateTime = dateFormat.format(Date())Leonard
W
2

I recently faced similar issues on Android. I was able to find a work around thanks to this thread!

I reused & refactored the code snippet shared above in Kotlin

Explanation: WebViewClient can't load Blob URL. A work around would be to convert Blob URL to a Blob Object, then to a Base64 data on the web side. The native side will download the attachment in Base64 data according to the mime type specified in the prefix of the Base64 data.

JavascriptInterface.kt

import android.content.Context
import android.os.Environment
import android.util.Base64
import android.util.Log
import android.webkit.JavascriptInterface
import android.widget.Toast
import java.io.File
import java.io.FileOutputStream

class JavascriptInterface {
    var context: Context;

    constructor(context: Context) {
        this.context = context;
    }

    /**
     * Method to process Base64 data then save it locally.
     *
     * 1. Strip Base64 prefix from Base64 data
     * 2. Decode Base64 data
     * 3. Write Base64 data to file based on mime type located in prefix
     * 4. Save file locally
     */
    @JavascriptInterface
    fun processBase64Data(base64Data: String) {
        Log.i("JavascriptInterface/processBase64Data", "Processing base64Data ...")

        var fileName = "";
        var bytes = "";

        if (base64Data.startsWith("data:image/png;base64,")) {
            fileName = "foo.png"
            bytes = base64Data.replaceFirst("data:image/png;base64,","")
        }

        if (fileName.isNotEmpty() && bytes.isNotEmpty()) {
            val downloadPath = File(
                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
                fileName
            )

            Log.i("JavascriptInterface/processBase64Data", "Download Path: ${downloadPath.absolutePath}")

            val decodedString = Base64.decode(bytes, Base64.DEFAULT)
            val os = FileOutputStream(downloadPath, false)
            os.write(decodedString)
            os.flush()
        }
    }

    /**
     * Method to convert blobUrl to Blob, then process Base64 data on native side
     *
     * 1. Download Blob URL as Blob object
     * 2. Convert Blob object to Base64 data
     * 3. Pass Base64 data to Android layer for processing
     */
    fun getBase64StringFromBlobUrl(blobUrl: String): String {
        Log.i("JavascriptInterface/getBase64StringFromBlobUrl", "Downloading $blobUrl ...")

        // Script to convert blob URL to Base64 data in Web layer, then process it in Android layer
        val script = "javascript: (() => {" +
            "async function getBase64StringFromBlobUrl() {" +
            "const xhr = new XMLHttpRequest();" +
            "xhr.open('GET', '${blobUrl}', true);" +
            "xhr.setRequestHeader('Content-type', 'image/png');" +
            "xhr.responseType = 'blob';" +
            "xhr.onload = () => {" +
            "if (xhr.status === 200) {" +
            "const blobResponse = xhr.response;" +
            "const fileReaderInstance = new FileReader();" +
            "fileReaderInstance.readAsDataURL(blobResponse);" +
            "fileReaderInstance.onloadend = () => {" +
            "console.log('Downloaded' + ' ' + '${blobUrl}' + ' ' + 'successfully!');" +
            "const base64data = fileReaderInstance.result;" +
            "Android.processBase64Data(base64data);" +
            "}" + // file reader on load end
            "}" + // if
            "};" + // xhr on load
            "xhr.send();" +
            "}" + // async function
            "getBase64StringFromBlobUrl();" +
            "}) ()"

        return script
    }
}

MainActivity.kt

import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.support.v4.app.ActivityCompat
import android.support.v7.app.AppCompatActivity
import android.webkit.DownloadListener
import android.webkit.WebView
import java.net.URL

class MainActivity : AppCompatActivity() {
    var debug = true

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Request permissions
        ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), PackageManager.PERMISSION_GRANTED);

        val wv = findViewById<WebView>(R.id.web_view)
        wv.settings.javaScriptEnabled = true
        wv.settings.domStorageEnabled = true

        // Load local .html with baseUrl set to production domain since attachment downloads does not work cross-origin
        val queryParams = "foo=bar"
        var url = URL(OmnichannelConfig.config["src"])
        val baseUrl = "${url.protocol}://${url.host}?${queryParams}"
        val data = application.assets.open("index.html").bufferedReader().use {
            it.readText()
        };

        wv.loadDataWithBaseURL(baseUrl, data, "text/html", null, baseUrl)

        // Expose Android methods to Javascript layer
        val javascriptInterface = JavascriptInterface(applicationContext)
        wv.addJavascriptInterface(javascriptInterface, "Android")

        // Subscribe to notification when a file from Web content needs to be downloaded in Android layer
        wv.setDownloadListener(DownloadListener { url, _, _, _, _ ->
            if (url.startsWith("blob:")) {
                wv.evaluateJavascript(javascriptInterface.getBase64StringFromBlobUrl(url), null)
            }
        })
    }
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.demo">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Note: Inside JavascriptInterface.kt's processBase64Data method, only data:image/png;base64, is being handled. Additional implementation is required to handle data with different mime types (data:application/pdf;base64,, data:image/gif;base64,, data:image/png;base64,, etc)

Wall answered 6/1, 2022 at 0:11 Comment(2)
Hey. Thanks for your answer here. But what is OnmiChannelConfig above?Pronation
Thanks for this Kotlin code. This code works for me! Quick note for other who might be reading this: 1. If you want to support pdf blob, etc, need to change the content type in both processBase64Data and getBase64StringFromBlobUrl methods. 2. fileName of "foo.png" is static in the code and you likely need a randomized name based on date, etc. Watch out for write error writing to existing file, invalid file name, etc.Earflap

© 2022 - 2024 — McMap. All rights reserved.