diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..84054d5 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + diff --git a/android/src/main/com/github/alinz/reactnativewebviewbridge/ReactWebViewManager.java b/android/src/main/com/github/alinz/reactnativewebviewbridge/ReactWebViewManager.java new file mode 100644 index 0000000..79126e4 --- /dev/null +++ b/android/src/main/com/github/alinz/reactnativewebviewbridge/ReactWebViewManager.java @@ -0,0 +1,336 @@ +/** + * 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.github.alinz.reactnativewebviewbridge; + +import javax.annotation.Nullable; + +import java.util.Map; + +import android.graphics.Bitmap; +import android.os.Build; +import android.os.SystemClock; +import android.text.TextUtils; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import com.github.alinz.reactnativewebviewbridge.events.TopLoadingErrorEvent; +import com.github.alinz.reactnativewebviewbridge.events.TopLoadingFinishEvent; +import com.github.alinz.reactnativewebviewbridge.events.TopLoadingStartEvent; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.common.MapBuilder; +import com.facebook.react.common.build.ReactBuildConfig; +import com.facebook.react.uimanager.annotations.ReactProp; +import com.facebook.react.uimanager.SimpleViewManager; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.UIManagerModule; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.EventDispatcher; + +/** + * Manages instances of {@link WebView} + * + * Can accept following commands: + * - GO_BACK + * - GO_FORWARD + * - RELOAD + * + * {@link WebView} instances could emit following direct events: + * - topLoadingFinish + * - topLoadingStart + * - topLoadingError + * + * Each event will carry the following properties: + * - target - view's react tag + * - url - url set for the webview + * - loading - whether webview is in a loading state + * - title - title of the current page + * - canGoBack - boolean, whether there is anything on a history stack to go back + * - canGoForward - boolean, whether it is possible to request GO_FORWARD command + */ +public class ReactWebViewBridgeManager extends SimpleViewManager { + + private static final String REACT_CLASS = "RCTWebView"; + + private static final String HTML_ENCODING = "UTF-8"; + private static final String HTML_MIME_TYPE = "text/html; charset=utf-8"; + + public static final int COMMAND_GO_BACK = 1; + public static final int COMMAND_GO_FORWARD = 2; + public static final int COMMAND_RELOAD = 3; + + // Use `webView.loadUrl("about:blank")` to reliably reset the view + // state and release page resources (including any running JavaScript). + private static final String BLANK_URL = "about:blank"; + + private WebViewConfig mWebViewConfig; + + private static class ReactWebViewClient extends WebViewClient { + + private boolean mLastLoadFailed = false; + + @Override + public void onPageFinished(WebView webView, String url) { + super.onPageFinished(webView, url); + + if (!mLastLoadFailed) { + ReactWebView reactWebView = (ReactWebView) webView; + reactWebView.callInjectedJavaScript(); + emitFinishEvent(webView, url); + } + } + + @Override + public void onPageStarted(WebView webView, String url, Bitmap favicon) { + super.onPageStarted(webView, url, favicon); + mLastLoadFailed = false; + + dispatchEvent( + webView, + new TopLoadingStartEvent( + webView.getId(), + SystemClock.uptimeMillis(), + createWebViewEvent(webView, url))); + } + + @Override + public void onReceivedError( + WebView webView, + int errorCode, + String description, + String failingUrl) { + super.onReceivedError(webView, errorCode, description, failingUrl); + mLastLoadFailed = true; + + // In case of an error JS side expect to get a finish event first, and then get an error event + // Android WebView does it in the opposite way, so we need to simulate that behavior + emitFinishEvent(webView, failingUrl); + + WritableMap eventData = createWebViewEvent(webView, failingUrl); + eventData.putDouble("code", errorCode); + eventData.putString("description", description); + + dispatchEvent( + webView, + new TopLoadingErrorEvent(webView.getId(), SystemClock.uptimeMillis(), eventData)); + } + + @Override + public void doUpdateVisitedHistory(WebView webView, String url, boolean isReload) { + super.doUpdateVisitedHistory(webView, url, isReload); + + dispatchEvent( + webView, + new TopLoadingStartEvent( + webView.getId(), + SystemClock.uptimeMillis(), + createWebViewEvent(webView, url))); + } + + private void emitFinishEvent(WebView webView, String url) { + dispatchEvent( + webView, + new TopLoadingFinishEvent( + webView.getId(), + SystemClock.uptimeMillis(), + createWebViewEvent(webView, url))); + } + + private static void dispatchEvent(WebView webView, Event event) { + ReactContext reactContext = (ReactContext) webView.getContext(); + EventDispatcher eventDispatcher = + reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); + eventDispatcher.dispatchEvent(event); + } + + private WritableMap createWebViewEvent(WebView webView, String url) { + WritableMap event = Arguments.createMap(); + event.putDouble("target", webView.getId()); + // Don't use webView.getUrl() here, the URL isn't updated to the new value yet in callbacks + // like onPageFinished + event.putString("url", url); + event.putBoolean("loading", !mLastLoadFailed && webView.getProgress() != 100); + event.putString("title", webView.getTitle()); + event.putBoolean("canGoBack", webView.canGoBack()); + event.putBoolean("canGoForward", webView.canGoForward()); + return event; + } + } + + /** + * Subclass of {@link WebView} that implements {@link LifecycleEventListener} interface in order + * to call {@link WebView#destroy} on activty destroy event and also to clear the client + */ + private static class ReactWebView extends WebView implements LifecycleEventListener { + private @Nullable String injectedJS; + + /** + * WebView must be created with an context of the current activity + * + * Activity Context is required for creation of dialogs internally by WebView + * Reactive Native needed for access to ReactNative internal system functionality + * + */ + public ReactWebView(ThemedReactContext reactContext) { + super(reactContext); + } + + @Override + public void onHostResume() { + // do nothing + } + + @Override + public void onHostPause() { + // do nothing + } + + @Override + public void onHostDestroy() { + cleanupCallbacksAndDestroy(); + } + + public void setInjectedJavaScript(@Nullable String js) { + injectedJS = js; + } + + public void callInjectedJavaScript() { + if ( + getSettings().getJavaScriptEnabled() && + injectedJS != null && + !TextUtils.isEmpty(injectedJS)) { + loadUrl("javascript:(function() {\n" + injectedJS + ";\n})();"); + } + } + + private void cleanupCallbacksAndDestroy() { + setWebViewClient(null); + destroy(); + } + } + + public ReactWebViewBridgeManager() { + mWebViewConfig = new WebViewConfig() { + public void configWebView(WebView webView) { + } + }; + } + + public ReactWebViewBridgeManager(WebViewConfig webViewConfig) { + mWebViewConfig = webViewConfig; + } + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + protected WebView createViewInstance(ThemedReactContext reactContext) { + ReactWebView webView = new ReactWebView(reactContext); + reactContext.addLifecycleEventListener(webView); + mWebViewConfig.configWebView(webView); + + if (ReactBuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + WebView.setWebContentsDebuggingEnabled(true); + } + + return webView; + } + + @ReactProp(name = "javaScriptEnabled") + public void setJavaScriptEnabled(WebView view, boolean enabled) { + view.getSettings().setJavaScriptEnabled(enabled); + } + + @ReactProp(name = "domStorageEnabled") + public void setDomStorageEnabled(WebView view, boolean enabled) { + view.getSettings().setDomStorageEnabled(enabled); + } + + @ReactProp(name = "userAgent") + public void setUserAgent(WebView view, @Nullable String userAgent) { + if (userAgent != null) { + // TODO(8496850): Fix incorrect behavior when property is unset (uA == null) + view.getSettings().setUserAgentString(userAgent); + } + } + + @ReactProp(name = "injectedJavaScript") + public void setInjectedJavaScript(WebView view, @Nullable String injectedJavaScript) { + ((ReactWebView) view).setInjectedJavaScript(injectedJavaScript); + } + + @ReactProp(name = "html") + public void setHtml(WebView view, @Nullable String html) { + if (html != null) { + view.loadData(html, HTML_MIME_TYPE, HTML_ENCODING); + } else { + view.loadUrl(BLANK_URL); + } + } + + @ReactProp(name = "url") + public void setUrl(WebView view, @Nullable String url) { + // TODO(8495359): url and html are coupled as they both call loadUrl, therefore in case when + // property url is removed in favor of property html being added in single transaction we may + // end up in a state when blank url is loaded as it depends on the order of update operations! + + String currentUrl = view.getUrl(); + if (currentUrl != null && currentUrl.equals(url)) { + // We are already loading it so no need to stomp over it and start again + return; + } + if (url != null) { + view.loadUrl(url); + } else { + view.loadUrl(BLANK_URL); + } + } + + @Override + protected void addEventEmitters(ThemedReactContext reactContext, WebView view) { + // Do not register default touch emitter and let WebView implementation handle touches + view.setWebViewClient(new ReactWebViewClient()); + } + + @Override + public @Nullable Map getCommandsMap() { + return MapBuilder.of( + "goBack", COMMAND_GO_BACK, + "goForward", COMMAND_GO_FORWARD, + "reload", COMMAND_RELOAD); + } + + @Override + public void receiveCommand(WebView root, int commandId, @Nullable ReadableArray args) { + switch (commandId) { + case COMMAND_GO_BACK: + root.goBack(); + break; + case COMMAND_GO_FORWARD: + root.goForward(); + break; + case COMMAND_RELOAD: + root.reload(); + break; + } + } + + @Override + public void onDropViewInstance(WebView webView) { + super.onDropViewInstance(webView); + ((ThemedReactContext) webView.getContext()).removeLifecycleEventListener((ReactWebView) webView); + ((ReactWebView) webView).cleanupCallbacksAndDestroy(); + } +} diff --git a/android/src/main/com/github/alinz/reactnativewebviewbridge/WebViewConfig.java b/android/src/main/com/github/alinz/reactnativewebviewbridge/WebViewConfig.java new file mode 100644 index 0000000..a47cee8 --- /dev/null +++ b/android/src/main/com/github/alinz/reactnativewebviewbridge/WebViewConfig.java @@ -0,0 +1,20 @@ +/** + * 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.github.alinz.reactnativewebviewbridge; + +import android.webkit.WebView; + +/** + * Implement this interface in order to config your {@link WebView}. An instance of that + * implementation will have to be given as a constructor argument to {@link ReactWebViewManager}. + */ +public interface WebViewConfig { + void configWebView(WebView webView); +} diff --git a/android/src/main/com/github/alinz/reactnativewebviewbridge/events/TopLoadingErrorEvent.java b/android/src/main/com/github/alinz/reactnativewebviewbridge/events/TopLoadingErrorEvent.java new file mode 100644 index 0000000..b4dd2f0 --- /dev/null +++ b/android/src/main/com/github/alinz/reactnativewebviewbridge/events/TopLoadingErrorEvent.java @@ -0,0 +1,49 @@ +/** + * 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.github.alinz.reactnativewebviewbridge.events; + +import com.facebook.react.bridge.WritableMap; +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 TopLoadingErrorEvent extends Event { + + public static final String EVENT_NAME = "topLoadingError"; + private WritableMap mEventData; + + public TopLoadingErrorEvent(int viewId, long timestampMs, WritableMap eventData) { + super(viewId, timestampMs); + mEventData = eventData; + } + + @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) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), mEventData); + } +} diff --git a/android/src/main/com/github/alinz/reactnativewebviewbridge/events/TopLoadingFinishEvent.java b/android/src/main/com/github/alinz/reactnativewebviewbridge/events/TopLoadingFinishEvent.java new file mode 100644 index 0000000..829c305 --- /dev/null +++ b/android/src/main/com/github/alinz/reactnativewebviewbridge/events/TopLoadingFinishEvent.java @@ -0,0 +1,49 @@ +/** + * 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.github.alinz.reactnativewebviewbridge.events; + +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +/** + * Event emitted when loading is completed. + */ +public class TopLoadingFinishEvent extends Event { + + public static final String EVENT_NAME = "topLoadingFinish"; + private WritableMap mEventData; + + public TopLoadingFinishEvent(int viewId, long timestampMs, WritableMap eventData) { + super(viewId, timestampMs); + mEventData = eventData; + } + + @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) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), mEventData); + } +} diff --git a/android/src/main/com/github/alinz/reactnativewebviewbridge/events/TopLoadingStartEvent.java b/android/src/main/com/github/alinz/reactnativewebviewbridge/events/TopLoadingStartEvent.java new file mode 100644 index 0000000..21d1839 --- /dev/null +++ b/android/src/main/com/github/alinz/reactnativewebviewbridge/events/TopLoadingStartEvent.java @@ -0,0 +1,49 @@ +/** + * 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.github.alinz.reactnativewebviewbridge.events; + +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +/** + * Event emitted when loading has started + */ +public class TopLoadingStartEvent extends Event { + + public static final String EVENT_NAME = "topLoadingStart"; + private WritableMap mEventData; + + public TopLoadingStartEvent(int viewId, long timestampMs, WritableMap eventData) { + super(viewId, timestampMs); + mEventData = eventData; + } + + @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) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), mEventData); + } +}