From abb8ea3aea686e2cd881a61fbc66d137857ef422 Mon Sep 17 00:00:00 2001 From: Jacob Parker Date: Sun, 16 Oct 2016 06:29:14 -0700 Subject: [PATCH] =?UTF-8?q?Implement=20a=20postMessage=20function=20and=20?= =?UTF-8?q?an=20onMessage=20event=20for=20webviews=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: JS API very similar to web workers and node's child process. Work has been done by somebody else for the Android implementation over at #7020, so we'd need to have these in sync before anything gets merged. I've made a prop `messagingEnabled` to be more explicit about creating globals—it might be sufficient to just check for an onMessage handler though. ![screen shot 2016-09-06 at 10 28 23](https://cloud.githubusercontent.com/assets/7275322/18268669/b1a12348-741c-11e6-91a1-ad39d5a8bc03.png) Closes https://github.com/facebook/react-native/pull/9762 Differential Revision: D4008260 fbshipit-source-id: 84b1afafbc0ab1edc3dfbf1a8fb870218e171a4c --- Examples/UIExplorer/js/WebViewExample.js | 51 +++++++++++ Examples/UIExplorer/js/messagingtest.html | 28 +++++++ .../Components/WebView/WebView.android.js | 22 ++++- Libraries/Components/WebView/WebView.ios.js | 39 +++++++++ React/Views/RCTWebView.h | 2 + React/Views/RCTWebView.m | 46 ++++++++++ React/Views/RCTWebViewManager.m | 14 ++++ .../uimanager/UIManagerModuleConstants.java | 1 + .../com/facebook/react/views/webview/BUCK | 1 + .../views/webview/ReactWebViewManager.java | 84 ++++++++++++++++++- .../views/webview/events/TopMessageEvent.java | 52 ++++++++++++ 11 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 Examples/UIExplorer/js/messagingtest.html create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopMessageEvent.java diff --git a/Examples/UIExplorer/js/WebViewExample.js b/Examples/UIExplorer/js/WebViewExample.js index a27451b90eea8e..be1fb44aec1148 100644 --- a/Examples/UIExplorer/js/WebViewExample.js +++ b/Examples/UIExplorer/js/WebViewExample.js @@ -217,6 +217,53 @@ class ScaledWebView extends React.Component { } } +class MessagingTest extends React.Component { + webview = null + + state = { + messagesReceivedFromWebView: 0, + message: '', + } + + onMessage = e => this.setState({ + messagesReceivedFromWebView: this.state.messagesReceivedFromWebView + 1, + message: e.nativeEvent.data, + }) + + postMessage = () => { + if (this.webview) { + this.webview.postMessage('"Hello" from React Native!'); + } + } + + render(): ReactElement { + const {messagesReceivedFromWebView, message} = this.state; + + return ( + + + Messages received from web view: {messagesReceivedFromWebView} + {message || '(No message)'} + + + + + diff --git a/Libraries/Components/WebView/WebView.android.js b/Libraries/Components/WebView/WebView.android.js index 54227e3d5447fc..3cc033a14976db 100644 --- a/Libraries/Components/WebView/WebView.android.js +++ b/Libraries/Components/WebView/WebView.android.js @@ -58,6 +58,7 @@ class WebView extends React.Component { automaticallyAdjustContentInsets: PropTypes.bool, contentInset: EdgeInsetsPropType, onNavigationStateChange: PropTypes.func, + onMessage: PropTypes.func, onContentSizeChange: PropTypes.func, startInLoadingState: PropTypes.bool, // force WebView to show loadingView on first load style: View.propTypes.style, @@ -218,6 +219,8 @@ class WebView extends React.Component { userAgent={this.props.userAgent} javaScriptEnabled={this.props.javaScriptEnabled} domStorageEnabled={this.props.domStorageEnabled} + messagingEnabled={typeof this.props.onMessage === 'function'} + onMessage={this.onMessage} contentInset={this.props.contentInset} automaticallyAdjustContentInsets={this.props.automaticallyAdjustContentInsets} onContentSizeChange={this.props.onContentSizeChange} @@ -268,6 +271,14 @@ class WebView extends React.Component { ); }; + postMessage = (data) => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.postMessage, + [String(data)] + ); + }; + /** * We return an event with a bunch of fields including: * url, title, loading, canGoBack, canGoForward @@ -310,9 +321,18 @@ class WebView extends React.Component { }); this.updateNavigationState(event); }; + + onMessage = (event: Event) => { + var {onMessage} = this.props; + onMessage && onMessage(event); + } } -var RCTWebView = requireNativeComponent('RCTWebView', WebView); +var RCTWebView = requireNativeComponent('RCTWebView', WebView, { + nativeOnly: { + messagingEnabled: PropTypes.bool, + }, +}); var styles = StyleSheet.create({ container: { diff --git a/Libraries/Components/WebView/WebView.ios.js b/Libraries/Components/WebView/WebView.ios.js index 62717ab70a6cc5..7961d777f7fc1b 100644 --- a/Libraries/Components/WebView/WebView.ios.js +++ b/Libraries/Components/WebView/WebView.ios.js @@ -235,6 +235,16 @@ class WebView extends React.Component { * Function that is invoked when the `WebView` loading starts or ends. */ onNavigationStateChange: PropTypes.func, + /** + * A function that is invoked when the webview calls `window.postMessage`. + * Setting this property will inject a `postMessage` global into your + * webview, but will still call pre-existing values of `postMessage`. + * + * `window.postMessage` accepts one argument, `data`, which will be + * available on the event object, `event.nativeEvent.data`. `data` + * must be a string. + */ + onMessage: PropTypes.func, /** * Boolean value that forces the `WebView` to show the loading view * on the first load. @@ -382,6 +392,8 @@ class WebView extends React.Component { source.uri = this.props.url; } + const messagingEnabled = typeof this.props.onMessage === 'function'; + var webView = { document.title = e.data; }); + * ``` + */ + postMessage = (data) => { + UIManager.dispatchViewManagerCommand( + this.getWebViewHandle(), + UIManager.RCTWebView.Commands.postMessage, + [String(data)] + ); + }; + /** * We return an event with a bunch of fields including: * url, title, loading, canGoBack, canGoForward @@ -502,6 +534,11 @@ class WebView extends React.Component { }); this._updateNavigationState(event); }; + + _onMessage = (event: Event) => { + var {onMessage} = this.props; + onMessage && onMessage(event); + } } var RCTWebView = requireNativeComponent('RCTWebView', WebView, { @@ -509,6 +546,8 @@ var RCTWebView = requireNativeComponent('RCTWebView', WebView, { onLoadingStart: true, onLoadingError: true, onLoadingFinish: true, + onMessage: true, + messagingEnabled: PropTypes.bool, }, }); diff --git a/React/Views/RCTWebView.h b/React/Views/RCTWebView.h index f8bd2608cc0bf3..c2c41431f632d2 100644 --- a/React/Views/RCTWebView.h +++ b/React/Views/RCTWebView.h @@ -34,6 +34,7 @@ shouldStartLoadForRequest:(NSMutableDictionary *)request @property (nonatomic, copy) NSDictionary *source; @property (nonatomic, assign) UIEdgeInsets contentInset; @property (nonatomic, assign) BOOL automaticallyAdjustContentInsets; +@property (nonatomic, assign) BOOL messagingEnabled; @property (nonatomic, copy) NSString *injectedJavaScript; @property (nonatomic, assign) BOOL scalesPageToFit; @@ -41,5 +42,6 @@ shouldStartLoadForRequest:(NSMutableDictionary *)request - (void)goBack; - (void)reload; - (void)stopLoading; +- (void)postMessage:(NSString *)message; @end diff --git a/React/Views/RCTWebView.m b/React/Views/RCTWebView.m index 76c3f096003d01..23fe37b84dca0f 100644 --- a/React/Views/RCTWebView.m +++ b/React/Views/RCTWebView.m @@ -20,6 +20,7 @@ #import "UIView+React.h" NSString *const RCTJSNavigationScheme = @"react-js-navigation"; +NSString *const RCTJSPostMessageHost = @"postMessage"; @interface RCTWebView () @@ -27,6 +28,7 @@ @interface RCTWebView () @property (nonatomic, copy) RCTDirectEventBlock onLoadingFinish; @property (nonatomic, copy) RCTDirectEventBlock onLoadingError; @property (nonatomic, copy) RCTDirectEventBlock onShouldStartLoadWithRequest; +@property (nonatomic, copy) RCTDirectEventBlock onMessage; @end @@ -82,6 +84,18 @@ - (void)stopLoading [_webView stopLoading]; } +- (void)postMessage:(NSString *)message +{ + NSDictionary *eventInitDict = @{ + @"data": message, + }; + NSString *source = [NSString + stringWithFormat:@"document.dispatchEvent(new MessageEvent('message', %@));", + RCTJSONStringify(eventInitDict, NULL) + ]; + [_webView stringByEvaluatingJavaScriptFromString:source]; +} + - (void)setSource:(NSDictionary *)source { if (![_source isEqualToDictionary:source]) { @@ -221,6 +235,18 @@ - (BOOL)webView:(__unused UIWebView *)webView shouldStartLoadWithRequest:(NSURLR } } + if (isJSNavigation && [request.URL.host isEqualToString:RCTJSPostMessageHost]) { + NSString *data = request.URL.query; + data = [data stringByReplacingOccurrencesOfString:@"+" withString:@" "]; + data = [data stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; + + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary: @{ + @"data": data, + }]; + _onMessage(event); + } + // JS Navigation handler return !isJSNavigation; } @@ -248,6 +274,26 @@ - (void)webView:(__unused UIWebView *)webView didFailLoadWithError:(NSError *)er - (void)webViewDidFinishLoad:(UIWebView *)webView { + if (_messagingEnabled) { + #if RCT_DEV + // See isNative in lodash + NSString *testPostMessageNative = @"String(window.postMessage) === String(Object.hasOwnProperty).replace('hasOwnProperty', 'postMessage')"; + BOOL postMessageIsNative = [ + [webView stringByEvaluatingJavaScriptFromString:testPostMessageNative] + isEqualToString:@"true" + ]; + if (!postMessageIsNative) { + RCTLogError(@"Setting onMessage on a WebView overrides existing values of window.postMessage, but a previous value was defined"); + } + #endif + NSString *source = [NSString stringWithFormat: + @"window.originalPostMessage = window.postMessage;" + "window.postMessage = function(data) {" + "window.location = '%@://%@?' + encodeURIComponent(String(data));" + "};", RCTJSNavigationScheme, RCTJSPostMessageHost + ]; + [webView stringByEvaluatingJavaScriptFromString:source]; + } if (_injectedJavaScript != nil) { NSString *jsEvaluationValue = [webView stringByEvaluatingJavaScriptFromString:_injectedJavaScript]; diff --git a/React/Views/RCTWebViewManager.m b/React/Views/RCTWebViewManager.m index af5f5b03b8a027..2730124fffa281 100644 --- a/React/Views/RCTWebViewManager.m +++ b/React/Views/RCTWebViewManager.m @@ -38,12 +38,14 @@ - (UIView *)view RCT_REMAP_VIEW_PROPERTY(scrollEnabled, _webView.scrollView.scrollEnabled, BOOL) RCT_REMAP_VIEW_PROPERTY(decelerationRate, _webView.scrollView.decelerationRate, CGFloat) RCT_EXPORT_VIEW_PROPERTY(scalesPageToFit, BOOL) +RCT_EXPORT_VIEW_PROPERTY(messagingEnabled, BOOL) RCT_EXPORT_VIEW_PROPERTY(injectedJavaScript, NSString) RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets) RCT_EXPORT_VIEW_PROPERTY(automaticallyAdjustContentInsets, BOOL) RCT_EXPORT_VIEW_PROPERTY(onLoadingStart, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onLoadingFinish, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onLoadingError, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onMessage, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onShouldStartLoadWithRequest, RCTDirectEventBlock) RCT_REMAP_VIEW_PROPERTY(allowsInlineMediaPlayback, _webView.allowsInlineMediaPlayback, BOOL) RCT_REMAP_VIEW_PROPERTY(mediaPlaybackRequiresUserAction, _webView.mediaPlaybackRequiresUserAction, BOOL) @@ -97,6 +99,18 @@ - (UIView *)view }]; } +RCT_EXPORT_METHOD(postMessage:(nonnull NSNumber *)reactTag message:(NSString *)message) +{ + [self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { + RCTWebView *view = viewRegistry[reactTag]; + if (![view isKindOfClass:[RCTWebView class]]) { + RCTLogError(@"Invalid view returned from registry, expecting RCTWebView, got: %@", view); + } else { + [view postMessage:message]; + } + }]; +} + #pragma mark - Exported synchronous methods - (BOOL)webView:(__unused RCTWebView *)webView diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java index 7b5528bf20daee..506c0063acabed 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java @@ -77,6 +77,7 @@ .put("topLoadingFinish", MapBuilder.of("registrationName", "onLoadingFinish")) .put("topLoadingStart", MapBuilder.of("registrationName", "onLoadingStart")) .put("topSelectionChange", MapBuilder.of("registrationName", "onSelectionChange")) + .put("topMessage", MapBuilder.of("registrationName", "onMessage")) .build(); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/BUCK b/ReactAndroid/src/main/java/com/facebook/react/views/webview/BUCK index 8da222d8dadd74..7a9875b621e92d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/webview/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/BUCK @@ -4,6 +4,7 @@ android_library( name = 'webview', srcs = glob(['**/*.java']), deps = [ + react_native_dep('libraries/fbcore/src/main/java/com/facebook/common/logging:logging'), react_native_target('java/com/facebook/react/bridge:bridge'), react_native_target('java/com/facebook/react/uimanager:uimanager'), react_native_target('java/com/facebook/react/uimanager/annotations:annotations'), diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java index 6e15091850ef13..735f23d333cd22 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/ReactWebViewManager.java @@ -26,7 +26,11 @@ import android.webkit.WebChromeClient; import android.webkit.WebView; import android.webkit.WebViewClient; +import android.webkit.JavascriptInterface; +import android.webkit.ValueCallback; +import com.facebook.common.logging.FLog; +import com.facebook.react.common.ReactConstants; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.ReactContext; @@ -46,6 +50,10 @@ import com.facebook.react.views.webview.events.TopLoadingErrorEvent; import com.facebook.react.views.webview.events.TopLoadingFinishEvent; import com.facebook.react.views.webview.events.TopLoadingStartEvent; +import com.facebook.react.views.webview.events.TopMessageEvent; + +import org.json.JSONObject; +import org.json.JSONException; /** * Manages instances of {@link WebView} @@ -74,6 +82,7 @@ public class ReactWebViewManager extends SimpleViewManager { private static final String HTML_ENCODING = "UTF-8"; private static final String HTML_MIME_TYPE = "text/html; charset=utf-8"; + private static final String BRIDGE_NAME = "__REACT_WEB_VIEW_BRIDGE"; private static final String HTTP_METHOD_POST = "POST"; @@ -81,6 +90,7 @@ public class ReactWebViewManager extends SimpleViewManager { public static final int COMMAND_GO_FORWARD = 2; public static final int COMMAND_RELOAD = 3; public static final int COMMAND_STOP_LOADING = 4; + public static final int COMMAND_POST_MESSAGE = 5; // Use `webView.loadUrl("about:blank")` to reliably reset the view // state and release page resources (including any running JavaScript). @@ -100,6 +110,7 @@ public void onPageFinished(WebView webView, String url) { if (!mLastLoadFailed) { ReactWebView reactWebView = (ReactWebView) webView; reactWebView.callInjectedJavaScript(); + reactWebView.linkBridge(); emitFinishEvent(webView, url); } } @@ -190,6 +201,20 @@ private WritableMap createWebViewEvent(WebView webView, String url) { */ private static class ReactWebView extends WebView implements LifecycleEventListener { private @Nullable String injectedJS; + private boolean messagingEnabled = false; + + private class ReactWebViewBridge { + ReactWebView mContext; + + ReactWebViewBridge(ReactWebView c) { + mContext = c; + } + + @JavascriptInterface + public void postMessage(String message) { + mContext.onMessage(message); + } + } /** * WebView must be created with an context of the current activity @@ -221,6 +246,20 @@ public void setInjectedJavaScript(@Nullable String js) { injectedJS = js; } + public void setMessagingEnabled(boolean enabled) { + if (messagingEnabled == enabled) { + return; + } + + messagingEnabled = enabled; + if (enabled) { + addJavascriptInterface(new ReactWebViewBridge(this), BRIDGE_NAME); + linkBridge(); + } else { + removeJavascriptInterface(BRIDGE_NAME); + } + } + public void callInjectedJavaScript() { if (getSettings().getJavaScriptEnabled() && injectedJS != null && @@ -229,6 +268,34 @@ public void callInjectedJavaScript() { } } + public void linkBridge() { + if (messagingEnabled) { + if (ReactBuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + // See isNative in lodash + String testPostMessageNative = "String(window.postMessage) === String(Object.hasOwnProperty).replace('hasOwnProperty', 'postMessage')"; + evaluateJavascript(testPostMessageNative, new ValueCallback() { + @Override + public void onReceiveValue(String value) { + if (value.equals("true")) { + FLog.w(ReactConstants.TAG, "Setting onMessage on a WebView overrides existing values of window.postMessage, but a previous value was defined"); + } + } + }); + } + + loadUrl("javascript:(" + + "window.originalPostMessage = window.postMessage," + + "window.postMessage = function(data) {" + + BRIDGE_NAME + ".postMessage(String(data));" + + "}" + + ")"); + } + } + + public void onMessage(String message) { + dispatchEvent(this, new TopMessageEvent(this.getId(), message)); + } + private void cleanupCallbacksAndDestroy() { setWebViewClient(null); destroy(); @@ -310,6 +377,11 @@ public void setInjectedJavaScript(WebView view, @Nullable String injectedJavaScr ((ReactWebView) view).setInjectedJavaScript(injectedJavaScript); } + @ReactProp(name = "messagingEnabled") + public void setMessagingEnabled(WebView view, boolean enabled) { + ((ReactWebView) view).setMessagingEnabled(enabled); + } + @ReactProp(name = "source") public void setSource(WebView view, @Nullable ReadableMap source) { if (source != null) { @@ -385,7 +457,8 @@ protected void addEventEmitters(ThemedReactContext reactContext, WebView view) { "goBack", COMMAND_GO_BACK, "goForward", COMMAND_GO_FORWARD, "reload", COMMAND_RELOAD, - "stopLoading", COMMAND_STOP_LOADING); + "stopLoading", COMMAND_STOP_LOADING, + "postMessage", COMMAND_POST_MESSAGE); } @Override @@ -403,6 +476,15 @@ public void receiveCommand(WebView root, int commandId, @Nullable ReadableArray case COMMAND_STOP_LOADING: root.stopLoading(); break; + case COMMAND_POST_MESSAGE: + try { + JSONObject eventInitDict = new JSONObject(); + eventInitDict.put("data", args.getString(0)); + root.loadUrl("javascript:(document.dispatchEvent(new MessageEvent('message', " + eventInitDict.toString() + ")))"); + } catch (JSONException e) { + throw new RuntimeException(e); + } + break; } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopMessageEvent.java b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopMessageEvent.java new file mode 100644 index 00000000000000..db5a4200d7fd6e --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/webview/events/TopMessageEvent.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.views.webview.events; + +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +/** + * Event emitted when there is an error in loading. + */ +public class TopMessageEvent extends Event { + + public static final String EVENT_NAME = "topMessage"; + private final String mData; + + public TopMessageEvent(int viewId, String data) { + super(viewId); + mData = data; + } + + @Override + public String getEventName() { + return EVENT_NAME; + } + + @Override + public boolean canCoalesce() { + return false; + } + + @Override + public short getCoalescingKey() { + // All events for a given view can be coalesced. + return 0; + } + + @Override + public void dispatch(RCTEventEmitter rctEventEmitter) { + WritableMap data = Arguments.createMap(); + data.putString("data", mData); + rctEventEmitter.receiveEvent(getViewTag(), EVENT_NAME, data); + } +}