diff --git a/Makefile b/Makefile index 810be08a3..b56cc71fb 100644 --- a/Makefile +++ b/Makefile @@ -76,6 +76,10 @@ post-install: @# Need to copy custom RNDocumentPicker.m that implements direct access to the document picker in iOS @cp ./native_modules/RNDocumentPicker.m node_modules/react-native-document-picker/ios/RNDocumentPicker/RNDocumentPicker.m + @# Need to copy custom RNCWebViewManager.java and RNCWEKWebView.m that implements IWA support for the WebView to avoid forking the lib + @cp ./native_modules/RNCWebViewManager.java node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java + @cp ./native_modules/RNCWKWebView.m node_modules/react-native-webview/ios/RNCWKWebView.m + @rm -f node_modules/intl/.babelrc @# Hack to get react-intl and its dependencies to work with react-native @# Based off of https://github.com/este/este/blob/master/gulp/native-fix.js diff --git a/native_modules/RNCWKWebView.m b/native_modules/RNCWKWebView.m new file mode 100644 index 000000000..7d5879866 --- /dev/null +++ b/native_modules/RNCWKWebView.m @@ -0,0 +1,693 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RNCWKWebView.h" +#import +#import +#import "RNCWKProcessPoolManager.h" +#import + +#import "objc/runtime.h" + +static NSTimer *keyboardTimer; +static NSString *const MessageHandlerName = @"ReactNativeWebView"; +static NSURLCredential* clientAuthenticationCredential; + +// runtime trick to remove WKWebView keyboard default toolbar +// see: http://stackoverflow.com/questions/19033292/ios-7-uiwebview-keyboard-issue/19042279#19042279 +@interface _SwizzleHelperWK : NSObject @end +@implementation _SwizzleHelperWK +-(id)inputAccessoryView +{ + return nil; +} +@end + +@interface RNCWKWebView () +@property (nonatomic, copy) RCTDirectEventBlock onLoadingStart; +@property (nonatomic, copy) RCTDirectEventBlock onLoadingFinish; +@property (nonatomic, copy) RCTDirectEventBlock onLoadingError; +@property (nonatomic, copy) RCTDirectEventBlock onLoadingProgress; +@property (nonatomic, copy) RCTDirectEventBlock onShouldStartLoadWithRequest; +@property (nonatomic, copy) RCTDirectEventBlock onMessage; +@property (nonatomic, copy) WKWebView *webView; +@end + +@implementation RNCWKWebView +{ + UIColor * _savedBackgroundColor; + BOOL _savedHideKeyboardAccessoryView; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if ((self = [super initWithFrame:frame])) { + super.backgroundColor = [UIColor clearColor]; + _bounces = YES; + _scrollEnabled = YES; + _showsHorizontalScrollIndicator = YES; + _showsVerticalScrollIndicator = YES; + _automaticallyAdjustContentInsets = YES; + _contentInset = UIEdgeInsetsZero; + } + + // Workaround for a keyboard dismissal bug present in iOS 12 + // https://openradar.appspot.com/radar?id=5018321736957952 + if (@available(iOS 12.0, *)) { + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(keyboardWillHide) + name:UIKeyboardWillHideNotification object:nil]; + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(keyboardWillShow) + name:UIKeyboardWillShowNotification object:nil]; + } + + return self; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +/** + * See https://stackoverflow.com/questions/25713069/why-is-wkwebview-not-opening-links-with-target-blank/25853806#25853806 for details. + */ +- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures +{ + if (!navigationAction.targetFrame.isMainFrame) { + [webView loadRequest:navigationAction.request]; + } + return nil; +} + +- (void)didMoveToWindow +{ + if (self.window != nil && _webView == nil) { + WKWebViewConfiguration *wkWebViewConfig = [WKWebViewConfiguration new]; + if (_incognito) { + wkWebViewConfig.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore]; + } else if (_cacheEnabled) { + wkWebViewConfig.websiteDataStore = [WKWebsiteDataStore defaultDataStore]; + } + if(self.useSharedProcessPool) { + wkWebViewConfig.processPool = [[RNCWKProcessPoolManager sharedManager] sharedProcessPool]; + } + wkWebViewConfig.userContentController = [WKUserContentController new]; + + if (_messagingEnabled) { + [wkWebViewConfig.userContentController addScriptMessageHandler:self name:MessageHandlerName]; + + NSString *source = [NSString stringWithFormat: + @"window.%@ = {" + " postMessage: function (data) {" + " window.webkit.messageHandlers.%@.postMessage(String(data));" + " }" + "};", MessageHandlerName, MessageHandlerName + ]; + + WKUserScript *script = [[WKUserScript alloc] initWithSource:source injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]; + [wkWebViewConfig.userContentController addUserScript:script]; + } + + wkWebViewConfig.allowsInlineMediaPlayback = _allowsInlineMediaPlayback; +#if WEBKIT_IOS_10_APIS_AVAILABLE + wkWebViewConfig.mediaTypesRequiringUserActionForPlayback = _mediaPlaybackRequiresUserAction + ? WKAudiovisualMediaTypeAll + : WKAudiovisualMediaTypeNone; + wkWebViewConfig.dataDetectorTypes = _dataDetectorTypes; +#else + wkWebViewConfig.mediaPlaybackRequiresUserAction = _mediaPlaybackRequiresUserAction; +#endif + + _webView = [[WKWebView alloc] initWithFrame:self.bounds configuration: wkWebViewConfig]; + _webView.scrollView.delegate = self; + _webView.UIDelegate = self; + _webView.navigationDelegate = self; + _webView.scrollView.scrollEnabled = _scrollEnabled; + _webView.scrollView.pagingEnabled = _pagingEnabled; + _webView.scrollView.bounces = _bounces; + _webView.scrollView.showsHorizontalScrollIndicator = _showsHorizontalScrollIndicator; + _webView.scrollView.showsVerticalScrollIndicator = _showsVerticalScrollIndicator; + _webView.allowsLinkPreview = _allowsLinkPreview; + [_webView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew context:nil]; + _webView.allowsBackForwardNavigationGestures = _allowsBackForwardNavigationGestures; + + if (_userAgent) { + _webView.customUserAgent = _userAgent; + } +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 /* __IPHONE_11_0 */ + if ([_webView.scrollView respondsToSelector:@selector(setContentInsetAdjustmentBehavior:)]) { + _webView.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + } +#endif + + [self addSubview:_webView]; + [self setHideKeyboardAccessoryView: _savedHideKeyboardAccessoryView]; + [self visitSource]; + } +} + +// Update webview property when the component prop changes. +- (void)setAllowsBackForwardNavigationGestures:(BOOL)allowsBackForwardNavigationGestures { + _allowsBackForwardNavigationGestures = allowsBackForwardNavigationGestures; + _webView.allowsBackForwardNavigationGestures = _allowsBackForwardNavigationGestures; +} + + +- (void)removeFromSuperview +{ + if (_webView) { + [_webView.configuration.userContentController removeScriptMessageHandlerForName:MessageHandlerName]; + [_webView removeObserver:self forKeyPath:@"estimatedProgress"]; + [_webView removeFromSuperview]; + _webView.scrollView.delegate = nil; + _webView = nil; + } + + [super removeFromSuperview]; +} + +-(void)keyboardWillHide +{ + keyboardTimer = [NSTimer scheduledTimerWithTimeInterval:0 target:self selector:@selector(keyboardDisplacementFix) userInfo:nil repeats:false]; + [[NSRunLoop mainRunLoop] addTimer:keyboardTimer forMode:NSRunLoopCommonModes]; +} +-(void)keyboardWillShow +{ + if (keyboardTimer != nil) { + [keyboardTimer invalidate]; + } +} +-(void)keyboardDisplacementFix +{ + // Additional viewport checks to prevent unintentional scrolls + UIScrollView *scrollView = self.webView.scrollView; + double maxContentOffset = scrollView.contentSize.height - scrollView.frame.size.height; + if (maxContentOffset < 0) { + maxContentOffset = 0; + } + if (scrollView.contentOffset.y > maxContentOffset) { + // https://stackoverflow.com/a/9637807/824966 + [UIView animateWithDuration:.25 animations:^{ + scrollView.contentOffset = CGPointMake(0, maxContentOffset); + }]; + } +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{ + if ([keyPath isEqual:@"estimatedProgress"] && object == self.webView) { + if(_onLoadingProgress){ + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary:@{@"progress":[NSNumber numberWithDouble:self.webView.estimatedProgress]}]; + _onLoadingProgress(event); + } + }else{ + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; + } +} + +- (void)setBackgroundColor:(UIColor *)backgroundColor +{ + _savedBackgroundColor = backgroundColor; + if (_webView == nil) { + return; + } + + CGFloat alpha = CGColorGetAlpha(backgroundColor.CGColor); + self.opaque = _webView.opaque = (alpha == 1.0); + _webView.scrollView.backgroundColor = backgroundColor; + _webView.backgroundColor = backgroundColor; +} + +/** + * This method is called whenever JavaScript running within the web view calls: + * - window.webkit.messageHandlers[MessageHandlerName].postMessage + */ +- (void)userContentController:(WKUserContentController *)userContentController + didReceiveScriptMessage:(WKScriptMessage *)message +{ + if (_onMessage != nil) { + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary: @{@"data": message.body}]; + _onMessage(event); + } +} + +- (void)setSource:(NSDictionary *)source +{ + if (![_source isEqualToDictionary:source]) { + _source = [source copy]; + + if (_webView != nil) { + [self visitSource]; + } + } +} + +- (void)setContentInset:(UIEdgeInsets)contentInset +{ + _contentInset = contentInset; + [RCTView autoAdjustInsetsForView:self + withScrollView:_webView.scrollView + updateOffset:NO]; +} + +- (void)refreshContentInset +{ + [RCTView autoAdjustInsetsForView:self + withScrollView:_webView.scrollView + updateOffset:YES]; +} + +- (void)visitSource +{ + // Check for a static html source first + NSString *html = [RCTConvert NSString:_source[@"html"]]; + if (html) { + NSURL *baseURL = [RCTConvert NSURL:_source[@"baseUrl"]]; + if (!baseURL) { + baseURL = [NSURL URLWithString:@"about:blank"]; + } + [_webView loadHTMLString:html baseURL:baseURL]; + return; + } + + NSURLRequest *request = [RCTConvert NSURLRequest:_source]; + // Because of the way React works, as pages redirect, we actually end up + // passing the redirect urls back here, so we ignore them if trying to load + // the same url. We'll expose a call to 'reload' to allow a user to load + // the existing page. + if ([request.URL isEqual:_webView.URL]) { + return; + } + if (!request.URL) { + // Clear the webview + [_webView loadHTMLString:@"" baseURL:nil]; + return; + } + [_webView loadRequest:request]; +} + +-(void)setHideKeyboardAccessoryView:(BOOL)hideKeyboardAccessoryView +{ + if (_webView == nil) { + _savedHideKeyboardAccessoryView = hideKeyboardAccessoryView; + return; + } + + if (_savedHideKeyboardAccessoryView == false) { + return; + } + + UIView* subview; + + for (UIView* view in _webView.scrollView.subviews) { + if([[view.class description] hasPrefix:@"WK"]) + subview = view; + } + + if(subview == nil) return; + + NSString* name = [NSString stringWithFormat:@"%@_SwizzleHelperWK", subview.class.superclass]; + Class newClass = NSClassFromString(name); + + if(newClass == nil) + { + newClass = objc_allocateClassPair(subview.class, [name cStringUsingEncoding:NSASCIIStringEncoding], 0); + if(!newClass) return; + + Method method = class_getInstanceMethod([_SwizzleHelperWK class], @selector(inputAccessoryView)); + class_addMethod(newClass, @selector(inputAccessoryView), method_getImplementation(method), method_getTypeEncoding(method)); + + objc_registerClassPair(newClass); + } + + object_setClass(subview, newClass); +} + +- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView +{ + scrollView.decelerationRate = _decelerationRate; +} + +- (void)setScrollEnabled:(BOOL)scrollEnabled +{ + _scrollEnabled = scrollEnabled; + _webView.scrollView.scrollEnabled = scrollEnabled; +} + +- (void)setShowsHorizontalScrollIndicator:(BOOL)showsHorizontalScrollIndicator +{ + _showsHorizontalScrollIndicator = showsHorizontalScrollIndicator; + _webView.scrollView.showsHorizontalScrollIndicator = showsHorizontalScrollIndicator; +} + +- (void)setShowsVerticalScrollIndicator:(BOOL)showsVerticalScrollIndicator +{ + _showsVerticalScrollIndicator = showsVerticalScrollIndicator; + _webView.scrollView.showsVerticalScrollIndicator = showsVerticalScrollIndicator; +} + +- (void)postMessage:(NSString *)message +{ + NSDictionary *eventInitDict = @{@"data": message}; + NSString *source = [NSString + stringWithFormat:@"window.dispatchEvent(new MessageEvent('message', %@));", + RCTJSONStringify(eventInitDict, NULL) + ]; + [self injectJavaScript: source]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Ensure webview takes the position and dimensions of RNCWKWebView + _webView.frame = self.bounds; +} + +- (NSMutableDictionary *)baseEvent +{ + NSDictionary *event = @{ + @"url": _webView.URL.absoluteString ?: @"", + @"title": _webView.title, + @"loading" : @(_webView.loading), + @"canGoBack": @(_webView.canGoBack), + @"canGoForward" : @(_webView.canGoForward) + }; + return [[NSMutableDictionary alloc] initWithDictionary: event]; +} + ++ (void)setClientAuthenticationCredential:(nullable NSURLCredential*)credential { + clientAuthenticationCredential = credential; +} + +#pragma mark - WKNavigationDelegate methods + +/** +* alert +*/ +- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler +{ + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"" message:message preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + completionHandler(); + }]]; + [[self topViewController] presentViewController:alert animated:YES completion:NULL]; + +} + +/** +* confirm +*/ +- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler{ + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"" message:message preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + completionHandler(YES); + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { + completionHandler(NO); + }]]; + [[self topViewController] presentViewController:alert animated:YES completion:NULL]; +} + +/** +* prompt +*/ +- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString *))completionHandler{ + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"" message:prompt preferredStyle:UIAlertControllerStyleAlert]; + [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) { + textField.textColor = [UIColor lightGrayColor]; + textField.placeholder = defaultText; + }]; + [alert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + completionHandler([[alert.textFields lastObject] text]); + }]]; + [[self topViewController] presentViewController:alert animated:YES completion:NULL]; +} + +/** + * topViewController + */ +-(UIViewController *)topViewController{ +   UIViewController *controller = [self topViewControllerWithRootViewController:[self getCurrentWindow].rootViewController]; +   return controller; +} + +/** + * topViewControllerWithRootViewController + */ +-(UIViewController *)topViewControllerWithRootViewController:(UIViewController *)viewController{ + if (viewController==nil) return nil; + if (viewController.presentedViewController!=nil) { + return [self topViewControllerWithRootViewController:viewController.presentedViewController]; + } else if ([viewController isKindOfClass:[UITabBarController class]]){ + return [self topViewControllerWithRootViewController:[(UITabBarController *)viewController selectedViewController]]; + } else if ([viewController isKindOfClass:[UINavigationController class]]){ + return [self topViewControllerWithRootViewController:[(UINavigationController *)viewController visibleViewController]]; + } else { + return viewController; + } +} +/** + * getCurrentWindow + */ +-(UIWindow *)getCurrentWindow{ + UIWindow *window = [UIApplication sharedApplication].keyWindow; + if (window.windowLevel!=UIWindowLevelNormal) { + for (UIWindow *wid in [UIApplication sharedApplication].windows) { + if (window.windowLevel==UIWindowLevelNormal) { + window = wid; + break; + } + } + } + return window; +} + + +/** + * Decides whether to allow or cancel a navigation. + * @see https://fburl.com/42r9fxob + */ +- (void) webView:(WKWebView *)webView + decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction + decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler +{ + static NSDictionary *navigationTypes; + static dispatch_once_t onceToken; + + dispatch_once(&onceToken, ^{ + navigationTypes = @{ + @(WKNavigationTypeLinkActivated): @"click", + @(WKNavigationTypeFormSubmitted): @"formsubmit", + @(WKNavigationTypeBackForward): @"backforward", + @(WKNavigationTypeReload): @"reload", + @(WKNavigationTypeFormResubmitted): @"formresubmit", + @(WKNavigationTypeOther): @"other", + }; + }); + + WKNavigationType navigationType = navigationAction.navigationType; + NSURLRequest *request = navigationAction.request; + + if (_onShouldStartLoadWithRequest) { + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary: @{ + @"url": (request.URL).absoluteString, + @"navigationType": navigationTypes[@(navigationType)] + }]; + if (![self.delegate webView:self + shouldStartLoadForRequest:event + withCallback:_onShouldStartLoadWithRequest]) { + decisionHandler(WKNavigationResponsePolicyCancel); + return; + } + } + + if (_onLoadingStart) { + // We have this check to filter out iframe requests and whatnot + BOOL isTopFrame = [request.URL isEqual:request.mainDocumentURL]; + if (isTopFrame) { + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary: @{ + @"url": (request.URL).absoluteString, + @"navigationType": navigationTypes[@(navigationType)] + }]; + _onLoadingStart(event); + } + } + + // Allow all navigation by default + decisionHandler(WKNavigationResponsePolicyAllow); +} + +/** + * Called when an error occurs while the web view is loading content. + * @see https://fburl.com/km6vqenw + */ +- (void) webView:(WKWebView *)webView + didFailProvisionalNavigation:(WKNavigation *)navigation + withError:(NSError *)error +{ + if (_onLoadingError) { + if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) { + // NSURLErrorCancelled is reported when a page has a redirect OR if you load + // a new URL in the WebView before the previous one came back. We can just + // ignore these since they aren't real errors. + // http://stackoverflow.com/questions/1024748/how-do-i-fix-nsurlerrordomain-error-999-in-iphone-3-0-os + return; + } + + if ([error.domain isEqualToString:@"WebKitErrorDomain"] && error.code == 102) { + // Error code 102 "Frame load interrupted" is raised by the WKWebView + // when the URL is from an http redirect. This is a common pattern when + // implementing OAuth with a WebView. + return; + } + + NSMutableDictionary *event = [self baseEvent]; + [event addEntriesFromDictionary:@{ + @"didFailProvisionalNavigation": @YES, + @"domain": error.domain, + @"code": @(error.code), + @"description": error.localizedDescription, + }]; + _onLoadingError(event); + } + + [self setBackgroundColor: _savedBackgroundColor]; +} + +- (void)evaluateJS:(NSString *)js + thenCall: (void (^)(NSString*)) callback +{ + [self.webView evaluateJavaScript: js completionHandler: ^(id result, NSError *error) { + if (error == nil) { + if (callback != nil) { + callback([NSString stringWithFormat:@"%@", result]); + } + } else { + RCTLogError(@"Error evaluating injectedJavaScript: This is possibly due to an unsupported return type. Try adding true to the end of your injectedJavaScript string."); + } + }]; +} + +-(void) webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler { + NSString *authenticationMethod = [[challenge protectionSpace] authenticationMethod]; + + if (authenticationMethod == NSURLAuthenticationMethodNTLM || authenticationMethod == NSURLAuthenticationMethodNegotiate) { + NSString *hostName = webView.URL.host; + NSString *title = @"Authentication Challenge"; + NSString *message = [NSString stringWithFormat:@"%@ requires user name and password", hostName]; + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) { + textField.placeholder = @"User"; + }]; + [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) { + textField.placeholder = @"Password"; + textField.secureTextEntry = YES; + }]; + [alertController addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + + NSString *userName = ((UITextField *)alertController.textFields[0]).text; + NSString *password = ((UITextField *)alertController.textFields[1]).text; + + NSURLCredential *credential = [[NSURLCredential alloc] initWithUser:userName password:password persistence:NSURLCredentialPersistenceNone]; + + completionHandler(NSURLSessionAuthChallengeUseCredential, credential); + + }]]; + [alertController addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { + completionHandler(NSURLSessionAuthChallengeUseCredential, nil); + }]]; + dispatch_async(dispatch_get_main_queue(), ^{ + UIViewController *rootVC = UIApplication.sharedApplication.delegate.window.rootViewController; + + while (rootVC.presentedViewController != nil) { + rootVC = rootVC.presentedViewController; + } + [rootVC presentViewController:alertController animated:YES completion:^{}]; + }); + } else if (!clientAuthenticationCredential) { + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); + return; + } else if (authenticationMethod == NSURLAuthenticationMethodClientCertificate) { + completionHandler(NSURLSessionAuthChallengeUseCredential, clientAuthenticationCredential); + } else { + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]); + } +} + +/** + * Called when the navigation is complete. + * @see https://fburl.com/rtys6jlb + */ +- (void) webView:(WKWebView *)webView + didFinishNavigation:(WKNavigation *)navigation +{ + if (_injectedJavaScript) { + [self evaluateJS: _injectedJavaScript thenCall: ^(NSString *jsEvaluationValue) { + NSMutableDictionary *event = [self baseEvent]; + event[@"jsEvaluationValue"] = jsEvaluationValue; + + if (self.onLoadingFinish) { + self.onLoadingFinish(event); + } + }]; + } else if (_onLoadingFinish) { + _onLoadingFinish([self baseEvent]); + } + + [self setBackgroundColor: _savedBackgroundColor]; +} + +- (void)injectJavaScript:(NSString *)script +{ + [self evaluateJS: script thenCall: nil]; +} + +- (void)goForward +{ + [_webView goForward]; +} + +- (void)goBack +{ + [_webView goBack]; +} + +- (void)reload +{ + /** + * When the initial load fails due to network connectivity issues, + * [_webView reload] doesn't reload the webpage. Therefore, we must + * manually call [_webView loadRequest:request]. + */ + NSURLRequest *request = [RCTConvert NSURLRequest:self.source]; + if (request.URL && !_webView.URL.absoluteString.length) { + [_webView loadRequest:request]; + } + else { + [_webView reload]; + } +} + +- (void)stopLoading +{ + [_webView stopLoading]; +} + +- (void)setBounces:(BOOL)bounces +{ + _bounces = bounces; + _webView.scrollView.bounces = bounces; +} +@end diff --git a/native_modules/RNCWebViewManager.java b/native_modules/RNCWebViewManager.java new file mode 100644 index 000000000..4297e7ec6 --- /dev/null +++ b/native_modules/RNCWebViewManager.java @@ -0,0 +1,877 @@ +package com.reactnativecommunity.webview; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.DownloadManager; +import android.content.Context; + +import com.facebook.react.uimanager.UIManagerModule; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Pattern; +import javax.annotation.Nullable; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import android.content.ActivityNotFoundException; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.text.TextUtils; +import android.text.InputType; +import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.webkit.ConsoleMessage; +import android.webkit.CookieManager; +import android.webkit.DownloadListener; +import android.webkit.GeolocationPermissions; +import android.webkit.JavascriptInterface; +import android.webkit.URLUtil; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.webkit.HttpAuthHandler; +import android.widget.EditText; +import android.widget.LinearLayout; + +import com.facebook.common.logging.FLog; +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.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.common.MapBuilder; +import com.facebook.react.common.ReactConstants; +import com.facebook.react.common.build.ReactBuildConfig; +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.SimpleViewManager; +import com.facebook.react.uimanager.ThemedReactContext; +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; + +import android.util.Log; + +/** + * Manages instances of {@link WebView} + * + * Can accept following commands: + * - 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 + * - 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 + */ +@ReactModule(name = RNCWebViewManager.REACT_CLASS) +public class RNCWebViewManager extends SimpleViewManager { + + protected static final String REACT_CLASS = "RNCWebView"; + private RNCWebViewPackage aPackage; + + protected static final String HTML_ENCODING = "UTF-8"; + protected static final String HTML_MIME_TYPE = "text/html"; + protected static final String JAVASCRIPT_INTERFACE = "ReactNativeWebView"; + + protected static final String HTTP_METHOD_POST = "POST"; + + public static final int COMMAND_GO_BACK = 1; + 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; + 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). + protected static final String BLANK_URL = "about:blank"; + + protected WebViewConfig mWebViewConfig; + + protected static class RNCWebViewClient extends WebViewClient { + + protected boolean mLastLoadFailed = false; + protected @Nullable ReadableArray mUrlPrefixesForDefaultIntent; + protected Activity mCurrentActivity; + + public void setCurrentActivity(Activity mCurrentActivity) { + this.mCurrentActivity = mCurrentActivity; + } + + @Override + public void onPageFinished(WebView webView, String url) { + super.onPageFinished(webView, url); + + if (!mLastLoadFailed) { + RNCWebView reactWebView = (RNCWebView) 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(), + createWebViewEvent(webView, url))); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + dispatchEvent(view, new TopShouldStartLoadWithRequestEvent(view.getId(), url)); + return true; + } + + + @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 + 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(), eventData)); + } + + @Override + public void onReceivedHttpAuthRequest(WebView view, + final HttpAuthHandler handler, String host, String realm) + { + Log.d("ReactNative", "host = " + host + " realm = " + realm); + if (this.mCurrentActivity != null) { + final EditText usernameInput = new EditText(this.mCurrentActivity); + usernameInput.setHint("Username"); + + final EditText passwordInput = new EditText(this.mCurrentActivity); + passwordInput.setHint("Password"); + passwordInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + + LinearLayout layout = new LinearLayout(this.mCurrentActivity); + layout.setOrientation(LinearLayout.VERTICAL); + layout.addView(usernameInput); + layout.addView(passwordInput); + + AlertDialog.Builder authDialog = new AlertDialog.Builder(this.mCurrentActivity) + .setTitle("Authentication Challenge") + .setMessage(host + " requires user name and password ") + .setView(layout) + .setCancelable(false) + .setPositiveButton("OK", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialogInterface, int i) { + handler.proceed(usernameInput.getText().toString(), passwordInput.getText().toString()); + dialogInterface.dismiss(); + } + }) + .setNegativeButton("Cancel", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialogInterface, int i) { + dialogInterface.dismiss(); + handler.cancel(); + } + }); + + if (view != null) { + authDialog.show(); + } + } else { + handler.cancel(); + } + } + + protected void emitFinishEvent(WebView webView, String url) { + dispatchEvent( + webView, + new TopLoadingFinishEvent( + webView.getId(), + createWebViewEvent(webView, url))); + } + + protected 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; + } + + public void setUrlPrefixesForDefaultIntent(ReadableArray specialUrls) { + mUrlPrefixesForDefaultIntent = specialUrls; + } + } + + /** + * Subclass of {@link WebView} that implements {@link LifecycleEventListener} interface in order + * to call {@link WebView#destroy} on activity destroy event and also to clear the client + */ + protected static class RNCWebView extends WebView implements LifecycleEventListener { + protected @Nullable String injectedJS; + protected boolean messagingEnabled = false; + protected @Nullable RNCWebViewClient mRNCWebViewClient; + protected boolean sendContentSizeChangeEvents = false; + protected ReactContext reactContext; + + public void setSendContentSizeChangeEvents(boolean sendContentSizeChangeEvents) { + this.sendContentSizeChangeEvents = sendContentSizeChangeEvents; + } + + + protected class RNCWebViewBridge { + RNCWebView mContext; + + RNCWebViewBridge(RNCWebView c) { + mContext = c; + } + + /** + * This method is called whenever JavaScript running within the web view calls: + * - window[JAVASCRIPT_INTERFACE].postMessage + */ + @JavascriptInterface + public void postMessage(String message) { + mContext.onMessage(message); + } + } + + /** + * 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 RNCWebView(ThemedReactContext reactContext) { + super(reactContext); + this.reactContext = reactContext; + } + + @Override + public void onHostResume() { + // do nothing + } + + @Override + public void onHostPause() { + // do nothing + } + + @Override + public void onHostDestroy() { + cleanupCallbacksAndDestroy(); + } + + @Override + protected void onSizeChanged(int w, int h, int ow, int oh) { + super.onSizeChanged(w, h, ow, oh); + + if (sendContentSizeChangeEvents) { + dispatchEvent( + this, + new ContentSizeChangeEvent( + this.getId(), + w, + h + ) + ); + } + } + + @Override + public void setWebViewClient(WebViewClient client) { + super.setWebViewClient(client); + Log.d("ReactNative", "SETTING THE WEBVIEW CLIENT"); + mRNCWebViewClient = (RNCWebViewClient)client; + if (this.reactContext != null && this.reactContext.getCurrentActivity() != null && mRNCWebViewClient != null) { + mRNCWebViewClient.setCurrentActivity(this.reactContext.getCurrentActivity()); + } + } + + public @Nullable RNCWebViewClient getRNCWebViewClient() { + return mRNCWebViewClient; + } + + public void setInjectedJavaScript(@Nullable String js) { + injectedJS = js; + } + + protected RNCWebViewBridge createRNCWebViewBridge(RNCWebView webView) { + return new RNCWebViewBridge(webView); + } + + @SuppressLint("AddJavascriptInterface") + public void setMessagingEnabled(boolean enabled) { + if (messagingEnabled == enabled) { + return; + } + + messagingEnabled = enabled; + + if (enabled) { + addJavascriptInterface(createRNCWebViewBridge(this), JAVASCRIPT_INTERFACE); + } else { + removeJavascriptInterface(JAVASCRIPT_INTERFACE); + } + } + + protected void evaluateJavascriptWithFallback(String script) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + evaluateJavascript(script, null); + return; + } + + try { + loadUrl("javascript:" + URLEncoder.encode(script, "UTF-8")); + } catch (UnsupportedEncodingException e) { + // UTF-8 should always be supported + throw new RuntimeException(e); + } + } + + public void callInjectedJavaScript() { + if (getSettings().getJavaScriptEnabled() && + injectedJS != null && + !TextUtils.isEmpty(injectedJS)) { + evaluateJavascriptWithFallback("(function() {\n" + injectedJS + ";\n})();"); + } + } + + public void onMessage(String message) { + dispatchEvent(this, new TopMessageEvent(this.getId(), message)); + } + + protected void cleanupCallbacksAndDestroy() { + setWebViewClient(null); + destroy(); + } + } + + public RNCWebViewManager() { + mWebViewConfig = new WebViewConfig() { + public void configWebView(WebView webView) { + } + }; + } + + public RNCWebViewManager(WebViewConfig webViewConfig) { + mWebViewConfig = webViewConfig; + } + + @Override + public String getName() { + return REACT_CLASS; + } + + protected RNCWebView createRNCWebViewInstance(ThemedReactContext reactContext) { + return new RNCWebView(reactContext); + } + + @Override + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + protected WebView createViewInstance(ThemedReactContext reactContext) { + RNCWebView webView = createRNCWebViewInstance(reactContext); + webView.setWebChromeClient(new WebChromeClient() { + @Override + public boolean onConsoleMessage(ConsoleMessage message) { + if (ReactBuildConfig.DEBUG) { + return super.onConsoleMessage(message); + } + // Ignore console logs in non debug builds. + return true; + } + + + @Override + public void onProgressChanged(WebView webView, int newProgress) { + super.onProgressChanged(webView, newProgress); + WritableMap event = Arguments.createMap(); + event.putDouble("target", webView.getId()); + event.putString("title", webView.getTitle()); + event.putBoolean("canGoBack", webView.canGoBack()); + event.putBoolean("canGoForward", webView.canGoForward()); + event.putDouble("progress", (float)newProgress/100); + dispatchEvent( + webView, + new TopLoadingProgressEvent( + webView.getId(), + event)); + } + + @Override + public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { + callback.invoke(origin, true, false); + } + + protected void openFileChooser(ValueCallback filePathCallback, String acceptType) { + getModule().startPhotoPickerIntent(filePathCallback, acceptType); + } + protected void openFileChooser(ValueCallback filePathCallback) { + getModule().startPhotoPickerIntent(filePathCallback, ""); + } + protected void openFileChooser(ValueCallback filePathCallback, String acceptType, String capture) { + getModule().startPhotoPickerIntent(filePathCallback, acceptType); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public boolean onShowFileChooser(WebView webView, ValueCallback filePathCallback, FileChooserParams fileChooserParams) { + String[] acceptTypes = fileChooserParams.getAcceptTypes(); + boolean allowMultiple = fileChooserParams.getMode() == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE; + Intent intent = fileChooserParams.createIntent(); + return getModule().startPhotoPickerIntent(filePathCallback, intent, acceptTypes, allowMultiple); + } + }); + reactContext.addLifecycleEventListener(webView); + mWebViewConfig.configWebView(webView); + WebSettings settings = webView.getSettings(); + settings.setBuiltInZoomControls(true); + settings.setDisplayZoomControls(false); + settings.setDomStorageEnabled(true); + + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + settings.setAllowFileAccessFromFileURLs(false); + setAllowUniversalAccessFromFileURLs(webView, false); + } + setMixedContentMode(webView, "never"); + + // Fixes broken full-screen modals/galleries due to body height being 0. + webView.setLayoutParams( + new LayoutParams(LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT)); + + setGeolocationEnabled(webView, false); + if (ReactBuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + WebView.setWebContentsDebuggingEnabled(true); + } + + webView.setDownloadListener(new DownloadListener() { + public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength) { + RNCWebViewModule module = getModule(); + + DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); + + String fileName = URLUtil.guessFileName(url, contentDisposition, mimetype); + String downloadMessage = "Downloading " + fileName; + + //Attempt to add cookie, if it exists + URL urlObj = null; + try { + urlObj = new URL(url); + String baseUrl = urlObj.getProtocol() + "://" + urlObj.getHost(); + String cookie = CookieManager.getInstance().getCookie(baseUrl); + request.addRequestHeader("Cookie", cookie); + System.out.println("Got cookie for DownloadManager: " + cookie); + } catch (MalformedURLException e) { + System.out.println("Error getting cookie for DownloadManager: " + e.toString()); + e.printStackTrace(); + } + + //Finish setting up request + request.addRequestHeader("User-Agent", userAgent); + request.setTitle(fileName); + request.setDescription(downloadMessage); + request.allowScanningByMediaScanner(); + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); + + module.setDownloadRequest(request); + + if (module.grantFileDownloaderPermissions()) { + module.downloadFile(); + } + } + }); + + return webView; + } + + @ReactProp(name = "javaScriptEnabled") + public void setJavaScriptEnabled(WebView view, boolean enabled) { + view.getSettings().setJavaScriptEnabled(enabled); + } + + @ReactProp(name = "showsHorizontalScrollIndicator") + public void setShowsHorizontalScrollIndicator(WebView view, boolean enabled) { + view.setHorizontalScrollBarEnabled(enabled); + } + + @ReactProp(name = "showsVerticalScrollIndicator") + public void setShowsVerticalScrollIndicator(WebView view, boolean enabled) { + view.setVerticalScrollBarEnabled(enabled); + } + + @ReactProp(name = "cacheEnabled") + public void setCacheEnabled(WebView view, boolean enabled) { + if (enabled) { + Context ctx = view.getContext(); + if (ctx != null) { + view.getSettings().setAppCachePath(ctx.getCacheDir().getAbsolutePath()); + view.getSettings().setCacheMode(WebSettings.LOAD_DEFAULT); + view.getSettings().setAppCacheEnabled(true); + } + } else { + view.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); + view.getSettings().setAppCacheEnabled(false); + } + } + + @ReactProp(name = "androidHardwareAccelerationDisabled") + public void setHardwareAccelerationDisabled(WebView view, boolean disabled) { + if (disabled) { + view.setLayerType(View.LAYER_TYPE_SOFTWARE, null); + } + } + + @ReactProp(name = "overScrollMode") + public void setOverScrollMode(WebView view, String overScrollModeString) { + Integer overScrollMode; + switch (overScrollModeString) { + case "never": + overScrollMode = View.OVER_SCROLL_NEVER; + break; + case "content": + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS; + break; + case "always": + default: + overScrollMode = View.OVER_SCROLL_ALWAYS; + break; + } + view.setOverScrollMode(overScrollMode); + } + + @ReactProp(name = "thirdPartyCookiesEnabled") + public void setThirdPartyCookiesEnabled(WebView view, boolean enabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + CookieManager.getInstance().setAcceptThirdPartyCookies(view, enabled); + } + } + + @ReactProp(name = "scalesPageToFit") + public void setScalesPageToFit(WebView view, boolean enabled) { + view.getSettings().setUseWideViewPort(!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); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + @ReactProp(name = "mediaPlaybackRequiresUserAction") + public void setMediaPlaybackRequiresUserAction(WebView view, boolean requires) { + view.getSettings().setMediaPlaybackRequiresUserGesture(requires); + } + + @ReactProp(name = "allowUniversalAccessFromFileURLs") + public void setAllowUniversalAccessFromFileURLs(WebView view, boolean allow) { + view.getSettings().setAllowUniversalAccessFromFileURLs(allow); + } + + @ReactProp(name = "saveFormDataDisabled") + public void setSaveFormDataDisabled(WebView view, boolean disable) { + view.getSettings().setSaveFormData(!disable); + } + + @ReactProp(name = "injectedJavaScript") + public void setInjectedJavaScript(WebView view, @Nullable String injectedJavaScript) { + ((RNCWebView) view).setInjectedJavaScript(injectedJavaScript); + } + + @ReactProp(name = "messagingEnabled") + public void setMessagingEnabled(WebView view, boolean enabled) { + ((RNCWebView) view).setMessagingEnabled(enabled); + } + + @ReactProp(name = "source") + public void setSource(WebView view, @Nullable ReadableMap source) { + if (source != null) { + if (source.hasKey("html")) { + String html = source.getString("html"); + if (source.hasKey("baseUrl")) { + view.loadDataWithBaseURL( + source.getString("baseUrl"), html, HTML_MIME_TYPE, HTML_ENCODING, null); + } else { + view.loadData(html, HTML_MIME_TYPE + "; charset=" + HTML_ENCODING, null); + } + return; + } + if (source.hasKey("uri")) { + String url = source.getString("uri"); + String previousUrl = view.getUrl(); + if (previousUrl != null && previousUrl.equals(url)) { + return; + } + if (source.hasKey("method")) { + String method = source.getString("method"); + if (method.equalsIgnoreCase(HTTP_METHOD_POST)) { + byte[] postData = null; + if (source.hasKey("body")) { + String body = source.getString("body"); + try { + postData = body.getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + postData = body.getBytes(); + } + } + if (postData == null) { + postData = new byte[0]; + } + view.postUrl(url, postData); + return; + } + } + HashMap headerMap = new HashMap<>(); + if (source.hasKey("headers")) { + ReadableMap headers = source.getMap("headers"); + ReadableMapKeySetIterator iter = headers.keySetIterator(); + while (iter.hasNextKey()) { + String key = iter.nextKey(); + if ("user-agent".equals(key.toLowerCase(Locale.ENGLISH))) { + if (view.getSettings() != null) { + view.getSettings().setUserAgentString(headers.getString(key)); + } + } else { + headerMap.put(key, headers.getString(key)); + } + } + } + view.loadUrl(url, headerMap); + return; + } + } + view.loadUrl(BLANK_URL); + } + + @ReactProp(name = "onContentSizeChange") + public void setOnContentSizeChange(WebView view, boolean sendContentSizeChangeEvents) { + ((RNCWebView) view).setSendContentSizeChangeEvents(sendContentSizeChangeEvents); + } + + @ReactProp(name = "mixedContentMode") + public void setMixedContentMode(WebView view, @Nullable String mixedContentMode) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (mixedContentMode == null || "never".equals(mixedContentMode)) { + view.getSettings().setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + } else if ("always".equals(mixedContentMode)) { + view.getSettings().setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); + } else if ("compatibility".equals(mixedContentMode)) { + view.getSettings().setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE); + } + } + } + + @ReactProp(name = "urlPrefixesForDefaultIntent") + public void setUrlPrefixesForDefaultIntent( + WebView view, + @Nullable ReadableArray urlPrefixesForDefaultIntent) { + RNCWebViewClient client = ((RNCWebView) view).getRNCWebViewClient(); + if (client != null && urlPrefixesForDefaultIntent != null) { + client.setUrlPrefixesForDefaultIntent(urlPrefixesForDefaultIntent); + } + } + + @ReactProp(name = "allowFileAccess") + public void setAllowFileAccess( + WebView view, + @Nullable Boolean allowFileAccess) { + view.getSettings().setAllowFileAccess(allowFileAccess != null && allowFileAccess); + } + + @ReactProp(name = "geolocationEnabled") + public void setGeolocationEnabled( + WebView view, + @Nullable Boolean isGeolocationEnabled) { + view.getSettings().setGeolocationEnabled(isGeolocationEnabled != null && isGeolocationEnabled); + } + + @Override + protected void addEventEmitters(ThemedReactContext reactContext, WebView view) { + // Do not register default touch emitter and let WebView implementation handle touches + view.setWebViewClient(new RNCWebViewClient()); + } + + @Override + public Map getExportedCustomDirectEventTypeConstants() { + 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 + public @Nullable Map getCommandsMap() { + return MapBuilder.of( + "goBack", COMMAND_GO_BACK, + "goForward", COMMAND_GO_FORWARD, + "reload", COMMAND_RELOAD, + "stopLoading", COMMAND_STOP_LOADING, + "postMessage", COMMAND_POST_MESSAGE, + "injectJavaScript", COMMAND_INJECT_JAVASCRIPT, + "loadUrl", COMMAND_LOAD_URL + ); + } + + @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; + case COMMAND_STOP_LOADING: + root.stopLoading(); + break; + case COMMAND_POST_MESSAGE: + try { + RNCWebView reactWebView = (RNCWebView) root; + JSONObject eventInitDict = new JSONObject(); + eventInitDict.put("data", args.getString(0)); + reactWebView.evaluateJavascriptWithFallback("(function () {" + + "var event;" + + "var data = " + eventInitDict.toString() + ";" + + "try {" + + "event = new MessageEvent('message', data);" + + "} catch (e) {" + + "event = document.createEvent('MessageEvent');" + + "event.initMessageEvent('message', true, true, data.data, data.origin, data.lastEventId, data.source);" + + "}" + + "document.dispatchEvent(event);" + + "})();"); + } catch (JSONException e) { + throw new RuntimeException(e); + } + break; + case COMMAND_INJECT_JAVASCRIPT: + 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; + } + } + + @Override + public void onDropViewInstance(WebView webView) { + super.onDropViewInstance(webView); + ((ThemedReactContext) webView.getContext()).removeLifecycleEventListener((RNCWebView) webView); + ((RNCWebView) webView).cleanupCallbacksAndDestroy(); + } + + protected static void dispatchEvent(WebView webView, Event event) { + ReactContext reactContext = (ReactContext) webView.getContext(); + EventDispatcher eventDispatcher = + reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); + eventDispatcher.dispatchEvent(event); + } + + public RNCWebViewPackage getPackage() { + return this.aPackage; + } + + public void setPackage(RNCWebViewPackage aPackage) { + this.aPackage = aPackage; + } + + public RNCWebViewModule getModule() { + return this.aPackage.getModule(); + } +}