// Copyright 2018 The Chromium 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 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'platform_interface.dart'; import 'src/webview_android.dart'; import 'src/webview_cupertino.dart'; typedef void WebViewCreatedCallback(WebViewController controller); enum JavascriptMode { /// JavaScript execution is disabled. disabled, /// JavaScript execution is not restricted. unrestricted, } /// A message that was sent by JavaScript code running in a [WebView]. class JavascriptMessage { /// Constructs a JavaScript message object. /// /// The `message` parameter must not be null. const JavascriptMessage(this.message) : assert(message != null); /// The contents of the message that was sent by the JavaScript code. final String message; } /// Callback type for handling messages sent from Javascript running in a web view. typedef void JavascriptMessageHandler(JavascriptMessage message); /// Information about a navigation action that is about to be executed. class NavigationRequest { NavigationRequest._({this.url, this.isForMainFrame}); /// The URL that will be loaded if the navigation is executed. final String url; /// Whether the navigation request is to be loaded as the main frame. final bool isForMainFrame; @override String toString() { return '$runtimeType(url: $url, isForMainFrame: $isForMainFrame)'; } } /// A decision on how to handle a navigation request. enum NavigationDecision { /// Prevent the navigation from taking place. prevent, /// Allow the navigation to take place. navigate, } /// Decides how to handle a specific navigation request. /// /// The returned [NavigationDecision] determines how the navigation described by /// `navigation` should be handled. /// /// See also: [WebView.navigationDelegate]. typedef NavigationDecision NavigationDelegate(NavigationRequest navigation); /// Signature for when a [WebView] has finished loading a page. typedef void PageFinishedCallback(String url); final RegExp _validChannelNames = RegExp('^[a-zA-Z_][a-zA-Z0-9]*\$'); /// A named channel for receiving messaged from JavaScript code running inside a web view. class JavascriptChannel { /// Constructs a Javascript channel. /// /// The parameters `name` and `onMessageReceived` must not be null. JavascriptChannel({ @required this.name, @required this.onMessageReceived, }) : assert(name != null), assert(onMessageReceived != null), assert(_validChannelNames.hasMatch(name)); /// The channel's name. /// /// Passing this channel object as part of a [WebView.javascriptChannels] adds a channel object to /// the Javascript window object's property named `name`. /// /// The name must start with a letter or underscore(_), followed by any combination of those /// characters plus digits. /// /// Note that any JavaScript existing `window` property with this name will be overriden. /// /// See also [WebView.javascriptChannels] for more details on the channel registration mechanism. final String name; /// A callback that's invoked when a message is received through the channel. final JavascriptMessageHandler onMessageReceived; } /// A web view widget for showing html content. class WebView extends StatefulWidget { /// Creates a new web view. /// /// The web view can be controlled using a `WebViewController` that is passed to the /// `onWebViewCreated` callback once the web view is created. /// /// The `javascriptMode` parameter must not be null. const WebView({ Key key, this.onWebViewCreated, this.initialUrl, this.javascriptMode = JavascriptMode.disabled, this.javascriptChannels, this.navigationDelegate, this.gestureRecognizers, this.onPageFinished, this.debuggingEnabled = false, }) : assert(javascriptMode != null), super(key: key); static WebViewPlatform _platform; /// Sets a custom [WebViewPlatform]. /// /// This property can be set to use a custom platform implementation for WebViews. /// /// Setting `platform` doesn't affect [WebView]s that were already created. /// /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. static set platform(WebViewPlatform platform) { _platform = platform; } /// The WebView platform that's used by this WebView. /// /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. static WebViewPlatform get platform { if (_platform == null) { switch (defaultTargetPlatform) { case TargetPlatform.android: _platform = AndroidWebView(); break; case TargetPlatform.iOS: _platform = CupertinoWebView(); break; default: throw UnsupportedError( "Trying to use the default webview implementation for $defaultTargetPlatform but there isn't a default one"); } } return _platform; } /// If not null invoked once the web view is created. final WebViewCreatedCallback onWebViewCreated; /// Which gestures should be consumed by the web view. /// /// It is possible for other gesture recognizers to be competing with the web view on pointer /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle /// vertical drags. The web view will claim gestures that are recognized by any of the /// recognizers on this list. /// /// When this set is empty or null, the web view will only handle pointer events for gestures that /// were not claimed by any other gesture recognizer. final Set> gestureRecognizers; /// The initial URL to load. final String initialUrl; /// Whether Javascript execution is enabled. final JavascriptMode javascriptMode; /// The set of [JavascriptChannel]s available to JavaScript code running in the web view. /// /// For each [JavascriptChannel] in the set, a channel object is made available for the /// JavaScript code in a window property named [JavascriptChannel.name]. /// The JavaScript code can then call `postMessage` on that object to send a message that will be /// passed to [JavascriptChannel.onMessageReceived]. /// /// For example for the following JavascriptChannel: /// /// ```dart /// JavascriptChannel(name: 'Print', onMessageReceived: (JavascriptMessage message) { print(message.message); }); /// ``` /// /// JavaScript code can call: /// /// ```javascript /// Print.postMessage('Hello'); /// ``` /// /// To asynchronously invoke the message handler which will print the message to standard output. /// /// Adding a new JavaScript channel only takes affect after the next page is loaded. /// /// Set values must not be null. A [JavascriptChannel.name] cannot be the same for multiple /// channels in the list. /// /// A null value is equivalent to an empty set. final Set javascriptChannels; /// A delegate function that decides how to handle navigation actions. /// /// When a navigation is initiated by the WebView (e.g when a user clicks a link) /// this delegate is called and has to decide how to proceed with the navigation. /// /// See [NavigationDecision] for possible decisions the delegate can take. /// /// When null all navigation actions are allowed. /// /// Caveats on Android: /// /// * Navigation actions targeted to the main frame can be intercepted, /// navigation actions targeted to subframes are allowed regardless of the value /// returned by this delegate. /// * Setting a navigationDelegate makes the WebView treat all navigations as if they were /// triggered by a user gesture, this disables some of Chromium's security mechanisms. /// A navigationDelegate should only be set when loading trusted content. /// * On Android WebView versions earlier than 67(most devices running at least Android L+ should have /// a later version): /// * When a navigationDelegate is set pages with frames are not properly handled by the /// webview, and frames will be opened in the main frame. /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. final NavigationDelegate navigationDelegate; /// Invoked when a page has finished loading. /// /// This is invoked only for the main frame. /// /// When [onPageFinished] is invoked on Android, the page being rendered may /// not be updated yet. /// /// When invoked on iOS or Android, any Javascript code that is embedded /// directly in the HTML has been loaded and code injected with /// [WebViewController.evaluateJavascript] can assume this. final PageFinishedCallback onPageFinished; /// Controls whether WebView debugging is enabled. /// /// Setting this to true enables [WebView debugging on Android](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/). /// /// WebView debugging is enabled by default in dev builds on iOS. /// /// To debug WebViews on iOS: /// - Enable developer options (Open Safari, go to Preferences -> Advanced and make sure "Show Develop Menu in Menubar" is on.) /// - From the Menu-bar (of Safari) select Develop -> iPhone Simulator -> /// /// By default `debuggingEnabled` is false. final bool debuggingEnabled; @override State createState() => _WebViewState(); } class _WebViewState extends State { final Completer _controller = Completer(); _PlatformCallbacksHandler _platformCallbacksHandler; @override Widget build(BuildContext context) { return WebView.platform.build( context: context, onWebViewPlatformCreated: _onWebViewPlatformCreated, webViewPlatformCallbacksHandler: _platformCallbacksHandler, gestureRecognizers: widget.gestureRecognizers, creationParams: _creationParamsfromWidget(widget), ); } @override void initState() { super.initState(); _assertJavascriptChannelNamesAreUnique(); _platformCallbacksHandler = _PlatformCallbacksHandler(widget); } @override void didUpdateWidget(WebView oldWidget) { super.didUpdateWidget(oldWidget); _assertJavascriptChannelNamesAreUnique(); _controller.future.then((WebViewController controller) { _platformCallbacksHandler._widget = widget; controller._updateWidget(widget); }); } void _onWebViewPlatformCreated(WebViewPlatformController webViewPlatform) { final WebViewController controller = WebViewController._(widget, webViewPlatform, _platformCallbacksHandler); _controller.complete(controller); if (widget.onWebViewCreated != null) { widget.onWebViewCreated(controller); } } void _assertJavascriptChannelNamesAreUnique() { if (widget.javascriptChannels == null || widget.javascriptChannels.isEmpty) { return; } assert(_extractChannelNames(widget.javascriptChannels).length == widget.javascriptChannels.length); } } CreationParams _creationParamsfromWidget(WebView widget) { return CreationParams( initialUrl: widget.initialUrl, webSettings: _webSettingsFromWidget(widget), javascriptChannelNames: _extractChannelNames(widget.javascriptChannels), ); } WebSettings _webSettingsFromWidget(WebView widget) { return WebSettings( javascriptMode: widget.javascriptMode, hasNavigationDelegate: widget.navigationDelegate != null, debuggingEnabled: widget.debuggingEnabled, ); } // This method assumes that no fields in `currentValue` are null. WebSettings _clearUnchangedWebSettings( WebSettings currentValue, WebSettings newValue) { assert(currentValue.javascriptMode != null); assert(currentValue.hasNavigationDelegate != null); assert(currentValue.debuggingEnabled != null); assert(newValue.javascriptMode != null); assert(newValue.hasNavigationDelegate != null); assert(newValue.debuggingEnabled != null); JavascriptMode javascriptMode; bool hasNavigationDelegate; bool debuggingEnabled; if (currentValue.javascriptMode != newValue.javascriptMode) { javascriptMode = newValue.javascriptMode; } if (currentValue.hasNavigationDelegate != newValue.hasNavigationDelegate) { hasNavigationDelegate = newValue.hasNavigationDelegate; } if (currentValue.debuggingEnabled != newValue.debuggingEnabled) { debuggingEnabled = newValue.debuggingEnabled; } return WebSettings( javascriptMode: javascriptMode, hasNavigationDelegate: hasNavigationDelegate, debuggingEnabled: debuggingEnabled); } Set _extractChannelNames(Set channels) { final Set channelNames = channels == null // TODO(iskakaushik): Remove this when collection literals makes it to stable. // ignore: prefer_collection_literals ? Set() : channels.map((JavascriptChannel channel) => channel.name).toSet(); return channelNames; } class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { _PlatformCallbacksHandler(this._widget) { _updateJavascriptChannelsFromSet(_widget.javascriptChannels); } WebView _widget; // Maps a channel name to a channel. final Map _javascriptChannels = {}; @override void onJavaScriptChannelMessage(String channel, String message) { _javascriptChannels[channel].onMessageReceived(JavascriptMessage(message)); } @override bool onNavigationRequest({String url, bool isForMainFrame}) { final NavigationRequest request = NavigationRequest._(url: url, isForMainFrame: isForMainFrame); final bool allowNavigation = _widget.navigationDelegate == null || _widget.navigationDelegate(request) == NavigationDecision.navigate; return allowNavigation; } @override void onPageFinished(String url) { if (_widget.onPageFinished != null) { _widget.onPageFinished(url); } } void _updateJavascriptChannelsFromSet(Set channels) { _javascriptChannels.clear(); if (channels == null) { return; } for (JavascriptChannel channel in channels) { _javascriptChannels[channel.name] = channel; } } } /// Controls a [WebView]. /// /// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated] /// callback for a [WebView] widget. class WebViewController { WebViewController._( this._widget, this._webViewPlatformController, this._platformCallbacksHandler, ) : assert(_webViewPlatformController != null) { _settings = _webSettingsFromWidget(_widget); } final WebViewPlatformController _webViewPlatformController; final _PlatformCallbacksHandler _platformCallbacksHandler; WebSettings _settings; WebView _widget; /// Loads the specified URL. /// /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will /// be added as key value pairs of HTTP headers for the request. /// /// `url` must not be null. /// /// Throws an ArgumentError if `url` is not a valid URL string. Future loadUrl( String url, { Map headers, }) async { assert(url != null); _validateUrlString(url); return _webViewPlatformController.loadUrl(url, headers); } /// Accessor to the current URL that the WebView is displaying. /// /// If [WebView.initialUrl] was never specified, returns `null`. /// Note that this operation is asynchronous, and it is possible that the /// current URL changes again by the time this function returns (in other /// words, by the time this future completes, the WebView may be displaying a /// different URL). Future currentUrl() { return _webViewPlatformController.currentUrl(); } /// Checks whether there's a back history item. /// /// Note that this operation is asynchronous, and it is possible that the "canGoBack" state has /// changed by the time the future completed. Future canGoBack() { return _webViewPlatformController.canGoBack(); } /// Checks whether there's a forward history item. /// /// Note that this operation is asynchronous, and it is possible that the "canGoForward" state has /// changed by the time the future completed. Future canGoForward() { return _webViewPlatformController.canGoForward(); } /// Goes back in the history of this WebView. /// /// If there is no back history item this is a no-op. Future goBack() { return _webViewPlatformController.goBack(); } /// Goes forward in the history of this WebView. /// /// If there is no forward history item this is a no-op. Future goForward() { return _webViewPlatformController.goForward(); } /// Reloads the current URL. Future reload() { return _webViewPlatformController.reload(); } /// Clears all caches used by the [WebView]. /// /// The following caches are cleared: /// 1. Browser HTTP Cache. /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. /// 3. Application cache. /// 4. Local Storage. /// /// Note: Calling this method also triggers a reload. Future clearCache() async { await _webViewPlatformController.clearCache(); return reload(); } Future _updateWidget(WebView widget) async { _widget = widget; await _updateSettings(_webSettingsFromWidget(widget)); await _updateJavascriptChannels(widget.javascriptChannels); } Future _updateSettings(WebSettings newSettings) { final WebSettings update = _clearUnchangedWebSettings(_settings, newSettings); _settings = newSettings; return _webViewPlatformController.updateSettings(update); } Future _updateJavascriptChannels( Set newChannels) async { final Set currentChannels = _platformCallbacksHandler._javascriptChannels.keys.toSet(); final Set newChannelNames = _extractChannelNames(newChannels); final Set channelsToAdd = newChannelNames.difference(currentChannels); final Set channelsToRemove = currentChannels.difference(newChannelNames); if (channelsToRemove.isNotEmpty) { _webViewPlatformController.removeJavascriptChannels(channelsToRemove); } if (channelsToAdd.isNotEmpty) { _webViewPlatformController.addJavascriptChannels(channelsToAdd); } _platformCallbacksHandler._updateJavascriptChannelsFromSet(newChannels); } /// Evaluates a JavaScript expression in the context of the current page. /// /// On Android returns the evaluation result as a JSON formatted string. /// /// On iOS depending on the value type the return value would be one of: /// /// - For primitive JavaScript types: the value string formatted (e.g JavaScript 100 returns '100'). /// - For JavaScript arrays of supported types: a string formatted NSArray(e.g '(1,2,3), note that the string for NSArray is formatted and might contain newlines and extra spaces.'). /// - Other non-primitive types are not supported on iOS and will complete the Future with an error. /// /// The Future completes with an error if a JavaScript error occurred, or on iOS, if the type of the /// evaluated expression is not supported as described above. /// /// When evaluating Javascript in a [WebView], it is best practice to wait for /// the [WebView.onPageFinished] callback. This guarantees all the Javascript /// embedded in the main frame HTML has been loaded. Future evaluateJavascript(String javascriptString) { if (_settings.javascriptMode == JavascriptMode.disabled) { return Future.error(FlutterError( 'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.')); } if (javascriptString == null) { return Future.error( ArgumentError('The argument javascriptString must not be null.')); } // TODO(amirh): remove this on when the invokeMethod update makes it to stable Flutter. // https://github.com/flutter/flutter/issues/26431 // ignore: strong_mode_implicit_dynamic_method return _webViewPlatformController.evaluateJavascript(javascriptString); } } /// Manages cookies pertaining to all [WebView]s. class CookieManager { /// Creates a [CookieManager] -- returns the instance if it's already been called. factory CookieManager() { return _instance ??= CookieManager._(); } CookieManager._(); static CookieManager _instance; /// Clears all cookies for all [WebView] instances. /// /// This is a no op on iOS version smaller than 9. /// /// Returns true if cookies were present before clearing, else false. Future clearCookies() => WebView.platform.clearCookies(); } // Throws an ArgumentError if `url` is not a valid URL string. void _validateUrlString(String url) { try { final Uri uri = Uri.parse(url); if (uri.scheme.isEmpty) { throw ArgumentError('Missing scheme in URL string: "$url"'); } } on FormatException catch (e) { throw ArgumentError(e); } }