Skip to content

Commit e56e8ff

Browse files
authored
Merge pull request #51 from MetaMask/feature/support_blob_downloads_for_Android
feat: For Android add support for downloading files with blob or base64 URLs #50
2 parents d0cccc5 + d2f2555 commit e56e8ff

File tree

8 files changed

+268
-2
lines changed

8 files changed

+268
-2
lines changed

android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import com.facebook.react.views.scroll.ScrollEventType;
4747
import com.reactnativecommunity.webview.events.TopCustomMenuSelectionEvent;
4848
import com.reactnativecommunity.webview.events.TopMessageEvent;
49+
import com.reactnativecommunity.webview.extension.file.BlobFileDownloader;
4950

5051
import org.json.JSONException;
5152
import org.json.JSONObject;
@@ -338,6 +339,10 @@ public void callInjectedJavaScript() {
338339
}
339340
}
340341

342+
public void injectBlobFileDownloaderScript() {
343+
evaluateJavascriptWithFallback(BlobFileDownloader.Companion.getBlobFileInterceptor());
344+
}
345+
341346
public void callInjectedJavaScriptBeforeContentLoaded() {
342347
if (getSettings().getJavaScriptEnabled() &&
343348
injectedJSBeforeContentLoaded != null &&

android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ public void onPageFinished(WebView webView, String url) {
7474

7575
reactWebView.callInjectedJavaScript();
7676

77+
reactWebView.injectBlobFileDownloaderScript();
78+
7779
emitFinishEvent(webView, url);
7880
}
7981
}

android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ import com.facebook.react.bridge.ReadableMap
2525
import com.facebook.react.common.MapBuilder
2626
import com.facebook.react.common.build.ReactBuildConfig
2727
import com.facebook.react.uimanager.ThemedReactContext
28+
import com.reactnativecommunity.webview.extension.file.Base64FileDownloader
29+
import com.reactnativecommunity.webview.extension.file.BlobFileDownloader
30+
import com.reactnativecommunity.webview.extension.file.addBlobFileDownloaderJavascriptInterface
2831
import org.json.JSONException
2932
import org.json.JSONObject
3033
import java.io.UnsupportedEncodingException
@@ -99,7 +102,31 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) {
99102
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
100103
webView.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
101104
}
102-
webView.setDownloadListener(DownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->
105+
val base64DownloaderRequestFilePermission = { base64: String ->
106+
webView.reactApplicationContext.getNativeModule(RNCWebViewModule::class.java)?.let { module ->
107+
module.setBase64DownloadRequest(base64)
108+
module.grantFileDownloaderPermissions(getDownloadingMessageOrDefault(), getLackPermissionToDownloadMessageOrDefault())
109+
}
110+
Unit
111+
}
112+
webView.addBlobFileDownloaderJavascriptInterface(
113+
downloadingMessage = getDownloadingMessageOrDefault(),
114+
requestFilePermission = base64DownloaderRequestFilePermission,
115+
)
116+
webView.setDownloadListener(DownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->
117+
if (url.startsWith("data:")) {
118+
Base64FileDownloader.downloadBase64File(
119+
context = context,
120+
base64 = url,
121+
downloadingMessage = getDownloadingMessageOrDefault(),
122+
requestFilePermission = base64DownloaderRequestFilePermission,
123+
)
124+
return@DownloadListener
125+
}
126+
if (url.startsWith("blob:")) {
127+
// Handled in RNCWebView.injectBlobFileDownloaderScript()
128+
return@DownloadListener
129+
}
103130
webView.setIgnoreErrFailedForThisURL(url)
104131
val module = webView.reactApplicationContext.getNativeModule(RNCWebViewModule::class.java) ?: return@DownloadListener
105132
val request: DownloadManager.Request = try {
@@ -391,7 +418,7 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) {
391418
viewWrapper.webView.settings.allowUniversalAccessFromFileURLs = allow
392419
}
393420

394-
private fun getDownloadingMessageOrDefault(): String? {
421+
private fun getDownloadingMessageOrDefault(): String {
395422
return mDownloadingMessage ?: DEFAULT_DOWNLOADING_MESSAGE
396423
}
397424

android/src/main/java/com/reactnativecommunity/webview/RNCWebViewModuleImpl.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import com.facebook.react.module.annotations.ReactModule;
3434
import com.facebook.react.modules.core.PermissionAwareActivity;
3535
import com.facebook.react.modules.core.PermissionListener;
36+
import com.reactnativecommunity.webview.extension.file.Base64FileDownloader;
3637

3738
import java.io.File;
3839
import java.io.IOException;
@@ -54,6 +55,7 @@ public class RNCWebViewModuleImpl implements ActivityEventListener {
5455
final private ReactApplicationContext mContext;
5556

5657
private DownloadManager.Request mDownloadRequest;
58+
private String base64DownloadRequest;
5759

5860
private ValueCallback<Uri> mFilePathCallbackLegacy;
5961
private ValueCallback<Uri[]> mFilePathCallback;
@@ -185,6 +187,8 @@ public boolean onRequestPermissionsResult(int requestCode, String[] permissions,
185187
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
186188
if (mDownloadRequest != null) {
187189
downloadFile(downloadingMessage);
190+
} else if (base64DownloadRequest != null) {
191+
Base64FileDownloader.INSTANCE.downloadBase64FileWithoutPermissionCheckAndDialog(mContext, base64DownloadRequest, downloadingMessage);
188192
}
189193
} else {
190194
Toast.makeText(mContext, lackPermissionToDownloadMessage, Toast.LENGTH_LONG).show();
@@ -310,6 +314,10 @@ public void setDownloadRequest(DownloadManager.Request request) {
310314
mDownloadRequest = request;
311315
}
312316

317+
public void setBase64DownloadRequest(String base64) {
318+
base64DownloadRequest = base64;
319+
}
320+
313321
public void downloadFile(String downloadingMessage) {
314322
DownloadManager dm = (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE);
315323

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package com.reactnativecommunity.webview.extension.file
2+
3+
import android.Manifest
4+
import android.app.AlertDialog
5+
import android.content.ContentValues
6+
import android.content.Context
7+
import android.content.DialogInterface
8+
import android.content.Intent
9+
import android.content.pm.PackageManager
10+
import android.os.Build
11+
import android.os.Environment
12+
import android.provider.MediaStore
13+
import android.util.Base64
14+
import android.util.Log
15+
import android.webkit.MimeTypeMap
16+
import android.widget.Toast
17+
import androidx.annotation.RequiresApi
18+
import androidx.core.content.ContextCompat
19+
import java.io.File
20+
import java.io.FileOutputStream
21+
22+
internal object Base64FileDownloader {
23+
24+
/**
25+
* This method checks for WRITE_EXTERNAL_STORAGE permission for Android 23 - 29 and requests if necessary
26+
*/
27+
fun downloadBase64File(context: Context, base64: String, downloadingMessage: String, requestFilePermission: (String) -> Unit) {
28+
val base64Parts = base64.split(",")
29+
if (base64Parts.size != 2) {
30+
Log.e("Base64FileDownloader", "Unable to parse base64 to download a file. Base64 value was: $base64")
31+
return
32+
}
33+
val (mimeType, extension) = getMimeTypeAndFileExtension(base64Parts[0])
34+
35+
showAlertDialog(context, extension) {
36+
Toast.makeText(context, downloadingMessage, Toast.LENGTH_LONG).show()
37+
val fileBytes = Base64.decode(base64Parts[1], Base64.DEFAULT)
38+
saveFileToDownloadsFolder(context, base64, fileBytes, mimeType, extension, requestFilePermission)
39+
}
40+
}
41+
42+
/**
43+
* This method is called from PermissionListener after WRITE_EXTERNAL_STORAGE permission is granted
44+
*/
45+
fun downloadBase64FileWithoutPermissionCheckAndDialog(
46+
context: Context,
47+
base64: String,
48+
downloadingMessage: String,
49+
) {
50+
val base64Parts = base64.split(",")
51+
if (base64Parts.size != 2) {
52+
Log.e("Base64FileDownloader", "Unable to parse base64 to download a file. Base64 value was: $base64")
53+
return
54+
}
55+
val (mimeType, extension) = getMimeTypeAndFileExtension(base64Parts[0])
56+
Toast.makeText(context, downloadingMessage, Toast.LENGTH_LONG).show()
57+
val fileBytes = Base64.decode(base64Parts[1], Base64.DEFAULT)
58+
saveFileToDownloadsFolder(context, base64, fileBytes, mimeType, extension, {})
59+
}
60+
61+
private fun showAlertDialog(context: Context, extension: String, onPositiveButtonClick: () -> Unit) {
62+
AlertDialog.Builder(context)
63+
.apply {
64+
setMessage("Do you want to download \nFile.${extension}?")
65+
setCancelable(false)
66+
setPositiveButton("Download", object : DialogInterface.OnClickListener {
67+
override fun onClick(p0: DialogInterface?, p1: Int) {
68+
onPositiveButtonClick()
69+
}
70+
})
71+
setNegativeButton("Cancel", object : DialogInterface.OnClickListener {
72+
override fun onClick(p0: DialogInterface?, p1: Int) {
73+
// Do nothing
74+
}
75+
})
76+
}
77+
.create()
78+
.show()
79+
}
80+
81+
82+
private fun getMimeTypeAndFileExtension(header: String): Pair<String, String> {
83+
val mimeType = Regex("data:([^;]+)").find(header)?.groupValues?.get(1) ?: "application/octet-stream"
84+
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: "bin"
85+
return mimeType to extension
86+
}
87+
88+
private fun saveFileToDownloadsFolder(
89+
context: Context,
90+
base64: String,
91+
fileBytes: ByteArray,
92+
mimeType: String,
93+
extension: String,
94+
requestFilePermission: (String) -> Unit,
95+
) {
96+
try {
97+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
98+
saveFileForAndroidQAndLater(context, fileBytes, mimeType, extension)
99+
} else {
100+
saveFileForAndroidLowerQ(context, base64, fileBytes, extension, requestFilePermission)
101+
}
102+
} catch (e: Exception) {
103+
Log.e("Base64FileDownloader", "Unable to download base64 file. Base64 value was: $base64", e)
104+
Toast.makeText(context, "Invalid filename or type", Toast.LENGTH_LONG).show()
105+
}
106+
}
107+
108+
@RequiresApi(Build.VERSION_CODES.Q)
109+
private fun saveFileForAndroidQAndLater(context: Context, fileBytes: ByteArray, mimeType: String, extension: String) {
110+
val resolver = context.contentResolver
111+
val fileName = "File.$extension"
112+
val values = ContentValues().apply {
113+
put(MediaStore.Downloads.DISPLAY_NAME, fileName) // When using this approach System automatically adds a number to the file (1), (2) if the same name already present
114+
put(MediaStore.Downloads.MIME_TYPE, mimeType)
115+
put(MediaStore.Downloads.IS_PENDING, 1)
116+
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
117+
}
118+
val uri = resolver.insert(MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values)
119+
if (uri != null) {
120+
resolver.openOutputStream(uri).use { it?.write(fileBytes) }
121+
values.clear()
122+
values.put(MediaStore.Downloads.IS_PENDING, 0)
123+
resolver.update(uri, values, null, null)
124+
showDownloadingFinishedToast(context)
125+
}
126+
}
127+
128+
private fun saveFileForAndroidLowerQ(
129+
context: Context,
130+
base64: String,
131+
fileBytes: ByteArray,
132+
extension: String,
133+
requestFilePermission: (String) -> Unit,
134+
) {
135+
if (Build.VERSION.SDK_INT >= 23 &&
136+
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
137+
) {
138+
requestFilePermission(base64)
139+
return
140+
}
141+
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
142+
if (!downloadsDir.exists()) downloadsDir.mkdirs()
143+
val file = getFileToSaveWithAvailableName(downloadsDir, extension)
144+
FileOutputStream(file).use {
145+
it.write(fileBytes)
146+
// Notify MediaScanner to show up file in the folder immediately
147+
context.sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE).setData(android.net.Uri.fromFile(file)))
148+
showDownloadingFinishedToast(context)
149+
}
150+
}
151+
152+
/**
153+
* For Android <Q we have to manually create a unique name for the file
154+
* File, File (1), File (2) and so on
155+
*/
156+
private fun getFileToSaveWithAvailableName(downloadsDir: File, extension: String): File {
157+
var suffix = 1
158+
var file = File(downloadsDir, "File.$extension")
159+
while (file.exists()) {
160+
file = File(downloadsDir, "File ($suffix).$extension")
161+
suffix++
162+
}
163+
return file
164+
}
165+
166+
private fun showDownloadingFinishedToast(context: Context) {
167+
Toast.makeText(context, "Downloaded successfully", Toast.LENGTH_LONG).show()
168+
}
169+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.reactnativecommunity.webview.extension.file
2+
3+
import android.content.Context
4+
import android.webkit.JavascriptInterface
5+
import com.reactnativecommunity.webview.RNCWebView
6+
import com.reactnativecommunity.webview.extension.file.Base64FileDownloader.downloadBase64File
7+
import java.io.IOException
8+
9+
internal fun RNCWebView.addBlobFileDownloaderJavascriptInterface(downloadingMessage: String, requestFilePermission: (String) -> Unit) {
10+
this.addJavascriptInterface(
11+
BlobFileDownloader(this.context, downloadingMessage, requestFilePermission),
12+
BlobFileDownloader.JS_INTERFACE_TAG,
13+
)
14+
}
15+
16+
internal class BlobFileDownloader(
17+
private val context: Context,
18+
private val downloadingMessage: String,
19+
private val requestFilePermission: (String) -> Unit,
20+
) {
21+
22+
@JavascriptInterface
23+
@Throws(IOException::class)
24+
fun getBase64FromBlobData(base64: String) {
25+
downloadBase64File(context, base64, downloadingMessage, requestFilePermission)
26+
}
27+
28+
companion object {
29+
const val JS_INTERFACE_TAG: String = "BlobFileDownloader"
30+
31+
fun getBlobFileInterceptor(): String =
32+
"""
33+
(function() {
34+
const originalCreateObjectURL = URL.createObjectURL;
35+
URL.createObjectURL = function(blob) {
36+
const url = originalCreateObjectURL.call(URL, blob);
37+
const reader = new FileReader();
38+
reader.readAsDataURL(blob);
39+
reader.onloadend = function() {
40+
const base64 = reader.result;
41+
${JS_INTERFACE_TAG}.getBase64FromBlobData(base64);
42+
};
43+
return url;
44+
};
45+
})();
46+
""".trimIndent()
47+
}
48+
}

android/src/newarch/com/reactnativecommunity/webview/RNCWebViewModule.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ public void setDownloadRequest(DownloadManager.Request request) {
4141
mRNCWebViewModuleImpl.setDownloadRequest(request);
4242
}
4343

44+
public void setBase64DownloadRequest(String base64) {
45+
mRNCWebViewModuleImpl.setBase64DownloadRequest(base64);
46+
}
4447
public void downloadFile(String downloadingMessage) {
4548
mRNCWebViewModuleImpl.downloadFile(downloadingMessage);
4649
}

android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewModule.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ public void setDownloadRequest(DownloadManager.Request request) {
4343
mRNCWebViewModuleImpl.setDownloadRequest(request);
4444
}
4545

46+
public void setBase64DownloadRequest(String base64) {
47+
mRNCWebViewModuleImpl.setBase64DownloadRequest(base64);
48+
}
49+
4650
public void downloadFile(String downloadingMessage) {
4751
mRNCWebViewModuleImpl.downloadFile(downloadingMessage);
4852
}

0 commit comments

Comments
 (0)