|
| 1 | +/** |
| 2 | + * Copyright (c) 2015-present, Facebook, Inc. |
| 3 | + * All rights reserved. |
| 4 | + * |
| 5 | + * This source code is licensed under the BSD-style license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. An additional grant |
| 7 | + * of patent rights can be found in the PATENTS file in the same directory. |
| 8 | + */ |
| 9 | + |
| 10 | +package com.github.alinz.reactnativewebviewbridge; |
| 11 | + |
| 12 | +import javax.annotation.Nullable; |
| 13 | + |
| 14 | +import java.util.Map; |
| 15 | + |
| 16 | +import android.graphics.Bitmap; |
| 17 | +import android.os.Build; |
| 18 | +import android.os.SystemClock; |
| 19 | +import android.text.TextUtils; |
| 20 | +import android.webkit.WebView; |
| 21 | +import android.webkit.WebViewClient; |
| 22 | + |
| 23 | +import com.github.alinz.reactnativewebviewbridge.events.TopLoadingErrorEvent; |
| 24 | +import com.github.alinz.reactnativewebviewbridge.events.TopLoadingFinishEvent; |
| 25 | +import com.github.alinz.reactnativewebviewbridge.events.TopLoadingStartEvent; |
| 26 | +import com.facebook.react.bridge.Arguments; |
| 27 | +import com.facebook.react.bridge.LifecycleEventListener; |
| 28 | +import com.facebook.react.bridge.ReactContext; |
| 29 | +import com.facebook.react.bridge.ReadableArray; |
| 30 | +import com.facebook.react.bridge.WritableMap; |
| 31 | +import com.facebook.react.common.MapBuilder; |
| 32 | +import com.facebook.react.common.build.ReactBuildConfig; |
| 33 | +import com.facebook.react.uimanager.annotations.ReactProp; |
| 34 | +import com.facebook.react.uimanager.SimpleViewManager; |
| 35 | +import com.facebook.react.uimanager.ThemedReactContext; |
| 36 | +import com.facebook.react.uimanager.UIManagerModule; |
| 37 | +import com.facebook.react.uimanager.events.Event; |
| 38 | +import com.facebook.react.uimanager.events.EventDispatcher; |
| 39 | + |
| 40 | +/** |
| 41 | + * Manages instances of {@link WebView} |
| 42 | + * |
| 43 | + * Can accept following commands: |
| 44 | + * - GO_BACK |
| 45 | + * - GO_FORWARD |
| 46 | + * - RELOAD |
| 47 | + * |
| 48 | + * {@link WebView} instances could emit following direct events: |
| 49 | + * - topLoadingFinish |
| 50 | + * - topLoadingStart |
| 51 | + * - topLoadingError |
| 52 | + * |
| 53 | + * Each event will carry the following properties: |
| 54 | + * - target - view's react tag |
| 55 | + * - url - url set for the webview |
| 56 | + * - loading - whether webview is in a loading state |
| 57 | + * - title - title of the current page |
| 58 | + * - canGoBack - boolean, whether there is anything on a history stack to go back |
| 59 | + * - canGoForward - boolean, whether it is possible to request GO_FORWARD command |
| 60 | + */ |
| 61 | +public class ReactWebViewBridgeManager extends SimpleViewManager<WebView> { |
| 62 | + |
| 63 | + private static final String REACT_CLASS = "RCTWebView"; |
| 64 | + |
| 65 | + private static final String HTML_ENCODING = "UTF-8"; |
| 66 | + private static final String HTML_MIME_TYPE = "text/html; charset=utf-8"; |
| 67 | + |
| 68 | + public static final int COMMAND_GO_BACK = 1; |
| 69 | + public static final int COMMAND_GO_FORWARD = 2; |
| 70 | + public static final int COMMAND_RELOAD = 3; |
| 71 | + |
| 72 | + // Use `webView.loadUrl("about:blank")` to reliably reset the view |
| 73 | + // state and release page resources (including any running JavaScript). |
| 74 | + private static final String BLANK_URL = "about:blank"; |
| 75 | + |
| 76 | + private WebViewConfig mWebViewConfig; |
| 77 | + |
| 78 | + private static class ReactWebViewClient extends WebViewClient { |
| 79 | + |
| 80 | + private boolean mLastLoadFailed = false; |
| 81 | + |
| 82 | + @Override |
| 83 | + public void onPageFinished(WebView webView, String url) { |
| 84 | + super.onPageFinished(webView, url); |
| 85 | + |
| 86 | + if (!mLastLoadFailed) { |
| 87 | + ReactWebView reactWebView = (ReactWebView) webView; |
| 88 | + reactWebView.callInjectedJavaScript(); |
| 89 | + emitFinishEvent(webView, url); |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + @Override |
| 94 | + public void onPageStarted(WebView webView, String url, Bitmap favicon) { |
| 95 | + super.onPageStarted(webView, url, favicon); |
| 96 | + mLastLoadFailed = false; |
| 97 | + |
| 98 | + dispatchEvent( |
| 99 | + webView, |
| 100 | + new TopLoadingStartEvent( |
| 101 | + webView.getId(), |
| 102 | + SystemClock.uptimeMillis(), |
| 103 | + createWebViewEvent(webView, url))); |
| 104 | + } |
| 105 | + |
| 106 | + @Override |
| 107 | + public void onReceivedError( |
| 108 | + WebView webView, |
| 109 | + int errorCode, |
| 110 | + String description, |
| 111 | + String failingUrl) { |
| 112 | + super.onReceivedError(webView, errorCode, description, failingUrl); |
| 113 | + mLastLoadFailed = true; |
| 114 | + |
| 115 | + // In case of an error JS side expect to get a finish event first, and then get an error event |
| 116 | + // Android WebView does it in the opposite way, so we need to simulate that behavior |
| 117 | + emitFinishEvent(webView, failingUrl); |
| 118 | + |
| 119 | + WritableMap eventData = createWebViewEvent(webView, failingUrl); |
| 120 | + eventData.putDouble("code", errorCode); |
| 121 | + eventData.putString("description", description); |
| 122 | + |
| 123 | + dispatchEvent( |
| 124 | + webView, |
| 125 | + new TopLoadingErrorEvent(webView.getId(), SystemClock.uptimeMillis(), eventData)); |
| 126 | + } |
| 127 | + |
| 128 | + @Override |
| 129 | + public void doUpdateVisitedHistory(WebView webView, String url, boolean isReload) { |
| 130 | + super.doUpdateVisitedHistory(webView, url, isReload); |
| 131 | + |
| 132 | + dispatchEvent( |
| 133 | + webView, |
| 134 | + new TopLoadingStartEvent( |
| 135 | + webView.getId(), |
| 136 | + SystemClock.uptimeMillis(), |
| 137 | + createWebViewEvent(webView, url))); |
| 138 | + } |
| 139 | + |
| 140 | + private void emitFinishEvent(WebView webView, String url) { |
| 141 | + dispatchEvent( |
| 142 | + webView, |
| 143 | + new TopLoadingFinishEvent( |
| 144 | + webView.getId(), |
| 145 | + SystemClock.uptimeMillis(), |
| 146 | + createWebViewEvent(webView, url))); |
| 147 | + } |
| 148 | + |
| 149 | + private static void dispatchEvent(WebView webView, Event event) { |
| 150 | + ReactContext reactContext = (ReactContext) webView.getContext(); |
| 151 | + EventDispatcher eventDispatcher = |
| 152 | + reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); |
| 153 | + eventDispatcher.dispatchEvent(event); |
| 154 | + } |
| 155 | + |
| 156 | + private WritableMap createWebViewEvent(WebView webView, String url) { |
| 157 | + WritableMap event = Arguments.createMap(); |
| 158 | + event.putDouble("target", webView.getId()); |
| 159 | + // Don't use webView.getUrl() here, the URL isn't updated to the new value yet in callbacks |
| 160 | + // like onPageFinished |
| 161 | + event.putString("url", url); |
| 162 | + event.putBoolean("loading", !mLastLoadFailed && webView.getProgress() != 100); |
| 163 | + event.putString("title", webView.getTitle()); |
| 164 | + event.putBoolean("canGoBack", webView.canGoBack()); |
| 165 | + event.putBoolean("canGoForward", webView.canGoForward()); |
| 166 | + return event; |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + /** |
| 171 | + * Subclass of {@link WebView} that implements {@link LifecycleEventListener} interface in order |
| 172 | + * to call {@link WebView#destroy} on activty destroy event and also to clear the client |
| 173 | + */ |
| 174 | + private static class ReactWebView extends WebView implements LifecycleEventListener { |
| 175 | + private @Nullable String injectedJS; |
| 176 | + |
| 177 | + /** |
| 178 | + * WebView must be created with an context of the current activity |
| 179 | + * |
| 180 | + * Activity Context is required for creation of dialogs internally by WebView |
| 181 | + * Reactive Native needed for access to ReactNative internal system functionality |
| 182 | + * |
| 183 | + */ |
| 184 | + public ReactWebView(ThemedReactContext reactContext) { |
| 185 | + super(reactContext); |
| 186 | + } |
| 187 | + |
| 188 | + @Override |
| 189 | + public void onHostResume() { |
| 190 | + // do nothing |
| 191 | + } |
| 192 | + |
| 193 | + @Override |
| 194 | + public void onHostPause() { |
| 195 | + // do nothing |
| 196 | + } |
| 197 | + |
| 198 | + @Override |
| 199 | + public void onHostDestroy() { |
| 200 | + cleanupCallbacksAndDestroy(); |
| 201 | + } |
| 202 | + |
| 203 | + public void setInjectedJavaScript(@Nullable String js) { |
| 204 | + injectedJS = js; |
| 205 | + } |
| 206 | + |
| 207 | + public void callInjectedJavaScript() { |
| 208 | + if ( |
| 209 | + getSettings().getJavaScriptEnabled() && |
| 210 | + injectedJS != null && |
| 211 | + !TextUtils.isEmpty(injectedJS)) { |
| 212 | + loadUrl("javascript:(function() {\n" + injectedJS + ";\n})();"); |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + private void cleanupCallbacksAndDestroy() { |
| 217 | + setWebViewClient(null); |
| 218 | + destroy(); |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + public ReactWebViewBridgeManager() { |
| 223 | + mWebViewConfig = new WebViewConfig() { |
| 224 | + public void configWebView(WebView webView) { |
| 225 | + } |
| 226 | + }; |
| 227 | + } |
| 228 | + |
| 229 | + public ReactWebViewBridgeManager(WebViewConfig webViewConfig) { |
| 230 | + mWebViewConfig = webViewConfig; |
| 231 | + } |
| 232 | + |
| 233 | + @Override |
| 234 | + public String getName() { |
| 235 | + return REACT_CLASS; |
| 236 | + } |
| 237 | + |
| 238 | + @Override |
| 239 | + protected WebView createViewInstance(ThemedReactContext reactContext) { |
| 240 | + ReactWebView webView = new ReactWebView(reactContext); |
| 241 | + reactContext.addLifecycleEventListener(webView); |
| 242 | + mWebViewConfig.configWebView(webView); |
| 243 | + |
| 244 | + if (ReactBuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { |
| 245 | + WebView.setWebContentsDebuggingEnabled(true); |
| 246 | + } |
| 247 | + |
| 248 | + return webView; |
| 249 | + } |
| 250 | + |
| 251 | + @ReactProp(name = "javaScriptEnabled") |
| 252 | + public void setJavaScriptEnabled(WebView view, boolean enabled) { |
| 253 | + view.getSettings().setJavaScriptEnabled(enabled); |
| 254 | + } |
| 255 | + |
| 256 | + @ReactProp(name = "domStorageEnabled") |
| 257 | + public void setDomStorageEnabled(WebView view, boolean enabled) { |
| 258 | + view.getSettings().setDomStorageEnabled(enabled); |
| 259 | + } |
| 260 | + |
| 261 | + @ReactProp(name = "userAgent") |
| 262 | + public void setUserAgent(WebView view, @Nullable String userAgent) { |
| 263 | + if (userAgent != null) { |
| 264 | + // TODO(8496850): Fix incorrect behavior when property is unset (uA == null) |
| 265 | + view.getSettings().setUserAgentString(userAgent); |
| 266 | + } |
| 267 | + } |
| 268 | + |
| 269 | + @ReactProp(name = "injectedJavaScript") |
| 270 | + public void setInjectedJavaScript(WebView view, @Nullable String injectedJavaScript) { |
| 271 | + ((ReactWebView) view).setInjectedJavaScript(injectedJavaScript); |
| 272 | + } |
| 273 | + |
| 274 | + @ReactProp(name = "html") |
| 275 | + public void setHtml(WebView view, @Nullable String html) { |
| 276 | + if (html != null) { |
| 277 | + view.loadData(html, HTML_MIME_TYPE, HTML_ENCODING); |
| 278 | + } else { |
| 279 | + view.loadUrl(BLANK_URL); |
| 280 | + } |
| 281 | + } |
| 282 | + |
| 283 | + @ReactProp(name = "url") |
| 284 | + public void setUrl(WebView view, @Nullable String url) { |
| 285 | + // TODO(8495359): url and html are coupled as they both call loadUrl, therefore in case when |
| 286 | + // property url is removed in favor of property html being added in single transaction we may |
| 287 | + // end up in a state when blank url is loaded as it depends on the order of update operations! |
| 288 | + |
| 289 | + String currentUrl = view.getUrl(); |
| 290 | + if (currentUrl != null && currentUrl.equals(url)) { |
| 291 | + // We are already loading it so no need to stomp over it and start again |
| 292 | + return; |
| 293 | + } |
| 294 | + if (url != null) { |
| 295 | + view.loadUrl(url); |
| 296 | + } else { |
| 297 | + view.loadUrl(BLANK_URL); |
| 298 | + } |
| 299 | + } |
| 300 | + |
| 301 | + @Override |
| 302 | + protected void addEventEmitters(ThemedReactContext reactContext, WebView view) { |
| 303 | + // Do not register default touch emitter and let WebView implementation handle touches |
| 304 | + view.setWebViewClient(new ReactWebViewClient()); |
| 305 | + } |
| 306 | + |
| 307 | + @Override |
| 308 | + public @Nullable Map<String, Integer> getCommandsMap() { |
| 309 | + return MapBuilder.of( |
| 310 | + "goBack", COMMAND_GO_BACK, |
| 311 | + "goForward", COMMAND_GO_FORWARD, |
| 312 | + "reload", COMMAND_RELOAD); |
| 313 | + } |
| 314 | + |
| 315 | + @Override |
| 316 | + public void receiveCommand(WebView root, int commandId, @Nullable ReadableArray args) { |
| 317 | + switch (commandId) { |
| 318 | + case COMMAND_GO_BACK: |
| 319 | + root.goBack(); |
| 320 | + break; |
| 321 | + case COMMAND_GO_FORWARD: |
| 322 | + root.goForward(); |
| 323 | + break; |
| 324 | + case COMMAND_RELOAD: |
| 325 | + root.reload(); |
| 326 | + break; |
| 327 | + } |
| 328 | + } |
| 329 | + |
| 330 | + @Override |
| 331 | + public void onDropViewInstance(WebView webView) { |
| 332 | + super.onDropViewInstance(webView); |
| 333 | + ((ThemedReactContext) webView.getContext()).removeLifecycleEventListener((ReactWebView) webView); |
| 334 | + ((ReactWebView) webView).cleanupCallbacksAndDestroy(); |
| 335 | + } |
| 336 | +} |
0 commit comments