从 Android WebViewClient 中的网站下载 Blob 文件

Posted

技术标签:

【中文标题】从 Android WebViewClient 中的网站下载 Blob 文件【英文标题】:Download Blob file from Website inside Android WebViewClient 【发布时间】:2018-07-31 05:39:57 【问题描述】:

我有一个带有按钮的 html 网页,当用户单击时,该按钮会触发 POST 请求。请求完成后,会触发以下代码:

window.open(fileUrl);

在浏览器中一切正常,但是在 Webview 组件中实现时,新选项卡不会打开。

仅供参考:在我的 android 应用上,我设置了以下内容:

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

AndroidManifest.xml我有以下权限:

<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"/>

我也尝试使用setDownloadListener 来获取下载。另一种方法是将WebViewClient() 替换为WebChromeClient(),但行为相同。

【问题讨论】:

【参考方案1】:

好的,我在使用 webview 时遇到了同样的问题,我意识到 WebViewClient 无法像 Chrome 桌面客户端那样加载“blob URL”。我使用Javascript接口解决了它。您可以按照以下步骤执行此操作,它与 minSdkVersion 一起工作正常: 17. 首先,使用 JS 将 Blob URL 数据转换为 Base64 字符串。其次,将此字符串发送到 Java 类,最后将其转换为可用格式,在这种情况下,我将其转换为“.pdf”文件。

在继续之前,您可以在此处下载源代码 :)。该应用程序是用 Kotlin 和 Java 开发的。如果您发现任何错误,请告诉我,我会修复它:

https://github.com/JaegerCodes/AmazingAndroidWebview

首先要做的事情。您必须设置您的网络视图。就我而言,我将网页加载到片段中:

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);
    

最后,创建一个 JavaScriptInterface 类。此类包含将在我们的网页中执行的脚本。

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);
            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();
    

 

额外:如果您想与其他应用共享这些下载的文件,请在以下位置创建一个 xml 文件:..\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>

最后将此提供程序添加到您的 AndroidManifest.xml 文件中

<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 ->

另一种方法是使用“Chrome 自定义标签”

Java:

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

科特林:

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

来源:

https://***.com/a/41339946/4001198

https://***.com/a/11901662/4001198

https://***.com/a/19959041/4001198

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

【讨论】:

非常感谢您。通知代码错误,但实际下载了 Blob。 嘿。这是关于Android Oreo(8.0)在没有提前配置通知渠道的情况下不显示通知的问题。抱歉,如果不清楚:) Blob 在我的情况下没有下载。当我在 android 的 webview 中单击下载时没有任何反应。请提供任何帮助。 @Vikrant 将其移至新类 可以确认这个答案在 2020 年仍然有效(虽然我没有直接复制粘贴,而是采用了它的方法)。如果您需要将 mime 类型和下载属性从 javascript 传递到 Javascript 界面,您还可以将其用于 PDF 以外的内容,例如fileName = document.querySelector('a[href=\""+ blobUrl +"\"]').getAttribute('download');【参考方案2】:

我最近在 Android 上遇到了类似的问题。多亏了这个帖子,我才能找到解决方法!

我在 Kotlin 中重用和重构了 code snippet shared above

说明:WebViewClient 无法加载 Blob URL。一种解决方法是将 Blob URL 转换为 Blob 对象,然后转换为 Web 端的 Base64 数据。 Native端会根据Base64数据前缀指定的mime类型下载Base64数据中的附件。

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>

注意JavascriptInterface.kt的processBase64Data方法内,仅限 data:image/png;base64, 正在处理中。需要额外的实现来处理具有不同 MIME 类型(data:application/pdf;base64,data:image/gif;base64,data:image/png;base64, 等)的数据

【讨论】:

以上是关于从 Android WebViewClient 中的网站下载 Blob 文件的主要内容,如果未能解决你的问题,请参考以下文章

Android WebViewClient url 重定向(Android URL 加载系统)

Android WebView 在添加 WebViewClient 时忽略 target="_blank"

在 Android WebViewClient 中获取空白页

Android - 如何在 API 级别 4 的 android WebViewClient 中拦截表单 POST

HTTP状态码webview android, WebViewClient

Android WebViewClient 回调调用过于频繁