Browse Source

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.
Andrei Pfeiffer 6 years ago
parent
commit
752a5b295a

+ 12
- 1
android/src/main/AndroidManifest.xml View File

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

+ 14
- 0
android/src/main/java/com/reactnativecommunity/webview/RNCWebViewFileProvider.java View File

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

+ 32
- 0
android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java View File

@@ -85,6 +85,7 @@ import org.json.JSONObject;
85 85
 public class RNCWebViewManager extends SimpleViewManager<WebView> {
86 86
 
87 87
   protected static final String REACT_CLASS = "RNCWebView";
88
+  private RNCWebViewPackage aPackage;
88 89
 
89 90
   protected static final String HTML_ENCODING = "UTF-8";
90 91
   protected static final String HTML_MIME_TYPE = "text/html";
@@ -427,6 +428,25 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
427 428
       public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) {
428 429
         callback.invoke(origin, true, false);
429 430
       }
431
+
432
+      protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType) {
433
+        getModule().startPhotoPickerIntent(filePathCallback, acceptType);
434
+      }
435
+      protected void openFileChooser(ValueCallback<Uri> filePathCallback) {
436
+        getModule().startPhotoPickerIntent(filePathCallback, "");
437
+      }
438
+      protected void openFileChooser(ValueCallback<Uri> filePathCallback, String acceptType, String capture) {
439
+        getModule().startPhotoPickerIntent(filePathCallback, acceptType);
440
+      }
441
+
442
+      @TargetApi(Build.VERSION_CODES.LOLLIPOP)
443
+      @Override
444
+      public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) {
445
+        String[] acceptTypes = fileChooserParams.getAcceptTypes();
446
+        boolean allowMultiple = fileChooserParams.getMode() == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE;
447
+        Intent intent = fileChooserParams.createIntent();
448
+        return getModule().startPhotoPickerIntent(filePathCallback, intent, acceptTypes, allowMultiple);
449
+      }
430 450
     });
431 451
     reactContext.addLifecycleEventListener(webView);
432 452
     mWebViewConfig.configWebView(webView);
@@ -747,4 +767,16 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
747 767
       reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher();
748 768
     eventDispatcher.dispatchEvent(event);
749 769
   }
770
+
771
+  public RNCWebViewPackage getPackage() {
772
+    return this.aPackage;
773
+  }
774
+
775
+  public void setPackage(RNCWebViewPackage aPackage) {
776
+    this.aPackage = aPackage;
777
+  }
778
+
779
+  public RNCWebViewModule getModule() {
780
+    return this.aPackage.getModule();
781
+  }
750 782
 }

+ 290
- 3
android/src/main/java/com/reactnativecommunity/webview/RNCWebViewModule.java View File

@@ -1,22 +1,309 @@
1 1
 
2 2
 package com.reactnativecommunity.webview;
3 3
 
4
+import android.app.Activity;
5
+import android.content.Intent;
6
+import android.net.Uri;
7
+import android.os.Build;
8
+import android.os.Environment;
9
+import android.os.Parcelable;
10
+import android.provider.MediaStore;
11
+import android.support.annotation.RequiresApi;
12
+import android.support.v4.content.FileProvider;
13
+import android.util.Log;
14
+import android.webkit.ValueCallback;
15
+import android.webkit.WebChromeClient;
16
+
17
+import com.facebook.react.bridge.ActivityEventListener;
18
+import com.facebook.react.bridge.Promise;
4 19
 import com.facebook.react.bridge.ReactApplicationContext;
5 20
 import com.facebook.react.bridge.ReactContextBaseJavaModule;
6 21
 import com.facebook.react.bridge.ReactMethod;
7
-import com.facebook.react.bridge.Callback;
8 22
 
9
-public class RNCWebViewModule extends ReactContextBaseJavaModule {
23
+import java.io.File;
24
+import java.io.IOException;
25
+import java.util.ArrayList;
26
+
27
+import static android.app.Activity.RESULT_OK;
28
+
29
+public class RNCWebViewModule extends ReactContextBaseJavaModule implements ActivityEventListener {
10 30
 
11 31
   private final ReactApplicationContext reactContext;
32
+  private RNCWebViewPackage aPackage;
33
+
34
+  private static final int PICKER = 1;
35
+  private static final int PICKER_LEGACY = 3;
36
+
37
+  private ValueCallback<Uri> filePathCallbackLegacy;
38
+  private ValueCallback<Uri[]> filePathCallback;
39
+  private Uri outputFileUri;
40
+
41
+  final String DEFAULT_MIME_TYPES = "*/*";
12 42
 
13 43
   public RNCWebViewModule(ReactApplicationContext reactContext) {
14 44
     super(reactContext);
15 45
     this.reactContext = reactContext;
46
+    reactContext.addActivityEventListener(this);
16 47
   }
17 48
 
18 49
   @Override
19 50
   public String getName() {
20 51
     return "RNCWebView";
21 52
   }
22
-}
53
+
54
+  @ReactMethod
55
+  public void isFileUploadSupported(final Promise promise) {
56
+      Boolean result = false;
57
+      int current = Build.VERSION.SDK_INT;
58
+      if (current >= Build.VERSION_CODES.LOLLIPOP) {
59
+          result = true;
60
+      }
61
+      if (current >= Build.VERSION_CODES.JELLY_BEAN && current <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
62
+          result = true;
63
+      }
64
+      promise.resolve(result);
65
+  }
66
+
67
+  public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
68
+
69
+    if (filePathCallback == null && filePathCallbackLegacy == null) {
70
+        return;
71
+    }
72
+
73
+    // based off of which button was pressed, we get an activity result and a file
74
+    // the camera activity doesn't properly return the filename* (I think?) so we use
75
+    // this filename instead
76
+    switch (requestCode) {
77
+    case PICKER:
78
+        if (resultCode != RESULT_OK) {
79
+            if (filePathCallback != null) {
80
+                filePathCallback.onReceiveValue(null);
81
+            }
82
+        } else {
83
+            Uri result[] = this.getSelectedFiles(data, resultCode);
84
+            if (result != null) {
85
+                filePathCallback.onReceiveValue(result);
86
+            } else {
87
+                filePathCallback.onReceiveValue(new Uri[] { outputFileUri });
88
+            }
89
+        }
90
+        break;
91
+    case PICKER_LEGACY:
92
+        Uri result = resultCode != Activity.RESULT_OK ? null : data == null ? outputFileUri : data.getData();
93
+        filePathCallbackLegacy.onReceiveValue(result);
94
+        break;
95
+
96
+    }
97
+    filePathCallback = null;
98
+    filePathCallbackLegacy= null;
99
+    outputFileUri = null;
100
+  }
101
+
102
+  public void onNewIntent(Intent intent) {
103
+  }
104
+
105
+  private Uri[] getSelectedFiles(Intent data, int resultCode) {
106
+    if (data == null) {
107
+        return null;
108
+    }
109
+
110
+    // we have one file selected
111
+    if (data.getData() != null) {
112
+        if (resultCode == RESULT_OK && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
113
+            return WebChromeClient.FileChooserParams.parseResult(resultCode, data);
114
+        } else {
115
+            return null;
116
+        }
117
+    }
118
+
119
+    // we have multiple files selected
120
+    if (data.getClipData() != null) {
121
+        final int numSelectedFiles = data.getClipData().getItemCount();
122
+        Uri[] result = new Uri[numSelectedFiles];
123
+        for (int i = 0; i < numSelectedFiles; i++) {
124
+            result[i] = data.getClipData().getItemAt(i).getUri();
125
+        }
126
+        return result;
127
+    }
128
+    return null;
129
+  }
130
+
131
+  public void startPhotoPickerIntent(ValueCallback<Uri> filePathCallback, String acceptType) {
132
+      filePathCallbackLegacy = filePathCallback;
133
+
134
+      Intent fileChooserIntent = getFileChooserIntent(acceptType);
135
+      Intent chooserIntent = Intent.createChooser(fileChooserIntent, "");
136
+
137
+      ArrayList<Parcelable> extraIntents = new ArrayList<>();
138
+      if (acceptsImages(acceptType)) {
139
+          extraIntents.add(getPhotoIntent());
140
+      }
141
+      if (acceptsVideo(acceptType)) {
142
+          extraIntents.add(getVideoIntent());
143
+      }
144
+      chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents.toArray(new Parcelable[]{}));
145
+
146
+      if (chooserIntent.resolveActivity(getCurrentActivity().getPackageManager()) != null) {
147
+          getCurrentActivity().startActivityForResult(chooserIntent, PICKER_LEGACY);
148
+      } else {
149
+          Log.w("RNCWebViewModule", "there is no Activity to handle this Intent");
150
+      }
151
+  }
152
+
153
+  @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
154
+  public boolean startPhotoPickerIntent(final ValueCallback<Uri[]> callback, final Intent intent, final String[] acceptTypes, final boolean allowMultiple) {
155
+    filePathCallback = callback;
156
+
157
+    ArrayList<Parcelable> extraIntents = new ArrayList<>();
158
+    if (acceptsImages(acceptTypes)) {
159
+      extraIntents.add(getPhotoIntent());
160
+    }
161
+    if (acceptsVideo(acceptTypes)) {
162
+      extraIntents.add(getVideoIntent());
163
+    }
164
+
165
+    Intent fileSelectionIntent = getFileChooserIntent(acceptTypes, allowMultiple);
166
+
167
+    Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
168
+    chooserIntent.putExtra(Intent.EXTRA_INTENT, fileSelectionIntent);
169
+    chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents.toArray(new Parcelable[]{}));
170
+
171
+    if (chooserIntent.resolveActivity(getCurrentActivity().getPackageManager()) != null) {
172
+        getCurrentActivity().startActivityForResult(chooserIntent, PICKER);
173
+    } else {
174
+        Log.w("RNCWebViewModule", "there is no Activity to handle this Intent");
175
+    }
176
+
177
+    return true;
178
+  }
179
+
180
+  public RNCWebViewPackage getPackage() {
181
+    return this.aPackage;
182
+  }
183
+
184
+  public void setPackage(RNCWebViewPackage aPackage) {
185
+    this.aPackage = aPackage;
186
+  }
187
+
188
+  private Intent getPhotoIntent() {
189
+    Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
190
+    outputFileUri = getOutputUri(MediaStore.ACTION_IMAGE_CAPTURE);
191
+    intent.putExtra(MediaStore.EXTRA_OUTPUT, outputFileUri);
192
+    return intent;
193
+  }
194
+
195
+  private Intent getVideoIntent() {
196
+    Intent intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
197
+    // @todo from experience, for Videos we get the data onActivityResult
198
+    // so there's no need to store the Uri
199
+    Uri outputVideoUri = getOutputUri(MediaStore.ACTION_VIDEO_CAPTURE);
200
+    intent.putExtra(MediaStore.EXTRA_OUTPUT, outputVideoUri);
201
+    return intent;
202
+  }
203
+
204
+  private Intent getFileChooserIntent(String acceptTypes) {
205
+    String _acceptTypes = acceptTypes;
206
+    if (acceptTypes.isEmpty()) {
207
+      _acceptTypes = DEFAULT_MIME_TYPES;
208
+    }
209
+    Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
210
+    intent.addCategory(Intent.CATEGORY_OPENABLE);
211
+    intent.setType(_acceptTypes);
212
+    return intent;
213
+  }
214
+
215
+  private Intent getFileChooserIntent(String[] acceptTypes, boolean allowMultiple) {
216
+    Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
217
+    intent.addCategory(Intent.CATEGORY_OPENABLE);
218
+    intent.setType("*/*");
219
+    intent.putExtra(Intent.EXTRA_MIME_TYPES, getAcceptedMimeType(acceptTypes));
220
+    intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple);
221
+    return intent;
222
+  }
223
+
224
+  private Boolean acceptsImages(String types) {
225
+    return types.isEmpty() || types.toLowerCase().contains("image");
226
+  }
227
+  private Boolean acceptsImages(String[] types) {
228
+    return isArrayEmpty(types) || arrayContainsString(types, "image");
229
+  }
230
+
231
+  private Boolean acceptsVideo(String types) {
232
+    return types.isEmpty() || types.toLowerCase().contains("video");
233
+  }
234
+  private Boolean acceptsVideo(String[] types) {
235
+    return isArrayEmpty(types) || arrayContainsString(types, "video");
236
+  }
237
+
238
+  private Boolean arrayContainsString(String[] array, String pattern){
239
+    for(String content : array){
240
+        if(content.contains(pattern)){
241
+            return true;
242
+        }
243
+    }
244
+    return false;
245
+  }
246
+
247
+  private String[] getAcceptedMimeType(String[] types) {
248
+    if (isArrayEmpty(types)) {
249
+        return new String[]{DEFAULT_MIME_TYPES};
250
+    }
251
+    return types;
252
+  }
253
+
254
+  private Uri getOutputUri(String intentType) {
255
+    File capturedFile = null;
256
+    try {
257
+        capturedFile = getCapturedFile(intentType);
258
+    } catch (IOException e) {
259
+        Log.e("CREATE FILE", "Error occurred while creating the File", e);
260
+        e.printStackTrace();
261
+    }
262
+
263
+    // for versions below 6.0 (23) we use the old File creation & permissions model
264
+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
265
+        return Uri.fromFile(capturedFile);
266
+    }
267
+
268
+    // for versions 6.0+ (23) we use the FileProvider to avoid runtime permissions
269
+    String packageName = getReactApplicationContext().getPackageName();
270
+    return FileProvider.getUriForFile(getReactApplicationContext(), packageName+".fileprovider", capturedFile);
271
+  }
272
+
273
+  private File getCapturedFile(String intentType) throws IOException {
274
+    String prefix = "";
275
+    String suffix = "";
276
+    String dir = "";
277
+    String filename = "";
278
+
279
+    if (intentType.equals(MediaStore.ACTION_IMAGE_CAPTURE)) {
280
+      prefix = "image-";
281
+      suffix = ".jpg";
282
+      dir = Environment.DIRECTORY_PICTURES;
283
+    } else if (intentType.equals(MediaStore.ACTION_VIDEO_CAPTURE)) {
284
+      prefix = "video-";
285
+      suffix = ".mp4";
286
+      dir = Environment.DIRECTORY_MOVIES;
287
+    }
288
+
289
+    filename = prefix + String.valueOf(System.currentTimeMillis()) + suffix;
290
+
291
+    // for versions below 6.0 (23) we use the old File creation & permissions model
292
+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
293
+        // only this Directory works on all tested Android versions
294
+        // ctx.getExternalFilesDir(dir) was failing on Android 5.0 (sdk 21)
295
+        File storageDir = Environment.getExternalStoragePublicDirectory(dir);
296
+        return new File(storageDir, filename);
297
+    }
298
+
299
+    File storageDir = getReactApplicationContext().getExternalFilesDir(null);
300
+    return File.createTempFile(filename, suffix, storageDir);
301
+  }
302
+
303
+  private Boolean isArrayEmpty(String[] arr) {
304
+    // when our array returned from getAcceptTypes() has no values set from the webview
305
+    // i.e. <input type="file" />, without any "accept" attr
306
+    // will be an array with one empty string element, afaik
307
+    return arr.length == 0 || (arr.length == 1 && arr[0].length() == 0);
308
+  }
309
+}

+ 19
- 4
android/src/main/java/com/reactnativecommunity/webview/RNCWebViewPackage.java View File

@@ -1,6 +1,7 @@
1 1
 
2 2
 package com.reactnativecommunity.webview;
3 3
 
4
+import java.util.ArrayList;
4 5
 import java.util.Arrays;
5 6
 import java.util.Collections;
6 7
 import java.util.List;
@@ -10,10 +11,19 @@ import com.facebook.react.bridge.NativeModule;
10 11
 import com.facebook.react.bridge.ReactApplicationContext;
11 12
 import com.facebook.react.uimanager.ViewManager;
12 13
 import com.facebook.react.bridge.JavaScriptModule;
14
+
13 15
 public class RNCWebViewPackage implements ReactPackage {
16
+
17
+    private RNCWebViewManager manager;
18
+    private RNCWebViewModule module;
19
+
14 20
     @Override
15 21
     public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
16
-      return Arrays.<NativeModule>asList(new RNCWebViewModule(reactContext));
22
+      List<NativeModule> modulesList = new ArrayList<>();
23
+      module = new RNCWebViewModule(reactContext);
24
+      module.setPackage(this);
25
+      modulesList.add(module);
26
+      return modulesList;
17 27
     }
18 28
 
19 29
     // Deprecated from RN 0.47
@@ -23,7 +33,12 @@ public class RNCWebViewPackage implements ReactPackage {
23 33
 
24 34
     @Override
25 35
     public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
26
-        RNCWebViewManager viewManager = new RNCWebViewManager();
27
-        return Arrays.<ViewManager>asList(viewManager);
36
+      manager = new RNCWebViewManager();
37
+      manager.setPackage(this);
38
+      return Arrays.<ViewManager>asList(manager);
39
+    }
40
+
41
+    public RNCWebViewModule getModule() {
42
+      return module;
28 43
     }
29
-}
44
+}

+ 4
- 0
android/src/main/res/xml/file_provider_paths.xml View File

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

+ 55
- 0
docs/Guide.md View File

@@ -50,3 +50,58 @@ class MyWeb extends Component {
50 50
 }
51 51
 ```
52 52
 
53
+### Add support for File Upload
54
+
55
+##### iOS
56
+
57
+For iOS, all you need to do is specify the permissions in your `ios/[project]/Info.plist` file:
58
+
59
+Photo capture:
60
+```
61
+<key>NSCameraUsageDescription</key>
62
+<string>Take pictures for certain activities</string>
63
+```
64
+
65
+Gallery selection:
66
+```
67
+<key>NSPhotoLibraryUsageDescription</key>
68
+<string>Select pictures for certain activities</string>
69
+```
70
+
71
+Video recording:
72
+```
73
+<key>NSMicrophoneUsageDescription</key>
74
+<string>Need microphone access for recording videos</string>
75
+```
76
+
77
+##### Android
78
+
79
+Add permission in AndroidManifest.xml:
80
+```xml
81
+<manifest ...>
82
+  ......
83
+
84
+  <!-- this is required only for Android 4.1-5.1 (api 16-22)  -->
85
+  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
86
+
87
+  ......
88
+</manifest>
89
+```
90
+
91
+##### Check for File Upload support, with `static isFileUploadSupported()`
92
+
93
+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)):
94
+
95
+```
96
+import { WebView } from "react-native-webview";
97
+
98
+WebView.isFileUploadSupported().then(res => {
99
+  if (res === true) {
100
+    // file upload is supported
101
+  } else {
102
+    // not file upload support
103
+  }
104
+});
105
+
106
+```
107
+

+ 7
- 1
js/WebView.android.js View File

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

+ 5
- 0
js/WebView.ios.js View File

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