From 573d02e94d0025cc897c7909066a16d8214b8234 Mon Sep 17 00:00:00 2001 From: Jacob Parker Date: Sun, 16 Oct 2016 06:29:14 -0700 Subject: [PATCH] cherry-pick abb8ea3aea686e2cd881a61fbc66d137857ef422 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a postMessage function and an onMessage event for webviews … 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/WebViewExample.js | 51 +++++++++++ Examples/UIExplorer/js/messagingtest.html | 28 ++++++ .../Components/WebView/WebView.android.js | 24 ++++- Libraries/Components/WebView/WebView.ios.js | 47 +++++++++- 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 | 91 ++++++++++++++++++- .../views/webview/events/TopMessageEvent.java | 52 +++++++++++ 11 files changed, 349 insertions(+), 8 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/WebViewExample.js b/Examples/UIExplorer/WebViewExample.js index 9c3020ef44e846..c0c99d2dc567cd 100644 --- a/Examples/UIExplorer/WebViewExample.js +++ b/Examples/UIExplorer/WebViewExample.js @@ -216,6 +216,53 @@ var ScaledWebView = React.createClass({ }, }) +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 c9db749a8b944b..57a12d7e6c83a7 100644 --- a/Libraries/Components/WebView/WebView.android.js +++ b/Libraries/Components/WebView/WebView.android.js @@ -60,6 +60,7 @@ var WebView = React.createClass({ automaticallyAdjustContentInsets: PropTypes.bool, contentInset: EdgeInsetsPropType, onNavigationStateChange: PropTypes.func, + onMessage: PropTypes.func, startInLoadingState: PropTypes.bool, // force WebView to show loadingView on first load style: View.propTypes.style, @@ -223,6 +224,8 @@ var WebView = React.createClass({ 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} onLoadingStart={this.onLoadingStart} @@ -272,6 +275,14 @@ var WebView = React.createClass({ ); }, + 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 @@ -313,11 +324,20 @@ var WebView = React.createClass({ viewState: WebViewState.IDLE, }); this.updateNavigationState(event); + }; + + onMessage = (event: Event) => { + var {onMessage} = this.props; + onMessage && onMessage(event); + } +} + +var RCTWebView = requireNativeComponent('RCTWebView', WebView, { + nativeOnly: { + messagingEnabled: PropTypes.bool, }, }); -var RCTWebView = requireNativeComponent('RCTWebView', WebView); - var styles = StyleSheet.create({ container: { flex: 1, diff --git a/Libraries/Components/WebView/WebView.ios.js b/Libraries/Components/WebView/WebView.ios.js index 09eeee0c3660e0..8d6c6896acc8d9 100644 --- a/Libraries/Components/WebView/WebView.ios.js +++ b/Libraries/Components/WebView/WebView.ios.js @@ -193,6 +193,20 @@ var WebView = React.createClass({ contentInset: EdgeInsetsPropType, onNavigationStateChange: PropTypes.func, startInLoadingState: PropTypes.bool, // force WebView to show loadingView on first load + /** + * 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, + + /** + * The style to apply to the `WebView`. + */ style: View.propTypes.style, /** @@ -300,6 +314,8 @@ var WebView = React.createClass({ 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 @@ -414,14 +450,21 @@ var WebView = React.createClass({ viewState: WebViewState.IDLE, }); this._updateNavigationState(event); - }, -}); + }; + + _onMessage = (event: Event) => { + var {onMessage} = this.props; + onMessage && onMessage(event); + } +} var RCTWebView = requireNativeComponent('RCTWebView', WebView, { nativeOnly: { 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 5809e24be5fff3..7f0e3123556eda 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 d3bdab1be52a73..5447d1cd1fcdea 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.java @@ -76,6 +76,7 @@ .put("topLoadingFinish", MapBuilder.of("registrationName", "onLoadingFinish")) .put("topLoadingError", MapBuilder.of("registrationName", "onLoadingError")) .put("topLayout", MapBuilder.of("registrationName", "onLayout")) + .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 586b071d545975..bd08fa33b8d5b1 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 @@ -22,9 +22,11 @@ import android.webkit.WebViewClient; import android.webkit.WebChromeClient; -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 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; @@ -41,6 +43,13 @@ import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.uimanager.events.Event; import com.facebook.react.uimanager.events.EventDispatcher; +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} @@ -69,6 +78,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"; @@ -76,6 +86,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). @@ -94,6 +105,7 @@ public void onPageFinished(WebView webView, String url) { if (!mLastLoadFailed) { ReactWebView reactWebView = (ReactWebView) webView; reactWebView.callInjectedJavaScript(); + reactWebView.linkBridge(); emitFinishEvent(webView, url); } } @@ -181,6 +193,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 @@ -212,6 +238,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 && @@ -220,6 +260,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(); @@ -290,6 +358,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) { @@ -352,7 +425,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 @@ -370,6 +444,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); + } +}