| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "FlutterWebView.h" |
| #import "FLTWKNavigationDelegate.h" |
| #import "FLTWKProgressionDelegate.h" |
| #import "JavaScriptChannelHandler.h" |
| |
| @implementation FLTWebViewFactory { |
| NSObject<FlutterBinaryMessenger>* _messenger; |
| } |
| |
| - (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger>*)messenger { |
| self = [super init]; |
| if (self) { |
| _messenger = messenger; |
| } |
| return self; |
| } |
| |
| - (NSObject<FlutterMessageCodec>*)createArgsCodec { |
| return [FlutterStandardMessageCodec sharedInstance]; |
| } |
| |
| - (NSObject<FlutterPlatformView>*)createWithFrame:(CGRect)frame |
| viewIdentifier:(int64_t)viewId |
| arguments:(id _Nullable)args { |
| FLTWebViewController* webviewController = [[FLTWebViewController alloc] initWithFrame:frame |
| viewIdentifier:viewId |
| arguments:args |
| binaryMessenger:_messenger]; |
| return webviewController; |
| } |
| |
| @end |
| |
| @implementation FLTWKWebView |
| |
| - (void)setFrame:(CGRect)frame { |
| [super setFrame:frame]; |
| self.scrollView.contentInset = UIEdgeInsetsZero; |
| // We don't want the contentInsets to be adjusted by iOS, flutter should always take control of |
| // webview's contentInsets. |
| // self.scrollView.contentInset = UIEdgeInsetsZero; |
| if (@available(iOS 11, *)) { |
| // Above iOS 11, adjust contentInset to compensate the adjustedContentInset so the sum will |
| // always be 0. |
| if (UIEdgeInsetsEqualToEdgeInsets(self.scrollView.adjustedContentInset, UIEdgeInsetsZero)) { |
| return; |
| } |
| UIEdgeInsets insetToAdjust = self.scrollView.adjustedContentInset; |
| self.scrollView.contentInset = UIEdgeInsetsMake(-insetToAdjust.top, -insetToAdjust.left, |
| -insetToAdjust.bottom, -insetToAdjust.right); |
| } |
| } |
| |
| @end |
| |
| @implementation FLTWebViewController { |
| FLTWKWebView* _webView; |
| int64_t _viewId; |
| FlutterMethodChannel* _channel; |
| NSString* _currentUrl; |
| // The set of registered JavaScript channel names. |
| NSMutableSet* _javaScriptChannelNames; |
| FLTWKNavigationDelegate* _navigationDelegate; |
| FLTWKProgressionDelegate* _progressionDelegate; |
| } |
| |
| - (instancetype)initWithFrame:(CGRect)frame |
| viewIdentifier:(int64_t)viewId |
| arguments:(id _Nullable)args |
| binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger { |
| if (self = [super init]) { |
| _viewId = viewId; |
| |
| NSString* channelName = [NSString stringWithFormat:@"plugins.flutter.io/webview_%lld", viewId]; |
| _channel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:messenger]; |
| _javaScriptChannelNames = [[NSMutableSet alloc] init]; |
| |
| WKUserContentController* userContentController = [[WKUserContentController alloc] init]; |
| if ([args[@"javascriptChannelNames"] isKindOfClass:[NSArray class]]) { |
| NSArray* javaScriptChannelNames = args[@"javascriptChannelNames"]; |
| [_javaScriptChannelNames addObjectsFromArray:javaScriptChannelNames]; |
| [self registerJavaScriptChannels:_javaScriptChannelNames controller:userContentController]; |
| } |
| |
| NSDictionary<NSString*, id>* settings = args[@"settings"]; |
| |
| WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init]; |
| [self applyConfigurationSettings:settings toConfiguration:configuration]; |
| configuration.userContentController = userContentController; |
| [self updateAutoMediaPlaybackPolicy:args[@"autoMediaPlaybackPolicy"] |
| inConfiguration:configuration]; |
| |
| _webView = [[FLTWKWebView alloc] initWithFrame:frame configuration:configuration]; |
| _navigationDelegate = [[FLTWKNavigationDelegate alloc] initWithChannel:_channel]; |
| _webView.UIDelegate = self; |
| _webView.navigationDelegate = _navigationDelegate; |
| __weak __typeof__(self) weakSelf = self; |
| [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { |
| [weakSelf onMethodCall:call result:result]; |
| }]; |
| |
| if (@available(iOS 11.0, *)) { |
| _webView.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; |
| if (@available(iOS 13.0, *)) { |
| _webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = NO; |
| } |
| } |
| |
| [self applySettings:settings]; |
| // TODO(amirh): return an error if apply settings failed once it's possible to do so. |
| // https://github.com/flutter/flutter/issues/36228 |
| |
| NSString* initialUrl = args[@"initialUrl"]; |
| if ([initialUrl isKindOfClass:[NSString class]]) { |
| [self loadUrl:initialUrl]; |
| } |
| } |
| return self; |
| } |
| |
| - (void)dealloc { |
| if (_progressionDelegate != nil) { |
| [_progressionDelegate stopObservingProgress:_webView]; |
| } |
| } |
| |
| - (UIView*)view { |
| return _webView; |
| } |
| |
| - (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { |
| if ([[call method] isEqualToString:@"updateSettings"]) { |
| [self onUpdateSettings:call result:result]; |
| } else if ([[call method] isEqualToString:@"loadUrl"]) { |
| [self onLoadUrl:call result:result]; |
| } else if ([[call method] isEqualToString:@"canGoBack"]) { |
| [self onCanGoBack:call result:result]; |
| } else if ([[call method] isEqualToString:@"canGoForward"]) { |
| [self onCanGoForward:call result:result]; |
| } else if ([[call method] isEqualToString:@"goBack"]) { |
| [self onGoBack:call result:result]; |
| } else if ([[call method] isEqualToString:@"goForward"]) { |
| [self onGoForward:call result:result]; |
| } else if ([[call method] isEqualToString:@"reload"]) { |
| [self onReload:call result:result]; |
| } else if ([[call method] isEqualToString:@"currentUrl"]) { |
| [self onCurrentUrl:call result:result]; |
| } else if ([[call method] isEqualToString:@"evaluateJavascript"]) { |
| [self onEvaluateJavaScript:call result:result]; |
| } else if ([[call method] isEqualToString:@"addJavascriptChannels"]) { |
| [self onAddJavaScriptChannels:call result:result]; |
| } else if ([[call method] isEqualToString:@"removeJavascriptChannels"]) { |
| [self onRemoveJavaScriptChannels:call result:result]; |
| } else if ([[call method] isEqualToString:@"clearCache"]) { |
| [self clearCache:result]; |
| } else if ([[call method] isEqualToString:@"getTitle"]) { |
| [self onGetTitle:result]; |
| } else if ([[call method] isEqualToString:@"scrollTo"]) { |
| [self onScrollTo:call result:result]; |
| } else if ([[call method] isEqualToString:@"scrollBy"]) { |
| [self onScrollBy:call result:result]; |
| } else if ([[call method] isEqualToString:@"getScrollX"]) { |
| [self getScrollX:call result:result]; |
| } else if ([[call method] isEqualToString:@"getScrollY"]) { |
| [self getScrollY:call result:result]; |
| } else { |
| result(FlutterMethodNotImplemented); |
| } |
| } |
| |
| - (void)onUpdateSettings:(FlutterMethodCall*)call result:(FlutterResult)result { |
| NSString* error = [self applySettings:[call arguments]]; |
| if (error == nil) { |
| result(nil); |
| return; |
| } |
| result([FlutterError errorWithCode:@"updateSettings_failed" message:error details:nil]); |
| } |
| |
| - (void)onLoadUrl:(FlutterMethodCall*)call result:(FlutterResult)result { |
| if (![self loadRequest:[call arguments]]) { |
| result([FlutterError |
| errorWithCode:@"loadUrl_failed" |
| message:@"Failed parsing the URL" |
| details:[NSString stringWithFormat:@"Request was: '%@'", [call arguments]]]); |
| } else { |
| result(nil); |
| } |
| } |
| |
| - (void)onCanGoBack:(FlutterMethodCall*)call result:(FlutterResult)result { |
| BOOL canGoBack = [_webView canGoBack]; |
| result(@(canGoBack)); |
| } |
| |
| - (void)onCanGoForward:(FlutterMethodCall*)call result:(FlutterResult)result { |
| BOOL canGoForward = [_webView canGoForward]; |
| result(@(canGoForward)); |
| } |
| |
| - (void)onGoBack:(FlutterMethodCall*)call result:(FlutterResult)result { |
| [_webView goBack]; |
| result(nil); |
| } |
| |
| - (void)onGoForward:(FlutterMethodCall*)call result:(FlutterResult)result { |
| [_webView goForward]; |
| result(nil); |
| } |
| |
| - (void)onReload:(FlutterMethodCall*)call result:(FlutterResult)result { |
| [_webView reload]; |
| result(nil); |
| } |
| |
| - (void)onCurrentUrl:(FlutterMethodCall*)call result:(FlutterResult)result { |
| _currentUrl = [[_webView URL] absoluteString]; |
| result(_currentUrl); |
| } |
| |
| - (void)onEvaluateJavaScript:(FlutterMethodCall*)call result:(FlutterResult)result { |
| NSString* jsString = [call arguments]; |
| if (!jsString) { |
| result([FlutterError errorWithCode:@"evaluateJavaScript_failed" |
| message:@"JavaScript String cannot be null" |
| details:nil]); |
| return; |
| } |
| [_webView evaluateJavaScript:jsString |
| completionHandler:^(_Nullable id evaluateResult, NSError* _Nullable error) { |
| if (error) { |
| result([FlutterError |
| errorWithCode:@"evaluateJavaScript_failed" |
| message:@"Failed evaluating JavaScript" |
| details:[NSString stringWithFormat:@"JavaScript string was: '%@'\n%@", |
| jsString, error]]); |
| } else { |
| result([NSString stringWithFormat:@"%@", evaluateResult]); |
| } |
| }]; |
| } |
| |
| - (void)onAddJavaScriptChannels:(FlutterMethodCall*)call result:(FlutterResult)result { |
| NSArray* channelNames = [call arguments]; |
| NSSet* channelNamesSet = [[NSSet alloc] initWithArray:channelNames]; |
| [_javaScriptChannelNames addObjectsFromArray:channelNames]; |
| [self registerJavaScriptChannels:channelNamesSet |
| controller:_webView.configuration.userContentController]; |
| result(nil); |
| } |
| |
| - (void)onRemoveJavaScriptChannels:(FlutterMethodCall*)call result:(FlutterResult)result { |
| // WkWebView does not support removing a single user script, so instead we remove all |
| // user scripts, all message handlers. And re-register channels that shouldn't be removed. |
| [_webView.configuration.userContentController removeAllUserScripts]; |
| for (NSString* channelName in _javaScriptChannelNames) { |
| [_webView.configuration.userContentController removeScriptMessageHandlerForName:channelName]; |
| } |
| |
| NSArray* channelNamesToRemove = [call arguments]; |
| for (NSString* channelName in channelNamesToRemove) { |
| [_javaScriptChannelNames removeObject:channelName]; |
| } |
| |
| [self registerJavaScriptChannels:_javaScriptChannelNames |
| controller:_webView.configuration.userContentController]; |
| result(nil); |
| } |
| |
| - (void)clearCache:(FlutterResult)result { |
| if (@available(iOS 9.0, *)) { |
| NSSet* cacheDataTypes = [WKWebsiteDataStore allWebsiteDataTypes]; |
| WKWebsiteDataStore* dataStore = [WKWebsiteDataStore defaultDataStore]; |
| NSDate* dateFrom = [NSDate dateWithTimeIntervalSince1970:0]; |
| [dataStore removeDataOfTypes:cacheDataTypes |
| modifiedSince:dateFrom |
| completionHandler:^{ |
| result(nil); |
| }]; |
| } else { |
| // support for iOS8 tracked in https://github.com/flutter/flutter/issues/27624. |
| NSLog(@"Clearing cache is not supported for Flutter WebViews prior to iOS 9."); |
| } |
| } |
| |
| - (void)onGetTitle:(FlutterResult)result { |
| NSString* title = _webView.title; |
| result(title); |
| } |
| |
| - (void)onScrollTo:(FlutterMethodCall*)call result:(FlutterResult)result { |
| NSDictionary* arguments = [call arguments]; |
| int x = [arguments[@"x"] intValue]; |
| int y = [arguments[@"y"] intValue]; |
| |
| _webView.scrollView.contentOffset = CGPointMake(x, y); |
| result(nil); |
| } |
| |
| - (void)onScrollBy:(FlutterMethodCall*)call result:(FlutterResult)result { |
| CGPoint contentOffset = _webView.scrollView.contentOffset; |
| |
| NSDictionary* arguments = [call arguments]; |
| int x = [arguments[@"x"] intValue] + contentOffset.x; |
| int y = [arguments[@"y"] intValue] + contentOffset.y; |
| |
| _webView.scrollView.contentOffset = CGPointMake(x, y); |
| result(nil); |
| } |
| |
| - (void)getScrollX:(FlutterMethodCall*)call result:(FlutterResult)result { |
| int offsetX = _webView.scrollView.contentOffset.x; |
| result(@(offsetX)); |
| } |
| |
| - (void)getScrollY:(FlutterMethodCall*)call result:(FlutterResult)result { |
| int offsetY = _webView.scrollView.contentOffset.y; |
| result(@(offsetY)); |
| } |
| |
| // Returns nil when successful, or an error message when one or more keys are unknown. |
| - (NSString*)applySettings:(NSDictionary<NSString*, id>*)settings { |
| NSMutableArray<NSString*>* unknownKeys = [[NSMutableArray alloc] init]; |
| for (NSString* key in settings) { |
| if ([key isEqualToString:@"jsMode"]) { |
| NSNumber* mode = settings[key]; |
| [self updateJsMode:mode]; |
| } else if ([key isEqualToString:@"hasNavigationDelegate"]) { |
| NSNumber* hasDartNavigationDelegate = settings[key]; |
| _navigationDelegate.hasDartNavigationDelegate = [hasDartNavigationDelegate boolValue]; |
| } else if ([key isEqualToString:@"hasProgressTracking"]) { |
| NSNumber* hasProgressTrackingValue = settings[key]; |
| bool hasProgressTracking = [hasProgressTrackingValue boolValue]; |
| if (hasProgressTracking) { |
| _progressionDelegate = [[FLTWKProgressionDelegate alloc] initWithWebView:_webView |
| channel:_channel]; |
| } |
| } else if ([key isEqualToString:@"debuggingEnabled"]) { |
| // no-op debugging is always enabled on iOS. |
| } else if ([key isEqualToString:@"gestureNavigationEnabled"]) { |
| NSNumber* allowsBackForwardNavigationGestures = settings[key]; |
| _webView.allowsBackForwardNavigationGestures = |
| [allowsBackForwardNavigationGestures boolValue]; |
| } else if ([key isEqualToString:@"userAgent"]) { |
| NSString* userAgent = settings[key]; |
| [self updateUserAgent:[userAgent isEqual:[NSNull null]] ? nil : userAgent]; |
| } else { |
| [unknownKeys addObject:key]; |
| } |
| } |
| if ([unknownKeys count] == 0) { |
| return nil; |
| } |
| return [NSString stringWithFormat:@"webview_flutter: unknown setting keys: {%@}", |
| [unknownKeys componentsJoinedByString:@", "]]; |
| } |
| |
| - (void)applyConfigurationSettings:(NSDictionary<NSString*, id>*)settings |
| toConfiguration:(WKWebViewConfiguration*)configuration { |
| NSAssert(configuration != _webView.configuration, |
| @"configuration needs to be updated before webView.configuration."); |
| for (NSString* key in settings) { |
| if ([key isEqualToString:@"allowsInlineMediaPlayback"]) { |
| NSNumber* allowsInlineMediaPlayback = settings[key]; |
| configuration.allowsInlineMediaPlayback = [allowsInlineMediaPlayback boolValue]; |
| } |
| } |
| } |
| |
| - (void)updateJsMode:(NSNumber*)mode { |
| WKPreferences* preferences = [[_webView configuration] preferences]; |
| switch ([mode integerValue]) { |
| case 0: // disabled |
| [preferences setJavaScriptEnabled:NO]; |
| break; |
| case 1: // unrestricted |
| [preferences setJavaScriptEnabled:YES]; |
| break; |
| default: |
| NSLog(@"webview_flutter: unknown JavaScript mode: %@", mode); |
| } |
| } |
| |
| - (void)updateAutoMediaPlaybackPolicy:(NSNumber*)policy |
| inConfiguration:(WKWebViewConfiguration*)configuration { |
| switch ([policy integerValue]) { |
| case 0: // require_user_action_for_all_media_types |
| if (@available(iOS 10.0, *)) { |
| configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; |
| } else if (@available(iOS 9.0, *)) { |
| configuration.requiresUserActionForMediaPlayback = true; |
| } else { |
| #pragma clang diagnostic push |
| #pragma clang diagnostic ignored "-Wdeprecated-declarations" |
| configuration.mediaPlaybackRequiresUserAction = true; |
| #pragma clang diagnostic pop |
| } |
| break; |
| case 1: // always_allow |
| if (@available(iOS 10.0, *)) { |
| configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; |
| } else if (@available(iOS 9.0, *)) { |
| configuration.requiresUserActionForMediaPlayback = false; |
| } else { |
| #pragma clang diagnostic push |
| #pragma clang diagnostic ignored "-Wdeprecated-declarations" |
| configuration.mediaPlaybackRequiresUserAction = false; |
| #pragma clang diagnostic pop |
| } |
| break; |
| default: |
| NSLog(@"webview_flutter: unknown auto media playback policy: %@", policy); |
| } |
| } |
| |
| - (bool)loadRequest:(NSDictionary<NSString*, id>*)request { |
| if (!request) { |
| return false; |
| } |
| |
| NSString* url = request[@"url"]; |
| if ([url isKindOfClass:[NSString class]]) { |
| id headers = request[@"headers"]; |
| if ([headers isKindOfClass:[NSDictionary class]]) { |
| return [self loadUrl:url withHeaders:headers]; |
| } else { |
| return [self loadUrl:url]; |
| } |
| } |
| |
| return false; |
| } |
| |
| - (bool)loadUrl:(NSString*)url { |
| return [self loadUrl:url withHeaders:[NSMutableDictionary dictionary]]; |
| } |
| |
| - (bool)loadUrl:(NSString*)url withHeaders:(NSDictionary<NSString*, NSString*>*)headers { |
| NSURL* nsUrl = [NSURL URLWithString:url]; |
| if (!nsUrl) { |
| return false; |
| } |
| NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:nsUrl]; |
| [request setAllHTTPHeaderFields:headers]; |
| [_webView loadRequest:request]; |
| return true; |
| } |
| |
| - (void)registerJavaScriptChannels:(NSSet*)channelNames |
| controller:(WKUserContentController*)userContentController { |
| for (NSString* channelName in channelNames) { |
| FLTJavaScriptChannel* channel = |
| [[FLTJavaScriptChannel alloc] initWithMethodChannel:_channel |
| javaScriptChannelName:channelName]; |
| [userContentController addScriptMessageHandler:channel name:channelName]; |
| NSString* wrapperSource = [NSString |
| stringWithFormat:@"window.%@ = webkit.messageHandlers.%@;", channelName, channelName]; |
| WKUserScript* wrapperScript = |
| [[WKUserScript alloc] initWithSource:wrapperSource |
| injectionTime:WKUserScriptInjectionTimeAtDocumentStart |
| forMainFrameOnly:NO]; |
| [userContentController addUserScript:wrapperScript]; |
| } |
| } |
| |
| - (void)updateUserAgent:(NSString*)userAgent { |
| if (@available(iOS 9.0, *)) { |
| [_webView setCustomUserAgent:userAgent]; |
| } else { |
| NSLog(@"Updating UserAgent is not supported for Flutter WebViews prior to iOS 9."); |
| } |
| } |
| |
| #pragma mark WKUIDelegate |
| |
| - (WKWebView*)webView:(WKWebView*)webView |
| createWebViewWithConfiguration:(WKWebViewConfiguration*)configuration |
| forNavigationAction:(WKNavigationAction*)navigationAction |
| windowFeatures:(WKWindowFeatures*)windowFeatures { |
| if (!navigationAction.targetFrame.isMainFrame) { |
| [webView loadRequest:navigationAction.request]; |
| } |
| |
| return nil; |
| } |
| |
| @end |