diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 73590d2..2d72d6f 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,2 +1,13 @@ - \ No newline at end of file + + + + + + diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewFileProvider.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewFileProvider.java new file mode 100644 index 0000000..0d7773d --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewFileProvider.java @@ -0,0 +1,14 @@ +package com.reactnativecommunity.webview; + +import android.support.v4.content.FileProvider; + +/** + * Providing a custom {@code FileProvider} prevents manifest {@code } 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. + +} diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java index 3a7ae87..6e5cdd4 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java @@ -85,6 +85,7 @@ import org.json.JSONObject; public class RNCWebViewManager extends SimpleViewManager { 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 { public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { callback.invoke(origin, true, false); } + + protected void openFileChooser(ValueCallback filePathCallback, String acceptType) { + getModule().startPhotoPickerIntent(filePathCallback, acceptType); + } + protected void openFileChooser(ValueCallback filePathCallback) { + getModule().startPhotoPickerIntent(filePathCallback, ""); + } + protected void openFileChooser(ValueCallback filePathCallback, String acceptType, String capture) { + getModule().startPhotoPickerIntent(filePathCallback, acceptType); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public boolean onShowFileChooser(WebView webView, ValueCallback 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 { 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(); + } } diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewModule.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewModule.java index b93d992..2590d5b 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewModule.java +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewModule.java @@ -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 filePathCallbackLegacy; + private ValueCallback 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"; } -} \ No newline at end of file + + @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 filePathCallback, String acceptType) { + filePathCallbackLegacy = filePathCallback; + + Intent fileChooserIntent = getFileChooserIntent(acceptType); + Intent chooserIntent = Intent.createChooser(fileChooserIntent, ""); + + ArrayList 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 callback, final Intent intent, final String[] acceptTypes, final boolean allowMultiple) { + filePathCallback = callback; + + ArrayList 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. , 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); + } +} diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewPackage.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewPackage.java index 60ee9f6..f8817c0 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewPackage.java +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewPackage.java @@ -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 createNativeModules(ReactApplicationContext reactContext) { - return Arrays.asList(new RNCWebViewModule(reactContext)); + List 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 createViewManagers(ReactApplicationContext reactContext) { - RNCWebViewManager viewManager = new RNCWebViewManager(); - return Arrays.asList(viewManager); + manager = new RNCWebViewManager(); + manager.setPackage(this); + return Arrays.asList(manager); } -} \ No newline at end of file + + public RNCWebViewModule getModule() { + return module; + } +} diff --git a/android/src/main/res/xml/file_provider_paths.xml b/android/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 0000000..d04c4ca --- /dev/null +++ b/android/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/docs/Guide.md b/docs/Guide.md index b9412e8..9f36585 100644 --- a/docs/Guide.md +++ b/docs/Guide.md @@ -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: +``` +NSCameraUsageDescription +Take pictures for certain activities +``` + +Gallery selection: +``` +NSPhotoLibraryUsageDescription +Select pictures for certain activities +``` + +Video recording: +``` +NSMicrophoneUsageDescription +Need microphone access for recording videos +``` + +##### Android + +Add permission in AndroidManifest.xml: +```xml + + ...... + + + + + ...... + +``` + +##### Check for File Upload support, with `static isFileUploadSupported()` + +File Upload using `` 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 + } +}); + +``` + diff --git a/js/WebView.android.js b/js/WebView.android.js index 1dc7743..718b16c 100644 --- a/js/WebView.android.js +++ b/js/WebView.android.js @@ -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 { 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, diff --git a/js/WebView.ios.js b/js/WebView.ios.js index ae679dc..e442344 100644 --- a/js/WebView.ios.js +++ b/js/WebView.ios.js @@ -133,6 +133,11 @@ class WebView extends React.Component { originWhitelist: WebViewShared.defaultOriginWhitelist, }; + static isFileUploadSupported = async () => { + // no native implementation for iOS, depends only on permissions + return true; + } + state = { viewState: this.props.startInLoadingState ? WebViewState.LOADING