feat(Android): Support Android file upload (#60)

Fixes #33 

I could really use some help from an Android developer on this one, because I just "made it work", don't know how to "make it work good".

Some things that should be reviewed:

- [ ] validate Android 5.0 devices (my emulator work, but outputs some weird sounds; a Galaxy 4 I tested on crashes)
- [ ] validate Android 5.1 devices (emulator works, couldn't find a real device)
- [ ] how to handle File Extensions? (https://www.w3schools.com/tags/att_input_accept.asp)

I'm sure that there's more refactoring to be done, so any help and advice would be appreciated.
This commit is contained in:
Andrei Pfeiffer 2018-11-21 12:46:43 +02:00 committed by Thibault Malbranche
parent f69942d70a
commit 752a5b295a
9 changed files with 438 additions and 9 deletions

View File

@ -1,2 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.reactnativecommunity.webview">
</manifest>
<application>
<provider
android:name=".RNCWebViewFileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>

View File

@ -0,0 +1,14 @@
package com.reactnativecommunity.webview;
import android.support.v4.content.FileProvider;
/**
* Providing a custom {@code FileProvider} prevents manifest {@code <provider>} name collisions.
*
* See https://developer.android.com/guide/topics/manifest/provider-element.html for details.
*/
public class RNCWebViewFileProvider extends FileProvider {
// This class intentionally left blank.
}

View File

@ -85,6 +85,7 @@ import org.json.JSONObject;
public class RNCWebViewManager extends SimpleViewManager<WebView> {
protected static final String REACT_CLASS = "RNCWebView";
private RNCWebViewPackage aPackage;
protected static final String HTML_ENCODING = "UTF-8";
protected static final String HTML_MIME_TYPE = "text/html";
@ -427,6 +428,25 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) {
callback.invoke(origin, true, false);
}
protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType) {
getModule().startPhotoPickerIntent(filePathCallback, acceptType);
}
protected void openFileChooser(ValueCallback<Uri> filePathCallback) {
getModule().startPhotoPickerIntent(filePathCallback, "");
}
protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType, String capture) {
getModule().startPhotoPickerIntent(filePathCallback, acceptType);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) {
String[] acceptTypes = fileChooserParams.getAcceptTypes();
boolean allowMultiple = fileChooserParams.getMode() == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE;
Intent intent = fileChooserParams.createIntent();
return getModule().startPhotoPickerIntent(filePathCallback, intent, acceptTypes, allowMultiple);
}
});
reactContext.addLifecycleEventListener(webView);
mWebViewConfig.configWebView(webView);
@ -747,4 +767,16 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher();
eventDispatcher.dispatchEvent(event);
}
public RNCWebViewPackage getPackage() {
return this.aPackage;
}
public void setPackage(RNCWebViewPackage aPackage) {
this.aPackage = aPackage;
}
public RNCWebViewModule getModule() {
return this.aPackage.getModule();
}
}

View File

@ -1,22 +1,309 @@
package com.reactnativecommunity.webview;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.Parcelable;
import android.provider.MediaStore;
import android.support.annotation.RequiresApi;
import android.support.v4.content.FileProvider;
import android.util.Log;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import com.facebook.react.bridge.ActivityEventListener;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Callback;
public class RNCWebViewModule extends ReactContextBaseJavaModule {
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import static android.app.Activity.RESULT_OK;
public class RNCWebViewModule extends ReactContextBaseJavaModule implements ActivityEventListener {
private final ReactApplicationContext reactContext;
private RNCWebViewPackage aPackage;
private static final int PICKER = 1;
private static final int PICKER_LEGACY = 3;
private ValueCallback<Uri> filePathCallbackLegacy;
private ValueCallback<Uri[]> filePathCallback;
private Uri outputFileUri;
final String DEFAULT_MIME_TYPES = "*/*";
public RNCWebViewModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
reactContext.addActivityEventListener(this);
}
@Override
public String getName() {
return "RNCWebView";
}
}
@ReactMethod
public void isFileUploadSupported(final Promise promise) {
Boolean result = false;
int current = Build.VERSION.SDK_INT;
if (current >= Build.VERSION_CODES.LOLLIPOP) {
result = true;
}
if (current >= Build.VERSION_CODES.JELLY_BEAN && current <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
result = true;
}
promise.resolve(result);
}
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
if (filePathCallback == null && filePathCallbackLegacy == null) {
return;
}
// based off of which button was pressed, we get an activity result and a file
// the camera activity doesn't properly return the filename* (I think?) so we use
// this filename instead
switch (requestCode) {
case PICKER:
if (resultCode != RESULT_OK) {
if (filePathCallback != null) {
filePathCallback.onReceiveValue(null);
}
} else {
Uri result[] = this.getSelectedFiles(data, resultCode);
if (result != null) {
filePathCallback.onReceiveValue(result);
} else {
filePathCallback.onReceiveValue(new Uri[] { outputFileUri });
}
}
break;
case PICKER_LEGACY:
Uri result = resultCode != Activity.RESULT_OK ? null : data == null ? outputFileUri : data.getData();
filePathCallbackLegacy.onReceiveValue(result);
break;
}
filePathCallback = null;
filePathCallbackLegacy= null;
outputFileUri = null;
}
public void onNewIntent(Intent intent) {
}
private Uri[] getSelectedFiles(Intent data, int resultCode) {
if (data == null) {
return null;
}
// we have one file selected
if (data.getData() != null) {
if (resultCode == RESULT_OK && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
return WebChromeClient.FileChooserParams.parseResult(resultCode, data);
} else {
return null;
}
}
// we have multiple files selected
if (data.getClipData() != null) {
final int numSelectedFiles = data.getClipData().getItemCount();
Uri[] result = new Uri[numSelectedFiles];
for (int i = 0; i < numSelectedFiles; i++) {
result[i] = data.getClipData().getItemAt(i).getUri();
}
return result;
}
return null;
}
public void startPhotoPickerIntent(ValueCallback<Uri> filePathCallback, String acceptType) {
filePathCallbackLegacy = filePathCallback;
Intent fileChooserIntent = getFileChooserIntent(acceptType);
Intent chooserIntent = Intent.createChooser(fileChooserIntent, "");
ArrayList<Parcelable> extraIntents = new ArrayList<>();
if (acceptsImages(acceptType)) {
extraIntents.add(getPhotoIntent());
}
if (acceptsVideo(acceptType)) {
extraIntents.add(getVideoIntent());
}
chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents.toArray(new Parcelable[]{}));
if (chooserIntent.resolveActivity(getCurrentActivity().getPackageManager()) != null) {
getCurrentActivity().startActivityForResult(chooserIntent, PICKER_LEGACY);
} else {
Log.w("RNCWebViewModule", "there is no Activity to handle this Intent");
}
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public boolean startPhotoPickerIntent(final ValueCallback<Uri[]> callback, final Intent intent, final String[] acceptTypes, final boolean allowMultiple) {
filePathCallback = callback;
ArrayList<Parcelable> extraIntents = new ArrayList<>();
if (acceptsImages(acceptTypes)) {
extraIntents.add(getPhotoIntent());
}
if (acceptsVideo(acceptTypes)) {
extraIntents.add(getVideoIntent());
}
Intent fileSelectionIntent = getFileChooserIntent(acceptTypes, allowMultiple);
Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
chooserIntent.putExtra(Intent.EXTRA_INTENT, fileSelectionIntent);
chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents.toArray(new Parcelable[]{}));
if (chooserIntent.resolveActivity(getCurrentActivity().getPackageManager()) != null) {
getCurrentActivity().startActivityForResult(chooserIntent, PICKER);
} else {
Log.w("RNCWebViewModule", "there is no Activity to handle this Intent");
}
return true;
}
public RNCWebViewPackage getPackage() {
return this.aPackage;
}
public void setPackage(RNCWebViewPackage aPackage) {
this.aPackage = aPackage;
}
private Intent getPhotoIntent() {
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
outputFileUri = getOutputUri(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, outputFileUri);
return intent;
}
private Intent getVideoIntent() {
Intent intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
// @todo from experience, for Videos we get the data onActivityResult
// so there's no need to store the Uri
Uri outputVideoUri = getOutputUri(MediaStore.ACTION_VIDEO_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, outputVideoUri);
return intent;
}
private Intent getFileChooserIntent(String acceptTypes) {
String _acceptTypes = acceptTypes;
if (acceptTypes.isEmpty()) {
_acceptTypes = DEFAULT_MIME_TYPES;
}
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(_acceptTypes);
return intent;
}
private Intent getFileChooserIntent(String[] acceptTypes, boolean allowMultiple) {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_MIME_TYPES, getAcceptedMimeType(acceptTypes));
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple);
return intent;
}
private Boolean acceptsImages(String types) {
return types.isEmpty() || types.toLowerCase().contains("image");
}
private Boolean acceptsImages(String[] types) {
return isArrayEmpty(types) || arrayContainsString(types, "image");
}
private Boolean acceptsVideo(String types) {
return types.isEmpty() || types.toLowerCase().contains("video");
}
private Boolean acceptsVideo(String[] types) {
return isArrayEmpty(types) || arrayContainsString(types, "video");
}
private Boolean arrayContainsString(String[] array, String pattern){
for(String content : array){
if(content.contains(pattern)){
return true;
}
}
return false;
}
private String[] getAcceptedMimeType(String[] types) {
if (isArrayEmpty(types)) {
return new String[]{DEFAULT_MIME_TYPES};
}
return types;
}
private Uri getOutputUri(String intentType) {
File capturedFile = null;
try {
capturedFile = getCapturedFile(intentType);
} catch (IOException e) {
Log.e("CREATE FILE", "Error occurred while creating the File", e);
e.printStackTrace();
}
// for versions below 6.0 (23) we use the old File creation & permissions model
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return Uri.fromFile(capturedFile);
}
// for versions 6.0+ (23) we use the FileProvider to avoid runtime permissions
String packageName = getReactApplicationContext().getPackageName();
return FileProvider.getUriForFile(getReactApplicationContext(), packageName+".fileprovider", capturedFile);
}
private File getCapturedFile(String intentType) throws IOException {
String prefix = "";
String suffix = "";
String dir = "";
String filename = "";
if (intentType.equals(MediaStore.ACTION_IMAGE_CAPTURE)) {
prefix = "image-";
suffix = ".jpg";
dir = Environment.DIRECTORY_PICTURES;
} else if (intentType.equals(MediaStore.ACTION_VIDEO_CAPTURE)) {
prefix = "video-";
suffix = ".mp4";
dir = Environment.DIRECTORY_MOVIES;
}
filename = prefix + String.valueOf(System.currentTimeMillis()) + suffix;
// for versions below 6.0 (23) we use the old File creation & permissions model
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// only this Directory works on all tested Android versions
// ctx.getExternalFilesDir(dir) was failing on Android 5.0 (sdk 21)
File storageDir = Environment.getExternalStoragePublicDirectory(dir);
return new File(storageDir, filename);
}
File storageDir = getReactApplicationContext().getExternalFilesDir(null);
return File.createTempFile(filename, suffix, storageDir);
}
private Boolean isArrayEmpty(String[] arr) {
// when our array returned from getAcceptTypes() has no values set from the webview
// i.e. <input type="file" />, without any "accept" attr
// will be an array with one empty string element, afaik
return arr.length == 0 || (arr.length == 1 && arr[0].length() == 0);
}
}

View File

@ -1,6 +1,7 @@
package com.reactnativecommunity.webview;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@ -10,10 +11,19 @@ import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import com.facebook.react.bridge.JavaScriptModule;
public class RNCWebViewPackage implements ReactPackage {
private RNCWebViewManager manager;
private RNCWebViewModule module;
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
return Arrays.<NativeModule>asList(new RNCWebViewModule(reactContext));
List<NativeModule> modulesList = new ArrayList<>();
module = new RNCWebViewModule(reactContext);
module.setPackage(this);
modulesList.add(module);
return modulesList;
}
// Deprecated from RN 0.47
@ -23,7 +33,12 @@ public class RNCWebViewPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
RNCWebViewManager viewManager = new RNCWebViewManager();
return Arrays.<ViewManager>asList(viewManager);
manager = new RNCWebViewManager();
manager.setPackage(this);
return Arrays.<ViewManager>asList(manager);
}
}
public RNCWebViewModule getModule() {
return module;
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="shared" path="." />
</paths>

View File

@ -50,3 +50,58 @@ class MyWeb extends Component {
}
```
### Add support for File Upload
##### iOS
For iOS, all you need to do is specify the permissions in your `ios/[project]/Info.plist` file:
Photo capture:
```
<key>NSCameraUsageDescription</key>
<string>Take pictures for certain activities</string>
```
Gallery selection:
```
<key>NSPhotoLibraryUsageDescription</key>
<string>Select pictures for certain activities</string>
```
Video recording:
```
<key>NSMicrophoneUsageDescription</key>
<string>Need microphone access for recording videos</string>
```
##### Android
Add permission in AndroidManifest.xml:
```xml
<manifest ...>
......
<!-- this is required only for Android 4.1-5.1 (api 16-22) -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
......
</manifest>
```
##### Check for File Upload support, with `static isFileUploadSupported()`
File Upload using `<input type="file" />` is not supported for Android 4.4 KitKat (see [details](https://github.com/delight-im/Android-AdvancedWebView/issues/4#issuecomment-70372146)):
```
import { WebView } from "react-native-webview";
WebView.isFileUploadSupported().then(res => {
if (res === true) {
// file upload is supported
} else {
// not file upload support
}
});
```

View File

@ -19,7 +19,8 @@ import {
UIManager,
View,
Image,
requireNativeComponent
requireNativeComponent,
NativeModules
} from 'react-native';
import invariant from 'fbjs/lib/invariant';
@ -71,6 +72,11 @@ class WebView extends React.Component<WebViewSharedProps, State> {
originWhitelist: WebViewShared.defaultOriginWhitelist,
};
static isFileUploadSupported = async () => {
// native implementation should return "true" only for Android 5+
return NativeModules.RNCWebView.isFileUploadSupported();
}
state = {
viewState: this.props.startInLoadingState ? WebViewState.LOADING : WebViewState.IDLE,
lastErrorEvent: null,

View File

@ -133,6 +133,11 @@ class WebView extends React.Component<WebViewSharedProps, State> {
originWhitelist: WebViewShared.defaultOriginWhitelist,
};
static isFileUploadSupported = async () => {
// no native implementation for iOS, depends only on permissions
return true;
}
state = {
viewState: this.props.startInLoadingState
? WebViewState.LOADING