瀏覽代碼

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 年之前
父節點
當前提交
a6010d93e0
No account linked to committer's email address
共有 11 個檔案被更改,包括 176 行新增22 行删除
  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 查看文件

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;
973
   decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse
975
   decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse
974
                     decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler
976
                     decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler
975
 {
977
 {
978
+  WKNavigationResponsePolicy policy = WKNavigationResponsePolicyAllow;
976
   if (_onHttpError && navigationResponse.forMainFrame) {
979
   if (_onHttpError && navigationResponse.forMainFrame) {
977
     if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) {
980
     if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) {
978
       NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response;
981
       NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response;
979
       NSInteger statusCode = response.statusCode;
982
       NSInteger statusCode = response.statusCode;
980
 
983
 
981
       if (statusCode >= 400) {
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
           @"url": response.URL.absoluteString,
987
           @"url": response.URL.absoluteString,
985
           @"statusCode": @(statusCode)
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 查看文件

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)

+ 18
- 3
docs/Guide.md 查看文件

226
 
226
 
227
 ##### iOS
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
 <key>NSPhotoLibraryAddUsageDescription</key>
248
 <key>NSPhotoLibraryAddUsageDescription</key>
237
 
251
 
238
 ##### Android
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
 ```xml
257
 ```xml
243
 <manifest ...>
258
 <manifest ...>

+ 33
- 0
docs/Reference.md 查看文件

65
 - [`sharedCookiesEnabled`](Reference.md#sharedCookiesEnabled)
65
 - [`sharedCookiesEnabled`](Reference.md#sharedCookiesEnabled)
66
 - [`textZoom`](Reference.md#textZoom)
66
 - [`textZoom`](Reference.md#textZoom)
67
 - [`ignoreSilentHardwareSwitch`](Reference.md#ignoreSilentHardwareSwitch)
67
 - [`ignoreSilentHardwareSwitch`](Reference.md#ignoreSilentHardwareSwitch)
68
+- [`onFileDownload`](Reference.md#onFileDownload)
68
 
69
 
69
 ## Methods Index
70
 ## Methods Index
70
 
71
 
1138
 | ------- | -------- | -------- |
1139
 | ------- | -------- | -------- |
1139
 | boolean | No       | iOS      |
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
 ## Methods
1174
 ## Methods
1142
 
1175
 
1143
 ### `extraNativeComponentConfig()`
1176
 ### `extraNativeComponentConfig()`

+ 14
- 0
example/App.tsx 查看文件

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
 import Injection from './examples/Injection';
18
 import Injection from './examples/Injection';
18
 
19
 
41
       return <Background />;
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
   Uploads: {
53
   Uploads: {
45
     title: 'Uploads',
54
     title: 'Uploads',
46
     testId: 'uploads',
55
     testId: 'uploads',
115
             title="Injection"
124
             title="Injection"
116
             onPress={() => this._changeTest('Injection')}
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
           {Platform.OS === 'android' && <Button
132
           {Platform.OS === 'android' && <Button
119
             testID="testType_uploads"
133
             testID="testType_uploads"
120
             title="Uploads"
134
             title="Uploads"

+ 55
- 0
example/examples/Downloads.tsx 查看文件

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 查看文件

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.2.0):
185
+  - react-native-webview (9.3.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: 1db33907230d0eb344964d6f3bb56b9ee77e25a4
329
+  react-native-webview: 60d883f994e96a560756c14592552e02a06d604d
330
   React-RCTActionSheet: 600b4d10e3aea0913b5a92256d2719c0cdd26d76
330
   React-RCTActionSheet: 600b4d10e3aea0913b5a92256d2719c0cdd26d76
331
   React-RCTAnimation: 791a87558389c80908ed06cc5dfc5e7920dfa360
331
   React-RCTAnimation: 791a87558389c80908ed06cc5dfc5e7920dfa360
332
   React-RCTBlob: d89293cc0236d9cb0933d85e430b0bbe81ad1d72
332
   React-RCTBlob: d89293cc0236d9cb0933d85e430b0bbe81ad1d72
339
   ReactCommon: 198c7c8d3591f975e5431bec1b0b3b581aa1c5dd
339
   ReactCommon: 198c7c8d3591f975e5431bec1b0b3b581aa1c5dd
340
   Yoga: f2a7cd4280bfe2cca5a7aed98ba0eb3d1310f18b
340
   Yoga: f2a7cd4280bfe2cca5a7aed98ba0eb3d1310f18b
341
 
341
 
342
-PODFILE CHECKSUM: 2b0bdb79b803eefe541da6f9be6b06e99063bbfd
342
+PODFILE CHECKSUM: c2e136f84288494fa269d79892a8a1cf7acd7c71
343
 
343
 
344
 COCOAPODS: 1.8.4
344
 COCOAPODS: 1.8.4

+ 1
- 1
index.d.ts 查看文件

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
 

+ 1
- 0
src/WebView.ios.tsx 查看文件

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

+ 25
- 0
src/WebViewTypes.ts 查看文件

113
   mainDocumentURL?: string;
113
   mainDocumentURL?: string;
114
 }
114
 }
115
 
115
 
116
+export interface FileDownload {
117
+  downloadUrl: string;
118
+}
119
+
116
 export type DecelerationRateConstant = 'normal' | 'fast';
120
 export type DecelerationRateConstant = 'normal' | 'fast';
117
 
121
 
118
 export interface WebViewMessage extends WebViewNativeEvent {
122
 export interface WebViewMessage extends WebViewNativeEvent {
141
 
145
 
142
 export type WebViewNavigationEvent = NativeSyntheticEvent<WebViewNavigation>;
146
 export type WebViewNavigationEvent = NativeSyntheticEvent<WebViewNavigation>;
143
 
147
 
148
+export type FileDownloadEvent = NativeSyntheticEvent<FileDownload>;
149
+
144
 export type WebViewMessageEvent = NativeSyntheticEvent<WebViewMessage>;
150
 export type WebViewMessageEvent = NativeSyntheticEvent<WebViewMessage>;
145
 
151
 
146
 export type WebViewErrorEvent = NativeSyntheticEvent<WebViewError>;
152
 export type WebViewErrorEvent = NativeSyntheticEvent<WebViewError>;
302
   onContentProcessDidTerminate?: (event: WebViewTerminatedEvent) => void;
308
   onContentProcessDidTerminate?: (event: WebViewTerminatedEvent) => void;
303
   injectedJavaScriptForMainFrameOnly?: boolean;
309
   injectedJavaScriptForMainFrameOnly?: boolean;
304
   injectedJavaScriptBeforeContentLoadedForMainFrameOnly?: boolean;
310
   injectedJavaScriptBeforeContentLoadedForMainFrameOnly?: boolean;
311
+  onFileDownload?: (event: FileDownloadEvent) => void;
305
 }
312
 }
306
 
313
 
307
 export interface MacOSNativeWebViewProps extends CommonNativeWebViewProps {
314
 export interface MacOSNativeWebViewProps extends CommonNativeWebViewProps {
512
    * @platform ios
519
    * @platform ios
513
   */
520
   */
514
   injectedJavaScriptBeforeContentLoadedForMainFrameOnly?: boolean;
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
 export interface MacOSWebViewProps extends WebViewSharedProps {
542
 export interface MacOSWebViewProps extends WebViewSharedProps {

+ 0
- 10
yarn.lock 查看文件

1502
   resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
1502
   resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
1503
   integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
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
 "@types/yargs-parser@*":
1505
 "@types/yargs-parser@*":
1511
   version "13.0.0"
1506
   version "13.0.0"
1512
   resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-13.0.0.tgz#453743c5bbf9f1bed61d959baab5b06be029b2d0"
1507
   resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-13.0.0.tgz#453743c5bbf9f1bed61d959baab5b06be029b2d0"
10159
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"
10154
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"
10160
   integrity sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==
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
 v8-compile-cache@^2.0.3:
10157
 v8-compile-cache@^2.0.3:
10168
   version "2.1.0"
10158
   version "2.1.0"
10169
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"
10159
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"