feat(Android Webview): Support onShouldStartLoadWithRequest on Android (#107)

This PR adds support for `onShouldStartLoadWithRequest` on android.

The initial PR was #59

The issue for this PR is: #106

fixes #106
This commit is contained in:
Thibault Malbranche 2018-11-30 02:59:12 +01:00
parent 48230e4dcf
commit b1b662628e
7 changed files with 219 additions and 158 deletions

View File

@ -48,6 +48,7 @@ android {
repositories {
mavenCentral()
jcenter()
maven {
url 'https://maven.google.com/'
name 'Google'

View File

@ -30,6 +30,7 @@ import android.webkit.GeolocationPermissions;
import android.webkit.JavascriptInterface;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
@ -51,11 +52,19 @@ import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.uimanager.events.ContentSizeChangeEvent;
import com.facebook.react.uimanager.events.Event;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.RCTEventEmitter;
import com.reactnativecommunity.webview.events.TopLoadingErrorEvent;
import com.reactnativecommunity.webview.events.TopLoadingFinishEvent;
import com.reactnativecommunity.webview.events.TopLoadingStartEvent;
import com.reactnativecommunity.webview.events.TopMessageEvent;
import com.reactnativecommunity.webview.events.TopLoadingProgressEvent;
import com.reactnativecommunity.webview.events.TopShouldStartLoadWithRequestEvent;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import javax.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
@ -66,12 +75,14 @@ import org.json.JSONObject;
* - GO_BACK
* - GO_FORWARD
* - RELOAD
* - LOAD_URL
*
* {@link WebView} instances could emit following direct events:
* - topLoadingFinish
* - topLoadingStart
* - topLoadingStart
* - topLoadingProgress
* - topShouldStartLoadWithRequest
*
* Each event will carry the following properties:
* - target - view's react tag
@ -99,6 +110,7 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
public static final int COMMAND_STOP_LOADING = 4;
public static final int COMMAND_POST_MESSAGE = 5;
public static final int COMMAND_INJECT_JAVASCRIPT = 6;
public static final int COMMAND_LOAD_URL = 7;
// Use `webView.loadUrl("about:blank")` to reliably reset the view
// state and release page resources (including any running JavaScript).
@ -111,7 +123,6 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
protected boolean mLastLoadFailed = false;
protected @Nullable ReadableArray mUrlPrefixesForDefaultIntent;
protected @Nullable List<Pattern> mOriginWhitelist;
@Override
public void onPageFinished(WebView webView, String url) {
@ -139,50 +150,16 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.equals(BLANK_URL)) return false;
// url blacklisting
if (mUrlPrefixesForDefaultIntent != null && mUrlPrefixesForDefaultIntent.size() > 0) {
ArrayList<Object> urlPrefixesForDefaultIntent =
mUrlPrefixesForDefaultIntent.toArrayList();
for (Object urlPrefix : urlPrefixesForDefaultIntent) {
if (url.startsWith((String) urlPrefix)) {
launchIntent(view.getContext(), url);
return true;
}
}
}
if (mOriginWhitelist != null && shouldHandleURL(mOriginWhitelist, url)) {
return false;
}
launchIntent(view.getContext(), url);
dispatchEvent(view, new TopShouldStartLoadWithRequestEvent(view.getId(), url));
return true;
}
private void launchIntent(Context context, String url) {
try {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
context.startActivity(intent);
} catch (ActivityNotFoundException e) {
FLog.w(ReactConstants.TAG, "activity not found to handle uri scheme for: " + url, e);
}
}
private boolean shouldHandleURL(List<Pattern> originWhitelist, String url) {
Uri uri = Uri.parse(url);
String scheme = uri.getScheme() != null ? uri.getScheme() : "";
String authority = uri.getAuthority() != null ? uri.getAuthority() : "";
String urlToCheck = scheme + "://" + authority;
for (Pattern pattern : originWhitelist) {
if (pattern.matcher(urlToCheck).matches()) {
return true;
}
}
return false;
@TargetApi(Build.VERSION_CODES.N)
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
dispatchEvent(view, new TopShouldStartLoadWithRequestEvent(view.getId(), request.getUrl().toString()));
return true;
}
@Override
@ -231,10 +208,6 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
public void setUrlPrefixesForDefaultIntent(ReadableArray specialUrls) {
mUrlPrefixesForDefaultIntent = specialUrls;
}
public void setOriginWhitelist(List<Pattern> originWhitelist) {
mOriginWhitelist = originWhitelist;
}
}
/**
@ -656,20 +629,6 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
view.getSettings().setGeolocationEnabled(isGeolocationEnabled != null && isGeolocationEnabled);
}
@ReactProp(name = "originWhitelist")
public void setOriginWhitelist(
WebView view,
@Nullable ReadableArray originWhitelist) {
RNCWebViewClient client = ((RNCWebView) view).getRNCWebViewClient();
if (client != null && originWhitelist != null) {
List<Pattern> whiteList = new LinkedList<>();
for (int i = 0 ; i < originWhitelist.size() ; i++) {
whiteList.add(Pattern.compile(originWhitelist.getString(i)));
}
client.setOriginWhitelist(whiteList);
}
}
@Override
protected void addEventEmitters(ThemedReactContext reactContext, WebView view) {
// Do not register default touch emitter and let WebView implementation handle touches
@ -678,9 +637,13 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
@Override
public Map getExportedCustomDirectEventTypeConstants() {
MapBuilder.Builder builder = MapBuilder.builder();
builder.put("topLoadingProgress", MapBuilder.of("registrationName", "onLoadingProgress"));
return builder.build();
Map export = super.getExportedCustomDirectEventTypeConstants();
if (export == null) {
export = MapBuilder.newHashMap();
}
export.put(TopLoadingProgressEvent.EVENT_NAME, MapBuilder.of("registrationName", "onLoadingProgress"));
export.put(TopShouldStartLoadWithRequestEvent.EVENT_NAME, MapBuilder.of("registrationName", "onShouldStartLoadWithRequest"));
return export;
}
@Override
@ -691,7 +654,8 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
"reload", COMMAND_RELOAD,
"stopLoading", COMMAND_STOP_LOADING,
"postMessage", COMMAND_POST_MESSAGE,
"injectJavaScript", COMMAND_INJECT_JAVASCRIPT
"injectJavaScript", COMMAND_INJECT_JAVASCRIPT,
"loadUrl", COMMAND_LOAD_URL
);
}
@ -734,6 +698,12 @@ public class RNCWebViewManager extends SimpleViewManager<WebView> {
RNCWebView reactWebView = (RNCWebView) root;
reactWebView.evaluateJavascriptWithFallback(args.getString(0));
break;
case COMMAND_LOAD_URL:
if (args == null) {
throw new RuntimeException("Arguments for loading an url are null!");
}
root.loadUrl(args.getString(0));
break;
}
}

View File

@ -0,0 +1,40 @@
package com.reactnativecommunity.webview.events;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.events.Event;
import com.facebook.react.uimanager.events.RCTEventEmitter;
public class TopShouldStartLoadWithRequestEvent extends Event<TopMessageEvent> {
public static final String EVENT_NAME = "topShouldStartLoadWithRequest";
private final String mUrl;
public TopShouldStartLoadWithRequestEvent(int viewId, String url) {
super(viewId);
mUrl = url;
}
@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("url", mUrl);
data.putString("navigationType", "other");
rctEventEmitter.receiveEvent(getViewTag(), EVENT_NAME, data);
}
}

View File

@ -8,35 +8,33 @@
* @flow
*/
'use strict';
import React from 'react';
import ReactNative from 'react-native';
import {
import ReactNative, {
ActivityIndicator,
Image,
requireNativeComponent,
StyleSheet,
UIManager,
View,
Image,
requireNativeComponent,
NativeModules
} from 'react-native';
import invariant from 'fbjs/lib/invariant';
import keyMirror from 'fbjs/lib/keyMirror';
import WebViewShared from './WebViewShared';
import {
defaultOriginWhitelist,
createOnShouldStartLoadWithRequest,
} from './WebViewShared';
import type {
WebViewEvent,
WebViewError,
WebViewErrorEvent,
WebViewMessageEvent,
WebViewNavigation,
WebViewNavigationEvent,
WebViewProgressEvent,
WebViewSharedProps,
WebViewSource,
WebViewProgressEvent,
} from './WebViewTypes';
const resolveAssetSource = Image.resolveAssetSource;
@ -69,7 +67,7 @@ class WebView extends React.Component<WebViewSharedProps, State> {
scalesPageToFit: true,
allowFileAccess: false,
saveFormDataDisabled: false,
originWhitelist: WebViewShared.defaultOriginWhitelist,
originWhitelist: defaultOriginWhitelist,
};
static isFileUploadSupported = async () => {
@ -78,7 +76,9 @@ class WebView extends React.Component<WebViewSharedProps, State> {
}
state = {
viewState: this.props.startInLoadingState ? WebViewState.LOADING : WebViewState.IDLE,
viewState: this.props.startInLoadingState
? WebViewState.LOADING
: WebViewState.IDLE,
lastErrorEvent: null,
};
@ -131,12 +131,14 @@ class WebView extends React.Component<WebViewSharedProps, State> {
const nativeConfig = this.props.nativeConfig || {};
const originWhitelist = (this.props.originWhitelist || []).map(
WebViewShared.originWhitelistToRegex,
);
let NativeWebView = nativeConfig.component || RNCWebView;
const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
this.onShouldStartLoadWithRequestCallback,
this.props.originWhitelist,
this.props.onShouldStartLoadWithRequest,
);
const webView = (
<NativeWebView
ref={this.webViewRef}
@ -157,6 +159,7 @@ class WebView extends React.Component<WebViewSharedProps, State> {
automaticallyAdjustContentInsets={
this.props.automaticallyAdjustContentInsets
}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onContentSizeChange={this.props.onContentSizeChange}
onLoadingStart={this.onLoadingStart}
onLoadingFinish={this.onLoadingFinish}
@ -170,7 +173,6 @@ class WebView extends React.Component<WebViewSharedProps, State> {
allowUniversalAccessFromFileURLs={
this.props.allowUniversalAccessFromFileURLs
}
originWhitelist={originWhitelist}
mixedContentMode={this.props.mixedContentMode}
saveFormDataDisabled={this.props.saveFormDataDisabled}
urlPrefixesForDefaultIntent={this.props.urlPrefixesForDefaultIntent}
@ -290,11 +292,24 @@ class WebView extends React.Component<WebViewSharedProps, State> {
const { onMessage } = this.props;
onMessage && onMessage(event);
};
onLoadingProgress = (event: WebViewProgressEvent) => {
const { onLoadProgress} = this.props;
const { onLoadProgress } = this.props;
onLoadProgress && onLoadProgress(event);
}
};
onShouldStartLoadWithRequestCallback = (
shouldStart: boolean,
url: string,
) => {
if (shouldStart) {
UIManager.dispatchViewManagerCommand(
this.getWebViewHandle(),
UIManager.RNCWebView.Commands.loadUrl,
[String(url)],
);
}
};
}
const RNCWebView = requireNativeComponent('RNCWebView');

View File

@ -25,7 +25,10 @@ import {
import invariant from 'fbjs/lib/invariant';
import keyMirror from 'fbjs/lib/keyMirror';
import WebViewShared from './WebViewShared';
import {
defaultOriginWhitelist,
createOnShouldStartLoadWithRequest,
} from './WebViewShared';
import type {
WebViewEvent,
WebViewError,
@ -130,7 +133,7 @@ class WebView extends React.Component<WebViewSharedProps, State> {
static defaultProps = {
useWebKit: true,
originWhitelist: WebViewShared.defaultOriginWhitelist,
originWhitelist: defaultOriginWhitelist,
};
static isFileUploadSupported = async () => {
@ -204,40 +207,11 @@ class WebView extends React.Component<WebViewSharedProps, State> {
const nativeConfig = this.props.nativeConfig || {};
let viewManager = nativeConfig.viewManager;
if (this.props.useWebKit) {
viewManager = viewManager || RNCWKWebViewManager;
} else {
viewManager = viewManager || RNCUIWebViewManager;
}
const compiledWhitelist = [
'about:blank',
...(this.props.originWhitelist || []),
].map(WebViewShared.originWhitelistToRegex);
const onShouldStartLoadWithRequest = event => {
let shouldStart = true;
const { url } = event.nativeEvent;
const origin = WebViewShared.extractOrigin(url);
const passesWhitelist = compiledWhitelist.some(x =>
new RegExp(x).test(origin),
);
shouldStart = shouldStart && passesWhitelist;
if (!passesWhitelist) {
Linking.openURL(url);
}
if (this.props.onShouldStartLoadWithRequest) {
shouldStart =
shouldStart &&
this.props.onShouldStartLoadWithRequest(event.nativeEvent);
}
invariant(viewManager != null, 'viewManager expected to be non-null');
viewManager.startLoadWithResult(
!!shouldStart,
event.nativeEvent.lockIdentifier,
);
};
const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
this.onShouldStartLoadWithRequestCallback,
this.props.originWhitelist,
this.props.onShouldStartLoadWithRequest,
);
const decelerationRate = processDecelerationRate(
this.props.decelerationRate,
@ -441,9 +415,25 @@ class WebView extends React.Component<WebViewSharedProps, State> {
};
_onLoadingProgress = (event: WebViewProgressEvent) => {
const {onLoadProgress} = this.props;
const { onLoadProgress } = this.props;
onLoadProgress && onLoadProgress(event);
}
};
onShouldStartLoadWithRequestCallback = (
shouldStart: boolean,
url: string,
lockIdentifier: number,
) => {
let viewManager = (this.props.nativeConfig || {}).viewManager;
if (this.props.useWebKit) {
viewManager = viewManager || RNCWKWebViewManager;
} else {
viewManager = viewManager || RNCUIWebViewManager;
}
invariant(viewManager != null, 'viewManager expected to be non-null');
viewManager.startLoadWithResult(!!shouldStart, lockIdentifier);
};
componentDidUpdate(prevProps: WebViewSharedProps) {
if (!(prevProps.useWebKit && this.props.useWebKit)) {

View File

@ -8,19 +8,58 @@
* @flow
*/
'use strict';
import escapeStringRegexp from 'escape-string-regexp';
import { Linking } from 'react-native';
import type {
WebViewNavigationEvent,
WebViewNavigation,
OnShouldStartLoadWithRequest,
} from './WebViewTypes';
const escapeStringRegexp = require('escape-string-regexp');
const defaultOriginWhitelist = ['http://*', 'https://*'];
const WebViewShared = {
defaultOriginWhitelist: ['http://*', 'https://*'],
extractOrigin: (url: string): string => {
const result = /^[A-Za-z0-9]+:(\/\/)?[^/]*/.exec(url);
return result === null ? '' : result[0];
},
originWhitelistToRegex: (originWhitelist: string): string => {
return escapeStringRegexp(originWhitelist).replace(/\\\*/g, '.*');
},
const extractOrigin = (url: string): string => {
const result = /^[A-Za-z0-9]+:(\/\/)?[^/]*/.exec(url);
return result === null ? '' : result[0];
};
module.exports = WebViewShared;
const originWhitelistToRegex = (originWhitelist: string): string =>
escapeStringRegexp(originWhitelist).replace(/\\\*/g, '.*');
const passesWhitelist = (compiledWhitelist: Array<string>, url: string) => {
const origin = extractOrigin(url);
return compiledWhitelist.some(x => new RegExp(x).test(origin));
};
const compileWhitelist = (
originWhitelist: ?$ReadOnlyArray<string>,
): Array<string> =>
['about:blank', ...(originWhitelist || [])].map(originWhitelistToRegex);
const createOnShouldStartLoadWithRequest = (
loadRequest: (
shouldStart: boolean,
url: string,
lockIdentifier: number,
) => void,
originWhitelist: ?$ReadOnlyArray<string>,
onShouldStartLoadWithRequest: ?OnShouldStartLoadWithRequest,
) => {
return ({ nativeEvent }: WebViewNavigationEvent) => {
let shouldStart = true;
const { url, lockIdentifier } = nativeEvent;
if (!passesWhitelist(compileWhitelist(originWhitelist), url)) {
Linking.openURL(url);
shouldStart = false
}
if (onShouldStartLoadWithRequest) {
shouldStart = onShouldStartLoadWithRequest(nativeEvent);
}
loadRequest(shouldStart, url, lockIdentifier);
};
};
export { defaultOriginWhitelist, createOnShouldStartLoadWithRequest };

View File

@ -10,12 +10,12 @@
'use strict';
import type {Node, Element, ComponentType} from 'react';
import type { Node, Element, ComponentType } from 'react';
import type {SyntheticEvent} from 'CoreEventTypes';
import type {EdgeInsetsProp} from 'EdgeInsetsPropType';
import type {ViewStyleProp} from 'StyleSheet';
import type {ViewProps} from 'ViewPropTypes';
import type { SyntheticEvent } from 'CoreEventTypes';
import type { EdgeInsetsProp } from 'EdgeInsetsPropType';
import type { ViewStyleProp } from 'StyleSheet';
import type { ViewProps } from 'ViewPropTypes';
export type WebViewNativeEvent = $ReadOnly<{|
url: string,
@ -23,12 +23,13 @@ export type WebViewNativeEvent = $ReadOnly<{|
title: string,
canGoBack: boolean,
canGoForward: boolean,
lockIdentifier: number,
|}>;
export type WebViewProgressEvent = $ReadOnly<{|
...WebViewNativeEvent,
progress: number,
|}>
...WebViewNativeEvent,
progress: number,
|}>;
export type WebViewNavigation = $ReadOnly<{|
...WebViewNativeEvent,
@ -118,22 +119,26 @@ export type WebViewSourceHtml = $ReadOnly<{|
export type WebViewSource = WebViewSourceUri | WebViewSourceHtml;
export type WebViewNativeConfig = $ReadOnly<{|
/*
/**
* The native component used to render the WebView.
*/
component?: ComponentType<WebViewSharedProps>,
/*
/**
* Set props directly on the native component WebView. Enables custom props which the
* original WebView doesn't pass through.
*/
props?: ?Object,
/*
/**
* Set the ViewManager to use for communication with the native side.
* @platform ios
*/
viewManager?: ?Object,
|}>;
export type OnShouldStartLoadWithRequest = (
event: WebViewNavigation,
) => boolean;
export type IOSWebViewProps = $ReadOnly<{|
/**
* If true, use WKWebView instead of UIWebView.
@ -205,17 +210,7 @@ export type IOSWebViewProps = $ReadOnly<{|
*
* @platform ios
*/
dataDetectorTypes?:
| ?DataDetectorTypes
| $ReadOnlyArray<DataDetectorTypes>,
/**
* Function that allows custom handling of any web view requests. Return
* `true` from the function to continue loading the request and `false`
* to stop loading.
* @platform ios
*/
onShouldStartLoadWithRequest?: (event: WebViewEvent) => mixed,
dataDetectorTypes?: ?DataDetectorTypes | $ReadOnlyArray<DataDetectorTypes>,
/**
* Boolean that determines whether HTML5 videos play inline or use the
@ -295,7 +290,7 @@ export type AndroidWebViewProps = $ReadOnly<{|
*/
saveFormDataDisabled?: ?boolean,
/*
/**
* Used on Android only, controls whether the given list of URL prefixes should
* make {@link com.facebook.react.views.webview.ReactWebViewClient} to launch a
* default activity intent for those URL instead of loading it within the webview.
@ -345,7 +340,7 @@ export type AndroidWebViewProps = $ReadOnly<{|
mixedContentMode?: ?('never' | 'always' | 'compatibility'),
|}>;
export type WebViewSharedProps = $ReadOnly<{|
export type WebViewSharedProps = $ReadOnly<{|
...ViewProps,
...IOSWebViewProps,
...AndroidWebViewProps,
@ -366,7 +361,11 @@ export type WebViewSharedProps = $ReadOnly<{|
/**
* Function that returns a view to show if there's an error.
*/
renderError: (errorDomain: ?string, errorCode: number, errorDesc: string) => Element<any>, // view to show if there's an error
renderError: (
errorDomain: ?string,
errorCode: number,
errorDesc: string,
) => Element<any>, // view to show if there's an error
/**
* Function that returns a loading indicator.
@ -457,6 +456,13 @@ export type WebViewSharedProps = $ReadOnly<{|
*/
originWhitelist?: $ReadOnlyArray<string>,
/**
* Function that allows custom handling of any web view requests. Return
* `true` from the function to continue loading the request and `false`
* to stop loading. The `navigationType` is always `other` on android.
*/
onShouldStartLoadWithRequest?: OnShouldStartLoadWithRequest,
/**
* Override the native component used to render the WebView. Enables a custom native
* WebView which uses the same JavaScript as the original WebView.