Browse Source

feat(iOS): Add onFileDownload callback to help iOS apps implement file downloads.

`onFileDownload` is called with the URL that you can use to download the file.
When RNCWebView detects that the HTTP response should result in a file download,
`onFileDownload` is called. The client can then provide code to download
the file.

RNCWebView determines that a file download should take place if either of the
following is true:
1. The HTTP response contains a `Content-Disposition` header that is of type
  'attachment'
2. The MIME type of the response cannot be rendered by the iOS WebView
Tyler Coffman 4 years ago
parent
commit
17bd1e5356
9 changed files with 131 additions and 11 deletions
  1. 14
    3
      docs/Guide.md
  2. 14
    0
      example/App.tsx
  3. 55
    0
      example/examples/Downloads.tsx
  4. 2
    2
      example/ios/Podfile.lock
  5. 1
    1
      index.d.ts
  6. 22
    5
      ios/RNCWebView.m
  7. 1
    0
      ios/RNCWebViewManager.m
  8. 1
    0
      src/WebView.ios.tsx
  9. 21
    0
      src/WebViewTypes.ts

+ 14
- 3
docs/Guide.md View File

230
 
230
 
231
 ##### iOS
231
 ##### iOS
232
 
232
 
233
-For iOS, all you need to do is specify the permissions in your `ios/[project]/Info.plist` file:
233
+On iOS, you are going to have to supply your own code to download files. You can supply an `onFileDownload` callback
234
+to the WebView component as a prop. If RNCWebView determines that a file download needs to take place, the URL where you can download the file
235
+will be given to `onFileDownload`. From that callback you can then download that file however you would like to do so.
236
+
237
+Example:
238
+```javascript
239
+  onFileDownload = ({ nativeEvent }) => {
240
+    const { downloadUrl } = nativeEvent;
241
+    // --> Your download code goes here <--
242
+  }
243
+```
234
 
244
 
235
-Save to gallery:
245
+To be able to save images to the gallery you need to specify this permission in your `ios/[project]/Info.plist` file:
236
 
246
 
237
 ```
247
 ```
238
 <key>NSPhotoLibraryAddUsageDescription</key>
248
 <key>NSPhotoLibraryAddUsageDescription</key>
241
 
251
 
242
 ##### Android
252
 ##### Android
243
 
253
 
244
-Add permission in AndroidManifest.xml:
254
+On Android, integration with the DownloadManager is built-in. 
255
+All you have to do to support downloads is add these permissions in AndroidManifest.xml:
245
 
256
 
246
 ```xml
257
 ```xml
247
 <manifest ...>
258
 <manifest ...>

+ 14
- 0
example/App.tsx View File

13
 import Alerts from './examples/Alerts';
13
 import Alerts from './examples/Alerts';
14
 import Scrolling from './examples/Scrolling';
14
 import Scrolling from './examples/Scrolling';
15
 import Background from './examples/Background';
15
 import Background from './examples/Background';
16
+import Downloads from './examples/Downloads';
16
 import Uploads from './examples/Uploads';
17
 import Uploads from './examples/Uploads';
17
 
18
 
18
 const TESTS = {
19
 const TESTS = {
40
       return <Background />;
41
       return <Background />;
41
     },
42
     },
42
   },
43
   },
44
+  Downloads: {
45
+    title: 'Downloads',
46
+    testId: 'downloads',
47
+    description: 'File downloads test',
48
+    render() {
49
+      return <Downloads />;
50
+    },
51
+  },
43
   Uploads: {
52
   Uploads: {
44
     title: 'Uploads',
53
     title: 'Uploads',
45
     testId: 'uploads',
54
     testId: 'uploads',
101
             title="Background"
110
             title="Background"
102
             onPress={() => this._changeTest('Background')}
111
             onPress={() => this._changeTest('Background')}
103
           />
112
           />
113
+          {Platform.OS == "ios" && <Button
114
+            testID="testType_downloads"
115
+            title="Downloads"
116
+            onPress={() => this._changeTest('Downloads')}
117
+          />}
104
           {Platform.OS === 'android' && <Button
118
           {Platform.OS === 'android' && <Button
105
             testID="testType_uploads"
119
             testID="testType_uploads"
106
             title="Uploads"
120
             title="Uploads"

+ 55
- 0
example/examples/Downloads.tsx View File

1
+import React, {Component} from 'react';
2
+import {Alert, Platform, View} from 'react-native';
3
+
4
+import WebView, {FileDownload} from '../..';
5
+
6
+const HTML = `
7
+<!DOCTYPE html>\n
8
+<html>
9
+  <head>
10
+    <title>Downloads</title>
11
+    <meta http-equiv="content-type" content="text/html; charset=utf-8">
12
+    <meta name="viewport" content="width=320, user-scalable=no">
13
+    <style type="text/css">
14
+      body {
15
+        margin: 0;
16
+        padding: 0;
17
+        font: 62.5% arial, sans-serif;
18
+        background: #ccc;
19
+      }
20
+    </style>
21
+  </head>
22
+  <body>
23
+    <a href="https://www.7-zip.org/a/7za920.zip">Example zip file download</a>
24
+  </body>
25
+</html>
26
+`;
27
+
28
+type Props = {};
29
+type State = {};
30
+
31
+export default class Downloads extends Component<Props, State> {
32
+  state = {};
33
+
34
+  onFileDownload = ({ nativeEvent }: { nativeEvent: FileDownload } ) => {
35
+    Alert.alert("File download detected", nativeEvent.downloadUrl);
36
+  };
37
+ 
38
+  render() {
39
+    const platformProps = Platform.select({
40
+      ios: {
41
+        onFileDownload: this.onFileDownload,
42
+      },
43
+    });
44
+
45
+    return (
46
+      <View style={{ height: 120 }}>
47
+        <WebView
48
+          source={{html: HTML}}
49
+          automaticallyAdjustContentInsets={false}
50
+          {...platformProps}
51
+        />
52
+      </View>
53
+    );
54
+  }
55
+}

+ 2
- 2
example/ios/Podfile.lock View File

182
     - React-cxxreact (= 0.61.5)
182
     - React-cxxreact (= 0.61.5)
183
     - React-jsi (= 0.61.5)
183
     - React-jsi (= 0.61.5)
184
   - React-jsinspector (0.61.5)
184
   - React-jsinspector (0.61.5)
185
-  - react-native-webview (8.0.6):
185
+  - react-native-webview (8.1.0):
186
     - React
186
     - React
187
   - React-RCTActionSheet (0.61.5):
187
   - React-RCTActionSheet (0.61.5):
188
     - React-Core/RCTActionSheetHeaders (= 0.61.5)
188
     - React-Core/RCTActionSheetHeaders (= 0.61.5)
326
   React-jsi: cb2cd74d7ccf4cffb071a46833613edc79cdf8f7
326
   React-jsi: cb2cd74d7ccf4cffb071a46833613edc79cdf8f7
327
   React-jsiexecutor: d5525f9ed5f782fdbacb64b9b01a43a9323d2386
327
   React-jsiexecutor: d5525f9ed5f782fdbacb64b9b01a43a9323d2386
328
   React-jsinspector: fa0ecc501688c3c4c34f28834a76302233e29dc0
328
   React-jsinspector: fa0ecc501688c3c4c34f28834a76302233e29dc0
329
-  react-native-webview: 222d83c9c489e09b5d3541519110a637490ad4fa
329
+  react-native-webview: 88d83b4df2e792cfc36c952b1c496e2ac01cf0b8
330
   React-RCTActionSheet: 600b4d10e3aea0913b5a92256d2719c0cdd26d76
330
   React-RCTActionSheet: 600b4d10e3aea0913b5a92256d2719c0cdd26d76
331
   React-RCTAnimation: 791a87558389c80908ed06cc5dfc5e7920dfa360
331
   React-RCTAnimation: 791a87558389c80908ed06cc5dfc5e7920dfa360
332
   React-RCTBlob: d89293cc0236d9cb0933d85e430b0bbe81ad1d72
332
   React-RCTBlob: d89293cc0236d9cb0933d85e430b0bbe81ad1d72

+ 1
- 1
index.d.ts View File

2
 // eslint-disable-next-line
2
 // eslint-disable-next-line
3
 import { IOSWebViewProps, AndroidWebViewProps } from './lib/WebViewTypes';
3
 import { IOSWebViewProps, AndroidWebViewProps } from './lib/WebViewTypes';
4
 
4
 
5
-export { WebViewMessageEvent, WebViewNavigation } from "./lib/WebViewTypes";
5
+export { FileDownload, WebViewMessageEvent, WebViewNavigation } from "./lib/WebViewTypes";
6
 
6
 
7
 export type WebViewProps = IOSWebViewProps & AndroidWebViewProps;
7
 export type WebViewProps = IOSWebViewProps & AndroidWebViewProps;
8
 
8
 

+ 22
- 5
ios/RNCWebView.m View File

67
     UIScrollViewDelegate,
67
     UIScrollViewDelegate,
68
 #endif // !TARGET_OS_OSX
68
 #endif // !TARGET_OS_OSX
69
     RCTAutoInsetsProtocol>
69
     RCTAutoInsetsProtocol>
70
+
71
+@property (nonatomic, copy) RCTDirectEventBlock onFileDownload;
70
 @property (nonatomic, copy) RCTDirectEventBlock onLoadingStart;
72
 @property (nonatomic, copy) RCTDirectEventBlock onLoadingStart;
71
 @property (nonatomic, copy) RCTDirectEventBlock onLoadingFinish;
73
 @property (nonatomic, copy) RCTDirectEventBlock onLoadingFinish;
72
 @property (nonatomic, copy) RCTDirectEventBlock onLoadingError;
74
 @property (nonatomic, copy) RCTDirectEventBlock onLoadingError;
1061
   decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse
1063
   decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse
1062
                     decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler
1064
                     decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler
1063
 {
1065
 {
1066
+  WKNavigationResponsePolicy policy = WKNavigationResponsePolicyAllow;
1064
   if (_onHttpError && navigationResponse.forMainFrame) {
1067
   if (_onHttpError && navigationResponse.forMainFrame) {
1065
     if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) {
1068
     if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) {
1066
       NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response;
1069
       NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response;
1067
       NSInteger statusCode = response.statusCode;
1070
       NSInteger statusCode = response.statusCode;
1071
+      NSString *disposition = [response valueForHTTPHeaderField:@"Content-Disposition"];
1068
 
1072
 
1069
       if (statusCode >= 400) {
1073
       if (statusCode >= 400) {
1070
-        NSMutableDictionary<NSString *, id> *event = [self baseEvent];
1071
-        [event addEntriesFromDictionary: @{
1074
+        NSMutableDictionary<NSString *, id> *httpErrorEvent = [self baseEvent];
1075
+        [httpErrorEvent addEntriesFromDictionary: @{
1072
           @"url": response.URL.absoluteString,
1076
           @"url": response.URL.absoluteString,
1073
           @"statusCode": @(statusCode)
1077
           @"statusCode": @(statusCode)
1074
         }];
1078
         }];
1075
 
1079
 
1076
-        _onHttpError(event);
1080
+        _onHttpError(httpErrorEvent);
1081
+      }
1082
+
1083
+      BOOL isAttachment = disposition != nil && [disposition hasPrefix:@"attachment"];
1084
+      if (isAttachment || !navigationResponse.canShowMIMEType) {
1085
+        if (_onFileDownload) {
1086
+          policy = WKNavigationResponsePolicyCancel;
1087
+
1088
+          NSMutableDictionary<NSString *, id> *downloadEvent = [self baseEvent];
1089
+          [downloadEvent addEntriesFromDictionary: @{
1090
+            @"downloadUrl": (response.URL).absoluteString,
1091
+          }];
1092
+          _onFileDownload(downloadEvent);
1093
+        }
1077
       }
1094
       }
1078
     }
1095
     }
1079
-  }  
1096
+  }
1080
 
1097
 
1081
-  decisionHandler(WKNavigationResponsePolicyAllow);
1098
+  decisionHandler(policy);
1082
 }
1099
 }
1083
 
1100
 
1084
 /**
1101
 /**

+ 1
- 0
ios/RNCWebViewManager.m View File

34
 }
34
 }
35
 
35
 
36
 RCT_EXPORT_VIEW_PROPERTY(source, NSDictionary)
36
 RCT_EXPORT_VIEW_PROPERTY(source, NSDictionary)
37
+RCT_EXPORT_VIEW_PROPERTY(onFileDownload, RCTDirectEventBlock)
37
 RCT_EXPORT_VIEW_PROPERTY(onLoadingStart, RCTDirectEventBlock)
38
 RCT_EXPORT_VIEW_PROPERTY(onLoadingStart, RCTDirectEventBlock)
38
 RCT_EXPORT_VIEW_PROPERTY(onLoadingFinish, RCTDirectEventBlock)
39
 RCT_EXPORT_VIEW_PROPERTY(onLoadingFinish, RCTDirectEventBlock)
39
 RCT_EXPORT_VIEW_PROPERTY(onLoadingError, RCTDirectEventBlock)
40
 RCT_EXPORT_VIEW_PROPERTY(onLoadingError, RCTDirectEventBlock)

+ 1
- 0
src/WebView.ios.tsx View File

338
         onLoadingError={this.onLoadingError}
338
         onLoadingError={this.onLoadingError}
339
         onLoadingFinish={this.onLoadingFinish}
339
         onLoadingFinish={this.onLoadingFinish}
340
         onLoadingProgress={this.onLoadingProgress}
340
         onLoadingProgress={this.onLoadingProgress}
341
+        onFileDownload={this.props.onFileDownload}
341
         onLoadingStart={this.onLoadingStart}
342
         onLoadingStart={this.onLoadingStart}
342
         onHttpError={this.onHttpError}
343
         onHttpError={this.onHttpError}
343
         onMessage={this.onMessage}
344
         onMessage={this.onMessage}

+ 21
- 0
src/WebViewTypes.ts View File

104
   mainDocumentURL?: string;
104
   mainDocumentURL?: string;
105
 }
105
 }
106
 
106
 
107
+export interface FileDownload {
108
+  downloadUrl: string;
109
+}
110
+
107
 export type DecelerationRateConstant = 'normal' | 'fast';
111
 export type DecelerationRateConstant = 'normal' | 'fast';
108
 
112
 
109
 export interface WebViewMessage extends WebViewNativeEvent {
113
 export interface WebViewMessage extends WebViewNativeEvent {
132
 
136
 
133
 export type WebViewNavigationEvent = NativeSyntheticEvent<WebViewNavigation>;
137
 export type WebViewNavigationEvent = NativeSyntheticEvent<WebViewNavigation>;
134
 
138
 
139
+export type FileDownloadEvent = NativeSyntheticEvent<FileDownload>;
140
+
135
 export type WebViewMessageEvent = NativeSyntheticEvent<WebViewMessage>;
141
 export type WebViewMessageEvent = NativeSyntheticEvent<WebViewMessage>;
136
 
142
 
137
 export type WebViewErrorEvent = NativeSyntheticEvent<WebViewError>;
143
 export type WebViewErrorEvent = NativeSyntheticEvent<WebViewError>;
290
   scrollEnabled?: boolean;
296
   scrollEnabled?: boolean;
291
   useSharedProcessPool?: boolean;
297
   useSharedProcessPool?: boolean;
292
   onContentProcessDidTerminate?: (event: WebViewTerminatedEvent) => void;
298
   onContentProcessDidTerminate?: (event: WebViewTerminatedEvent) => void;
299
+  onFileDownload?: (event: FileDownloadEvent) => void;
293
 }
300
 }
294
 
301
 
295
 export interface MacOSNativeWebViewProps extends CommonNativeWebViewProps {
302
 export interface MacOSNativeWebViewProps extends CommonNativeWebViewProps {
482
    * @platform ios
489
    * @platform ios
483
    */
490
    */
484
   onContentProcessDidTerminate?: (event: WebViewTerminatedEvent) => void;
491
   onContentProcessDidTerminate?: (event: WebViewTerminatedEvent) => void;
492
+
493
+  /**
494
+   * Function that is invoked when the client needs to download a file.
495
+   * If the webview navigates to a URL that results in an HTTP response with a
496
+   * Content-Disposition header 'attachment...', then this will be called.
497
+   * If the MIME type indicates that the content is not renderable by the
498
+   * webview, that will also cause this to be called.
499
+   * 
500
+   * The application will need to provide its own code to actually download
501
+   * the file.
502
+   * 
503
+   * If not provided, the default is to let the webview try to render the file.
504
+   */
505
+  onFileDownload?: (event: FileDownloadEvent) => void;
485
 }
506
 }
486
 
507
 
487
 export interface MacOSWebViewProps extends WebViewSharedProps {
508
 export interface MacOSWebViewProps extends WebViewSharedProps {