Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Blob downloading feature (Android) #3022

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
45 changes: 45 additions & 0 deletions android/src/main/assets/blobDownloader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// This is used because download from native side won't have session changes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what what this means about "session changes"?


window.reactNativeDownloadBlobUrl = function reactNativeDownloadBlobUrl(url) {
var req = new XMLHttpRequest();
req.open('GET', url, true);
req.responseType = 'blob';

req.onload = function(event) {
var blob = req.response;
saveBlob(blob);
};
req.send();

function sendMessage(message) {
ReactNativeWebViewDownloader.downloadFile(JSON.stringify(message));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ReactNativeWebViewDownloader.downloadFile(JSON.stringify(message));
ReactNativeWebViewDownloader.downloadFile(JSON.stringify(message));

}

function saveBlob(blob, filename) {
var reader = new FileReader();
reader.readAsDataURL(blob);
let fileName = "";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
reader.readAsDataURL(blob);
let fileName = "";
reader.readAsDataURL(blob);
let fileName = "";



Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

reader.onloadend = function() {

const popularExts = ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "svg", "mp3", "mp4", "avi", "mov", "wmv", "flv", "ogg", "webm", "mkv", "zip", "rar", "7z", "tar", "gz", "bz2", "iso", "dmg", "exe", "apk", "torrent", "epub", "mobi", "azw", "azw3", "djvu", "djv", "fb2", "rtf", "odt", "odp", "ods", "odg", "odf", "odb", "csv", "tsv", "ics", "vcf", "msg", "eml", "emlx", "mht", "mhtml", "xps", "oxps", "ps", "rtfd", "key", "numbers", "pages", "apk", "torrent", "epub", "mobi", "azw", "azw3", "djvu", "djv", "fb2", "rtf", "odt", "odp", "ods", "odg", "odf", "odb", "csv", "tsv", "ics", "vcf", "msg", "eml", "emlx", "mht", "mhtml", "xps", "oxps", "ps", "rtfd", "key", "numbers", "pages"];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like a more accurate name would be supportedExtensions... but also, why only support these? Why not accept whatever extension is present?

let ext = blob.type.split("/")[1];
if (!ext || !popularExts.includes(ext)) {
ext = "bin";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ext = "bin";
ext = "bin";

}

fileName = `${filename || "download"}.${ext}`;

sendMessage({
event: 'file',
fileName,
data: reader.result
});

devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
}
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
};
};


}
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved


Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
import android.content.Context;
import android.graphics.Rect;
import android.os.Build;
import android.os.Environment;
import android.text.TextUtils;
import android.util.AttributeSet;

import android.util.Base64;
import android.util.Log;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
Expand All @@ -19,6 +22,7 @@
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.CatalystInstance;
Expand All @@ -44,6 +48,11 @@
import org.json.JSONException;
import org.json.JSONObject;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.List;
Expand All @@ -56,6 +65,10 @@ public class RNCWebView extends WebView implements LifecycleEventListener {
String injectedJSBeforeContentLoaded;
protected static final String JAVASCRIPT_INTERFACE = "ReactNativeWebView";

protected static final String DOWNLOAD_INTERFACE = "ReactNativeWebViewDownloader";

String downloadingMessage = "File Downloaded!";
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved

/**
* android.webkit.WebChromeClient fundamentally does not support JS injection into frames other
* than the main frame, so these two properties are mostly here just for parity with iOS & macOS.
Expand All @@ -64,6 +77,8 @@ public class RNCWebView extends WebView implements LifecycleEventListener {
protected boolean injectedJavaScriptBeforeContentLoadedForMainFrameOnly = true;

protected boolean messagingEnabled = false;

protected boolean downloadingBlobEnabled = false;
protected @Nullable
String messagingModuleName;
protected @Nullable
Expand Down Expand Up @@ -248,6 +263,10 @@ protected RNCWebViewBridge createRNCWebViewBridge(RNCWebView webView) {
return new RNCWebViewBridge(webView);
}

protected RNCWebViewDownloadBridge createRNCWebViewDownloadBlobBridge(RNCWebView webView) {
return new RNCWebViewDownloadBridge(webView);
}

protected void createCatalystInstance() {
ThemedReactContext reactContext = (ThemedReactContext) this.getContext();

Expand All @@ -271,6 +290,26 @@ public void setMessagingEnabled(boolean enabled) {
}
}

@SuppressLint("AddJavascriptInterface")
public void setDownloadingBlobEnabled(boolean enabled) {
if (downloadingBlobEnabled == enabled) {
return;
}

downloadingBlobEnabled = enabled;

if (enabled) {
addJavascriptInterface(createRNCWebViewDownloadBlobBridge(this), DOWNLOAD_INTERFACE);
} else {
removeJavascriptInterface(DOWNLOAD_INTERFACE);
}
}


public void setDownloadingMessage(String message) {
downloadingMessage = message;
}

protected void evaluateJavascriptWithFallback(String script) {
evaluateJavascript(script, null);
}
Expand All @@ -283,6 +322,22 @@ public void callInjectedJavaScript() {
}
}

public void injectBlobDownloaderJS(){
// get blobDownloaderJS from assets and inject it
String blobDownloaderJS = null;
try {
InputStream inputStream = this.getContext().getAssets().open("blobDownloader.js");
int size = inputStream.available();
byte[] buffer = new byte[size];
inputStream.read(buffer);
inputStream.close();
blobDownloaderJS = new String(buffer, "UTF-8");
} catch (IOException e) {
e.printStackTrace();
}
evaluateJavascriptWithFallback("(function() {\n" + blobDownloaderJS + ";\n})();" );
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
}

public void callInjectedJavaScriptBeforeContentLoaded() {
if (getSettings().getJavaScriptEnabled() &&
injectedJSBeforeContentLoaded != null &&
Expand Down Expand Up @@ -401,8 +456,71 @@ protected class RNCWebViewBridge {
public void postMessage(String message) {
mWebView.onMessage(message);
}



devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
}

protected class RNCWebViewDownloadBridge {
RNCWebView mWebView;

RNCWebViewDownloadBridge(RNCWebView c) {
mWebView = c;
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* This method is called whenever JavaScript running within the web view calls:
* - window[JAVASCRIPT_INTERFACE].postMessage
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
*/

@JavascriptInterface
public void downloadFile(String json) {
//parse json
try {
JSONObject jsonObject = null;
jsonObject = new JSONObject(json);
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
String url = jsonObject.getString("data");
String fileName = jsonObject.getString("fileName");
//decode base64 string and save to file
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);

File file = new File(path, fileName);

if(!path.exists())
path.mkdirs();
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved


devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
// if file exists, change name to avoid overwrite
int i = 1;
while(file.exists()) {
file = new File(path, fileName.substring(0, fileName.lastIndexOf(".")) + "(" + i + ")" + fileName.substring(fileName.lastIndexOf(".")));
i++;
}

if(!file.exists())
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
file.createNewFile();

String base64EncodedString = url.substring(url.indexOf(",") + 1);
byte[] decodedBytes = Base64.decode(base64EncodedString, Base64.DEFAULT);
OutputStream os = new FileOutputStream(file);
os.write(decodedBytes);
os.close();

Toast.makeText(mWebView.getContext(), downloadingMessage, Toast.LENGTH_LONG).show();



devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
} catch (JSONException e) {
e.printStackTrace();
} catch (IOException e) {
Log.i("ReactNative", "IOException: " + e.getMessage());
e.printStackTrace();
}


devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
}
}


protected static class ProgressChangedFilter {
private boolean waitingForCommandLoadUrl = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ public void onPageFinished(WebView webView, String url) {

reactWebView.callInjectedJavaScript();

reactWebView.injectBlobDownloaderJS();
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved

emitFinishEvent(webView, url);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.CookieManager
import android.webkit.DownloadListener
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import com.facebook.react.bridge.ReadableArray
Expand All @@ -24,6 +26,8 @@ import com.facebook.react.common.build.ReactBuildConfig
import com.facebook.react.uimanager.ThemedReactContext
import org.json.JSONException
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.UnsupportedEncodingException
import java.net.MalformedURLException
import java.net.URL
Expand Down Expand Up @@ -81,6 +85,7 @@ class RNCWebViewManagerImpl {
setAllowUniversalAccessFromFileURLs(webView, false)
setMixedContentMode(webView, "never")


devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
// Fixes broken full-screen modals/galleries due to body height being 0.
webView.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
Expand All @@ -90,6 +95,16 @@ class RNCWebViewManagerImpl {
WebView.setWebContentsDebuggingEnabled(true)
}
webView.setDownloadListener(DownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->

devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved
Log.i("ReactNative", mimetype);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This log line should probably include some context, e.g. what's being logged

if(url.startsWith("blob:")){
Log.i("ReactNative", "Downloading " + url);
downloadBlob(url, webView)
return@DownloadListener
}
Comment on lines +99 to +103
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should only be executed if the feature is enabled?

// print the url
devmuhnnad marked this conversation as resolved.
Show resolved Hide resolved


webView.setIgnoreErrFailedForThisURL(url)
val module = webView.themedReactContext.getNativeModule(RNCWebViewModule::class.java) ?: return@DownloadListener
val request: DownloadManager.Request = try {
Expand All @@ -105,6 +120,8 @@ class RNCWebViewManagerImpl {

val downloadMessage = "Downloading $fileName"



Comment on lines +122 to +123
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

//Attempt to add cookie, if it exists
var urlObj: URL? = null
try {
Expand Down Expand Up @@ -137,6 +154,31 @@ class RNCWebViewManagerImpl {
return webView
}



Comment on lines +156 to +157
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

class RNCWebViewBridge internal constructor() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between this and RNCWebViewDownloadBridge?


@RequiresApi(Build.VERSION_CODES.O)
@JavascriptInterface
fun downloadFile() {
Log.i("ReactNative", "invoked by js");
}
Comment on lines +160 to +164
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks unused



Comment on lines +165 to +166
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

}

fun downloadBlob(url : String, webview: RNCWebView){
injectJs(webview, "window.reactNativeDownloadBlobUrl('" + url + "');");
}

fun injectJs(webview: RNCWebView, js: String){
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think React Native supports versions older than Kit Kat

webview.evaluateJavascript(js, null);
} else {
webview.loadUrl("javascript:$js");
}
}

private fun setupWebChromeClient(
webView: RNCWebView,
) {
Expand Down Expand Up @@ -499,6 +541,11 @@ class RNCWebViewManagerImpl {
view.setMessagingEnabled(value)
}

fun setBlobDownloadingEnabled(view: RNCWebView, value: Boolean) {
view.setDownloadingBlobEnabled(value)
view.setDownloadingMessage(getDownloadingMessageOrDefault())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like you wanted a "downloaded" message, not the "downloading" message that getDownloadingMessageOrDefault is tied to...?

}

fun setMediaPlaybackRequiresUserAction(view: RNCWebView, value: Boolean) {
view.settings.mediaPlaybackRequiresUserGesture = value
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ public void setMessagingEnabled(RNCWebView view, boolean value) {
mRNCWebViewManagerImpl.setMessagingEnabled(view, value);
}

@ReactProp(name = "blobDownloadingEnabled")
public void setBlobDownloadingEnabled(RNCWebView view, boolean value) {
mRNCWebViewManagerImpl.setBlobDownloadingEnabled(view, value);
}

@ReactProp(name = "menuItems")
public void setMenuCustomItems(RNCWebView view, @Nullable ReadableArray items) {
mRNCWebViewManagerImpl.setMenuCustomItems(view, items);
Expand Down
1 change: 1 addition & 0 deletions src/RNCWebViewNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export interface NativeProps extends ViewProps {
webviewDebuggingEnabled?: boolean;
mediaPlaybackRequiresUserAction?: boolean;
messagingEnabled: boolean;
blobDownloadingEnabled: boolean;
onLoadingError: DirectEventHandler<WebViewErrorEvent>;
onLoadingFinish: DirectEventHandler<WebViewNavigationEvent>;
onLoadingProgress: DirectEventHandler<WebViewNativeProgressEvent>;
Expand Down
5 changes: 3 additions & 2 deletions src/WebView.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const WebViewComponent = forwardRef<{}, AndroidWebViewProps>(({
allowFileAccess = false,
saveFormDataDisabled = false,
cacheEnabled = true,
blobDownloadingEnabled = false,
androidLayerType = "none",
originWhitelist = defaultOriginWhitelist,
setSupportMultipleWindows = true,
Expand Down Expand Up @@ -79,7 +80,7 @@ const WebViewComponent = forwardRef<{}, AndroidWebViewProps>(({
}
}, []);

const { onLoadingStart, onShouldStartLoadWithRequest, onMessage, viewState, setViewState, lastErrorEvent, onHttpError, onLoadingError, onLoadingFinish, onLoadingProgress, onRenderProcessGone } = useWebViewLogic({
const { onLoadingStart, onShouldStartLoadWithRequest, onMessage, viewState, setViewState, lastErrorEvent, onHttpError, onLoadingError, onLoadingFinish, onLoadingProgress, onRenderProcessGone } = useWebViewLogic({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { onLoadingStart, onShouldStartLoadWithRequest, onMessage, viewState, setViewState, lastErrorEvent, onHttpError, onLoadingError, onLoadingFinish, onLoadingProgress, onRenderProcessGone } = useWebViewLogic({
const { onLoadingStart, onShouldStartLoadWithRequest, onMessage, viewState, setViewState, lastErrorEvent, onHttpError, onLoadingError, onLoadingFinish, onLoadingProgress, onRenderProcessGone } = useWebViewLogic({

onNavigationStateChange,
onLoad,
onError,
Expand Down Expand Up @@ -175,7 +176,7 @@ const WebViewComponent = forwardRef<{}, AndroidWebViewProps>(({
{...otherProps}
messagingEnabled={typeof onMessageProp === 'function'}
messagingModuleName={messagingModuleName}

blobDownloadingEnabled={blobDownloadingEnabled}
hasOnScroll={!!otherProps.onScroll}
onLoadingError={onLoadingError}
onLoadingFinish={onLoadingFinish}
Expand Down
7 changes: 7 additions & 0 deletions src/WebViewTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,13 @@ export interface WebViewSharedProps extends ViewProps {
*/
javaScriptEnabled?: boolean;

/**
* Boolean value to enable downloading blob files in the `WebView`.
* default value is `false`.
* @platform android
*/
blobDownloadingEnabled?: boolean;

/**
* A Boolean value indicating whether JavaScript can open windows without user interaction.
* The default value is `false`.
Expand Down