Browse Source

feat(iOS): Add onFileDownload callback (#1214)

`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
trcoffman 4 years ago
parent
commit
a6010d93e0
No account linked to committer's email address
11 changed files with 176 additions and 22 deletions
  1. 25
    5
      apple/RNCWebView.m
  2. 1
    0
      apple/RNCWebViewManager.m
  3. 18
    3
      docs/Guide.md
  4. 33
    0
      docs/Reference.md
  5. 14
    0
      example/App.tsx
  6. 55
    0
      example/examples/Downloads.tsx
  7. 3
    3
      example/ios/Podfile.lock
  8. 1
    1
      index.d.ts
  9. 1
    0
      src/WebView.ios.tsx
  10. 25
    0
      src/WebViewTypes.ts
  11. 0
    10
      yarn.lock

+ 25
- 5
apple/RNCWebView.m View File

@@ -67,6 +67,8 @@ static NSDictionary* customCertificatesForHost;
67 67
     UIScrollViewDelegate,
68 68
 #endif // !TARGET_OS_OSX
69 69
     RCTAutoInsetsProtocol>
70
+
71
+@property (nonatomic, copy) RCTDirectEventBlock onFileDownload;
70 72
 @property (nonatomic, copy) RCTDirectEventBlock onLoadingStart;
71 73
 @property (nonatomic, copy) RCTDirectEventBlock onLoadingFinish;
72 74
 @property (nonatomic, copy) RCTDirectEventBlock onLoadingError;
@@ -973,24 +975,42 @@ static NSDictionary* customCertificatesForHost;
973 975
   decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse
974 976
                     decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler
975 977
 {
978
+  WKNavigationResponsePolicy policy = WKNavigationResponsePolicyAllow;
976 979
   if (_onHttpError && navigationResponse.forMainFrame) {
977 980
     if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) {
978 981
       NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response;
979 982
       NSInteger statusCode = response.statusCode;
980 983
 
981 984
       if (statusCode >= 400) {
982
-        NSMutableDictionary<NSString *, id> *event = [self baseEvent];
983
-        [event addEntriesFromDictionary: @{
985
+        NSMutableDictionary<NSString *, id> *httpErrorEvent = [self baseEvent];
986
+        [httpErrorEvent addEntriesFromDictionary: @{
984 987
           @"url": response.URL.absoluteString,
985 988
           @"statusCode": @(statusCode)
986 989
         }];
987 990
 
988
-        _onHttpError(event);
991
+        _onHttpError(httpErrorEvent);
992
+      }
993
+
994
+      NSString *disposition = nil;
995
+      if (@available(iOS 13, *)) {
996
+        disposition = [response valueForHTTPHeaderField:@"Content-Disposition"];
997
+      }
998
+      BOOL isAttachment = disposition != nil && [disposition hasPrefix:@"attachment"];
999
+      if (isAttachment || !navigationResponse.canShowMIMEType) {
1000
+        if (_onFileDownload) {
1001
+          policy = WKNavigationResponsePolicyCancel;
1002
+
1003
+          NSMutableDictionary<NSString *, id> *downloadEvent = [self baseEvent];
1004
+          [downloadEvent addEntriesFromDictionary: @{
1005
+            @"downloadUrl": (response.URL).absoluteString,
1006
+          }];
1007
+          _onFileDownload(downloadEvent);
1008
+        }
989 1009
       }
990 1010
     }
991
-  }  
1011
+  }
992 1012
 
993
-  decisionHandler(WKNavigationResponsePolicyAllow);
1013
+  decisionHandler(policy);
994 1014
 }
995 1015
 
996 1016
 /**

+ 1
- 0
apple/RNCWebViewManager.m View File

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

+ 18
- 3
docs/Guide.md View File

@@ -226,9 +226,23 @@ You can control **single** or **multiple** file selection by specifing the [`mul
226 226
 
227 227
 ##### iOS
228 228
 
229
-For iOS, all you need to do is specify the permissions in your `ios/[project]/Info.plist` file:
229
+On iOS, you are going to have to supply your own code to download files. You can supply an `onFileDownload` callback
230
+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
231
+will be given to `onFileDownload`. From that callback you can then download that file however you would like to do so.
232
+
233
+NOTE: iOS 13+ is needed for the best possible download experience. On iOS 13 Apple added an API for accessing HTTP response headers, which
234
+is used to determine if an HTTP response should be a download. On iOS 12 or older, only MIME types that cannot be rendered by the webview will
235
+trigger calls to `onFileDownload`.
236
+
237
+Example:
238
+```javascript
239
+  onFileDownload = ({ nativeEvent }) => {
240
+    const { downloadUrl } = nativeEvent;
241
+    // --> Your download code goes here <--
242
+  }
243
+```
230 244
 
231
-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:
232 246
 
233 247
 ```
234 248
 <key>NSPhotoLibraryAddUsageDescription</key>
@@ -237,7 +251,8 @@ Save to gallery:
237 251
 
238 252
 ##### Android
239 253
 
240
-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:
241 256
 
242 257
 ```xml
243 258
 <manifest ...>

+ 33
- 0
docs/Reference.md View File

@@ -65,6 +65,7 @@ This document lays out the current public properties and methods for the React N
65 65
 - [`sharedCookiesEnabled`](Reference.md#sharedCookiesEnabled)
66 66
 - [`textZoom`](Reference.md#textZoom)
67 67
 - [`ignoreSilentHardwareSwitch`](Reference.md#ignoreSilentHardwareSwitch)
68
+- [`onFileDownload`](Reference.md#onFileDownload)
68 69
 
69 70
 ## Methods Index
70 71
 
@@ -1138,6 +1139,38 @@ When set to true the hardware silent switch is ignored. Default: `false`
1138 1139
 | ------- | -------- | -------- |
1139 1140
 | boolean | No       | iOS      |
1140 1141
 
1142
+### `onFileDownload`
1143
+This property is iOS-only.
1144
+
1145
+Function that is invoked when the client needs to download a file.
1146
+
1147
+iOS 13+ only: If the webview navigates to a URL that results in an HTTP
1148
+response with a Content-Disposition header 'attachment...', then
1149
+this will be called.
1150
+
1151
+iOS 8+: If the MIME type indicates that the content is not renderable by the
1152
+webview, that will also cause this to be called. On iOS versions before 13,
1153
+this is the only condition that will cause this function to be called.
1154
+
1155
+The application will need to provide its own code to actually download
1156
+the file.
1157
+
1158
+If not provided, the default is to let the webview try to render the file.
1159
+
1160
+Example:
1161
+```jsx
1162
+<WebView
1163
+  source={{ uri: 'https://reactnative.dev' }}
1164
+  onFileDownload={ ( { nativeEvent: { downloadUrl } } ) => {
1165
+    // You use downloadUrl which is a string to download files however you want.
1166
+  }}
1167
+  />
1168
+```
1169
+
1170
+| Type    | Required | Platform |
1171
+| ------- | -------- | -------- |
1172
+| function | No       | iOS      |
1173
+
1141 1174
 ## Methods
1142 1175
 
1143 1176
 ### `extraNativeComponentConfig()`

+ 14
- 0
example/App.tsx View File

@@ -13,6 +13,7 @@ import {
13 13
 import Alerts from './examples/Alerts';
14 14
 import Scrolling from './examples/Scrolling';
15 15
 import Background from './examples/Background';
16
+import Downloads from './examples/Downloads';
16 17
 import Uploads from './examples/Uploads';
17 18
 import Injection from './examples/Injection';
18 19
 
@@ -41,6 +42,14 @@ const TESTS = {
41 42
       return <Background />;
42 43
     },
43 44
   },
45
+  Downloads: {
46
+    title: 'Downloads',
47
+    testId: 'downloads',
48
+    description: 'File downloads test',
49
+    render() {
50
+      return <Downloads />;
51
+    },
52
+  },
44 53
   Uploads: {
45 54
     title: 'Uploads',
46 55
     testId: 'uploads',
@@ -115,6 +124,11 @@ export default class App extends Component<Props, State> {
115 124
             title="Injection"
116 125
             onPress={() => this._changeTest('Injection')}
117 126
           />
127
+          {Platform.OS == "ios" && <Button
128
+            testID="testType_downloads"
129
+            title="Downloads"
130
+            onPress={() => this._changeTest('Downloads')}
131
+          />}
118 132
           {Platform.OS === 'android' && <Button
119 133
             testID="testType_uploads"
120 134
             title="Uploads"

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

@@ -0,0 +1,55 @@
1
+import React, {Component} from 'react';
2
+import {Alert, Platform, View} from 'react-native';
3
+
4
+import WebView, {FileDownload} from 'react-native-webview';
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
+}

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

@@ -182,7 +182,7 @@ PODS:
182 182
     - React-cxxreact (= 0.61.5)
183 183
     - React-jsi (= 0.61.5)
184 184
   - React-jsinspector (0.61.5)
185
-  - react-native-webview (8.2.0):
185
+  - react-native-webview (9.3.0):
186 186
     - React
187 187
   - React-RCTActionSheet (0.61.5):
188 188
     - React-Core/RCTActionSheetHeaders (= 0.61.5)
@@ -326,7 +326,7 @@ SPEC CHECKSUMS:
326 326
   React-jsi: cb2cd74d7ccf4cffb071a46833613edc79cdf8f7
327 327
   React-jsiexecutor: d5525f9ed5f782fdbacb64b9b01a43a9323d2386
328 328
   React-jsinspector: fa0ecc501688c3c4c34f28834a76302233e29dc0
329
-  react-native-webview: 1db33907230d0eb344964d6f3bb56b9ee77e25a4
329
+  react-native-webview: 60d883f994e96a560756c14592552e02a06d604d
330 330
   React-RCTActionSheet: 600b4d10e3aea0913b5a92256d2719c0cdd26d76
331 331
   React-RCTAnimation: 791a87558389c80908ed06cc5dfc5e7920dfa360
332 332
   React-RCTBlob: d89293cc0236d9cb0933d85e430b0bbe81ad1d72
@@ -339,6 +339,6 @@ SPEC CHECKSUMS:
339 339
   ReactCommon: 198c7c8d3591f975e5431bec1b0b3b581aa1c5dd
340 340
   Yoga: f2a7cd4280bfe2cca5a7aed98ba0eb3d1310f18b
341 341
 
342
-PODFILE CHECKSUM: 2b0bdb79b803eefe541da6f9be6b06e99063bbfd
342
+PODFILE CHECKSUM: c2e136f84288494fa269d79892a8a1cf7acd7c71
343 343
 
344 344
 COCOAPODS: 1.8.4

+ 1
- 1
index.d.ts View File

@@ -2,7 +2,7 @@ import { Component } from 'react';
2 2
 // eslint-disable-next-line
3 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 7
 export type WebViewProps = IOSWebViewProps & AndroidWebViewProps;
8 8
 

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

@@ -340,6 +340,7 @@ class WebView extends React.Component<IOSWebViewProps, State> {
340 340
         onLoadingError={this.onLoadingError}
341 341
         onLoadingFinish={this.onLoadingFinish}
342 342
         onLoadingProgress={this.onLoadingProgress}
343
+        onFileDownload={this.props.onFileDownload}
343 344
         onLoadingStart={this.onLoadingStart}
344 345
         onHttpError={this.onHttpError}
345 346
         onMessage={this.onMessage}

+ 25
- 0
src/WebViewTypes.ts View File

@@ -113,6 +113,10 @@ export interface WebViewNavigation extends WebViewNativeEvent {
113 113
   mainDocumentURL?: string;
114 114
 }
115 115
 
116
+export interface FileDownload {
117
+  downloadUrl: string;
118
+}
119
+
116 120
 export type DecelerationRateConstant = 'normal' | 'fast';
117 121
 
118 122
 export interface WebViewMessage extends WebViewNativeEvent {
@@ -141,6 +145,8 @@ export type WebViewProgressEvent = NativeSyntheticEvent<
141 145
 
142 146
 export type WebViewNavigationEvent = NativeSyntheticEvent<WebViewNavigation>;
143 147
 
148
+export type FileDownloadEvent = NativeSyntheticEvent<FileDownload>;
149
+
144 150
 export type WebViewMessageEvent = NativeSyntheticEvent<WebViewMessage>;
145 151
 
146 152
 export type WebViewErrorEvent = NativeSyntheticEvent<WebViewError>;
@@ -302,6 +308,7 @@ export interface IOSNativeWebViewProps extends CommonNativeWebViewProps {
302 308
   onContentProcessDidTerminate?: (event: WebViewTerminatedEvent) => void;
303 309
   injectedJavaScriptForMainFrameOnly?: boolean;
304 310
   injectedJavaScriptBeforeContentLoadedForMainFrameOnly?: boolean;
311
+  onFileDownload?: (event: FileDownloadEvent) => void;
305 312
 }
306 313
 
307 314
 export interface MacOSNativeWebViewProps extends CommonNativeWebViewProps {
@@ -512,6 +519,24 @@ export interface IOSWebViewProps extends WebViewSharedProps {
512 519
    * @platform ios
513 520
   */
514 521
   injectedJavaScriptBeforeContentLoadedForMainFrameOnly?: boolean;
522
+
523
+  /**
524
+   * Function that is invoked when the client needs to download a file.
525
+   *
526
+   * iOS 13+ only: If the webview navigates to a URL that results in an HTTP
527
+   * response with a Content-Disposition header 'attachment...', then
528
+   * this will be called.
529
+   *
530
+   * iOS 8+: If the MIME type indicates that the content is not renderable by the
531
+   * webview, that will also cause this to be called. On iOS versions before 13,
532
+   * this is the only condition that will cause this function to be called.
533
+   *
534
+   * The application will need to provide its own code to actually download
535
+   * the file.
536
+   *
537
+   * If not provided, the default is to let the webview try to render the file.
538
+   */
539
+  onFileDownload?: (event: FileDownloadEvent) => void;
515 540
 }
516 541
 
517 542
 export interface MacOSWebViewProps extends WebViewSharedProps {

+ 0
- 10
yarn.lock View File

@@ -1502,11 +1502,6 @@
1502 1502
   resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
1503 1503
   integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
1504 1504
 
1505
-"@types/uuid@^7.0.2":
1506
-  version "7.0.2"
1507
-  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-7.0.2.tgz#d680a9c596ef84abf5c4c07a32ffd66d582526f8"
1508
-  integrity sha512-8Ly3zIPTnT0/8RCU6Kg/G3uTICf9sRwYOpUzSIM3503tLIKcnJPRuinHhXngJUy2MntrEf6dlpOHXJju90Qh5w==
1509
-
1510 1505
 "@types/yargs-parser@*":
1511 1506
   version "13.0.0"
1512 1507
   resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-13.0.0.tgz#453743c5bbf9f1bed61d959baab5b06be029b2d0"
@@ -10159,11 +10154,6 @@ uuid@^3.3.2, uuid@^3.3.3:
10159 10154
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"
10160 10155
   integrity sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==
10161 10156
 
10162
-uuid@^7.0.3:
10163
-  version "7.0.3"
10164
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b"
10165
-  integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==
10166
-
10167 10157
 v8-compile-cache@^2.0.3:
10168 10158
   version "2.1.0"
10169 10159
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"