blob: 49063a27a0665f96fcc1c215004d6f350a15e0d9 [file] [log] [blame]
// 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/services.dart';
import 'package:flutter/widgets.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,
}) : assert(javascriptMode != null),
super(key: key);
/// 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<Factory<OneSequenceGestureRecognizer>> 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<JavascriptChannel> 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;
@override
State<StatefulWidget> createState() => _WebViewState();
}
class _WebViewState extends State<WebView> {
final Completer<WebViewController> _controller =
Completer<WebViewController>();
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.android) {
return GestureDetector(
// We prevent text selection by intercepting the long press event.
// This is a temporary stop gap due to issues with text selection on Android:
// https://github.com/flutter/flutter/issues/24585 - the text selection
// dialog is not responding to touch events.
// https://github.com/flutter/flutter/issues/24584 - the text selection
// handles are not showing.
// TODO(amirh): remove this when the issues above are fixed.
onLongPress: () {},
excludeFromSemantics: true,
child: AndroidView(
viewType: 'plugins.flutter.io/webview',
onPlatformViewCreated: _onPlatformViewCreated,
gestureRecognizers: widget.gestureRecognizers,
// WebView content is not affected by the Android view's layout direction,
// we explicitly set it here so that the widget doesn't require an ambient
// directionality.
layoutDirection: TextDirection.rtl,
creationParams: _CreationParams.fromWidget(widget).toMap(),
creationParamsCodec: const StandardMessageCodec(),
),
);
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
return UiKitView(
viewType: 'plugins.flutter.io/webview',
onPlatformViewCreated: _onPlatformViewCreated,
gestureRecognizers: widget.gestureRecognizers,
creationParams: _CreationParams.fromWidget(widget).toMap(),
creationParamsCodec: const StandardMessageCodec(),
);
}
return Text(
'$defaultTargetPlatform is not yet supported by the webview_flutter plugin');
}
@override
void initState() {
super.initState();
_assertJavascriptChannelNamesAreUnique();
}
@override
void didUpdateWidget(WebView oldWidget) {
super.didUpdateWidget(oldWidget);
_assertJavascriptChannelNamesAreUnique();
_controller.future.then(
(WebViewController controller) => controller._updateWidget(widget));
}
void _onPlatformViewCreated(int id) {
final WebViewController controller = WebViewController._(id, widget);
_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);
}
}
Set<String> _extractChannelNames(Set<JavascriptChannel> channels) {
final Set<String> channelNames = channels == null
// TODO(iskakaushik): Remove this when collection literals makes it to stable.
// ignore: prefer_collection_literals
? Set<String>()
: channels.map((JavascriptChannel channel) => channel.name).toSet();
return channelNames;
}
class _CreationParams {
_CreationParams(
{this.initialUrl, this.settings, this.javascriptChannelNames});
static _CreationParams fromWidget(WebView widget) {
return _CreationParams(
initialUrl: widget.initialUrl,
settings: _WebSettings.fromWidget(widget),
javascriptChannelNames:
_extractChannelNames(widget.javascriptChannels).toList(),
);
}
final String initialUrl;
final _WebSettings settings;
final List<String> javascriptChannelNames;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'initialUrl': initialUrl,
'settings': settings.toMap(),
'javascriptChannelNames': javascriptChannelNames,
};
}
}
class _WebSettings {
_WebSettings({
this.javascriptMode,
this.hasNavigationDelegate,
});
static _WebSettings fromWidget(WebView widget) {
return _WebSettings(
javascriptMode: widget.javascriptMode,
hasNavigationDelegate: widget.navigationDelegate != null,
);
}
final JavascriptMode javascriptMode;
final bool hasNavigationDelegate;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'jsMode': javascriptMode.index,
'hasNavigationDelegate': hasNavigationDelegate,
};
}
Map<String, dynamic> updatesMap(_WebSettings newSettings) {
final Map<String, dynamic> updates = <String, dynamic>{};
if (javascriptMode != newSettings.javascriptMode) {
updates['jsMode'] = newSettings.javascriptMode.index;
}
if (hasNavigationDelegate != newSettings.hasNavigationDelegate) {
updates['hasNavigationDelegate'] = newSettings.hasNavigationDelegate;
}
return updates;
}
}
/// Controls a [WebView].
///
/// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated]
/// callback for a [WebView] widget.
class WebViewController {
WebViewController._(
int id,
this._widget,
) : _channel = MethodChannel('plugins.flutter.io/webview_$id') {
_settings = _WebSettings.fromWidget(_widget);
_updateJavascriptChannelsFromSet(_widget.javascriptChannels);
_channel.setMethodCallHandler(_onMethodCall);
}
final MethodChannel _channel;
_WebSettings _settings;
WebView _widget;
// Maps a channel name to a channel.
Map<String, JavascriptChannel> _javascriptChannels =
<String, JavascriptChannel>{};
Future<bool> _onMethodCall(MethodCall call) async {
switch (call.method) {
case 'javascriptChannelMessage':
final String channel = call.arguments['channel'];
final String message = call.arguments['message'];
_javascriptChannels[channel]
.onMessageReceived(JavascriptMessage(message));
return true;
case 'navigationRequest':
final NavigationRequest request = NavigationRequest._(
url: call.arguments['url'],
isForMainFrame: call.arguments['isForMainFrame'],
);
// _navigationDelegate can be null if the widget was rebuilt with no
// navigation delegate after a navigation happened and just before we
// got the navigationRequest message.
final bool allowNavigation = _widget.navigationDelegate == null ||
_widget.navigationDelegate(request) == NavigationDecision.navigate;
return allowNavigation;
case 'onPageFinished':
if (_widget.onPageFinished != null) {
_widget.onPageFinished(call.arguments['url']);
}
return null;
}
throw MissingPluginException(
'${call.method} was invoked but has no handler');
}
/// Loads the specified URL.
///
/// `url` must not be null.
///
/// Throws an ArgumentError if `url` is not a valid URL string.
Future<void> loadUrl(String url) async {
assert(url != null);
_validateUrlString(url);
// 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 _channel.invokeMethod('loadUrl', url);
}
/// 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<String> currentUrl() async {
// 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
final String url = await _channel.invokeMethod('currentUrl');
return url;
}
/// 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<bool> canGoBack() async {
// 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
final bool canGoBack = await _channel.invokeMethod("canGoBack");
return 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<bool> canGoForward() async {
// 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
final bool canGoForward = await _channel.invokeMethod("canGoForward");
return canGoForward;
}
/// Goes back in the history of this WebView.
///
/// If there is no back history item this is a no-op.
Future<void> goBack() async {
// 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 _channel.invokeMethod("goBack");
}
/// Goes forward in the history of this WebView.
///
/// If there is no forward history item this is a no-op.
Future<void> goForward() async {
// 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 _channel.invokeMethod("goForward");
}
/// Reloads the current URL.
Future<void> reload() async {
// 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 _channel.invokeMethod("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<void> clearCache() async {
// 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
await _channel.invokeMethod("clearCache");
return reload();
}
Future<void> _updateWidget(WebView widget) async {
_widget = widget;
await _updateSettings(_WebSettings.fromWidget(widget));
await _updateJavascriptChannels(widget.javascriptChannels);
}
Future<void> _updateSettings(_WebSettings setting) async {
final Map<String, dynamic> updateMap = _settings.updatesMap(setting);
if (updateMap == null || updateMap.isEmpty) {
return null;
}
_settings = setting;
// 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 _channel.invokeMethod('updateSettings', updateMap);
}
Future<void> _updateJavascriptChannels(
Set<JavascriptChannel> newChannels) async {
final Set<String> currentChannels = _javascriptChannels.keys.toSet();
final Set<String> newChannelNames = _extractChannelNames(newChannels);
final Set<String> channelsToAdd =
newChannelNames.difference(currentChannels);
final Set<String> channelsToRemove =
currentChannels.difference(newChannelNames);
if (channelsToRemove.isNotEmpty) {
// TODO(amirh): remove this when the invokeMethod update makes it to stable Flutter.
// https://github.com/flutter/flutter/issues/26431
// ignore: strong_mode_implicit_dynamic_method
_channel.invokeMethod(
'removeJavascriptChannels', channelsToRemove.toList());
}
if (channelsToAdd.isNotEmpty) {
// TODO(amirh): remove this when the invokeMethod update makes it to stable Flutter.
// https://github.com/flutter/flutter/issues/26431
// ignore: strong_mode_implicit_dynamic_method
_channel.invokeMethod('addJavascriptChannels', channelsToAdd.toList());
}
_updateJavascriptChannelsFromSet(newChannels);
}
void _updateJavascriptChannelsFromSet(Set<JavascriptChannel> channels) {
_javascriptChannels.clear();
if (channels == null) {
return;
}
for (JavascriptChannel channel in channels) {
_javascriptChannels[channel.name] = channel;
}
}
/// 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<String> evaluateJavascript(String javascriptString) async {
if (_settings.javascriptMode == JavascriptMode.disabled) {
throw FlutterError(
'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.');
}
if (javascriptString == null) {
throw 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
final String result =
await _channel.invokeMethod('evaluateJavascript', javascriptString);
return result;
}
}
/// 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 const MethodChannel _channel =
MethodChannel('plugins.flutter.io/cookie_manager');
static CookieManager _instance;
/// Clears all cookies.
///
/// This is supported for >= IOS 9.
///
/// Returns true if cookies were present before clearing, else false.
Future<bool> clearCookies() => _channel
// TODO(amirh): remove this when the invokeMethod update makes it to stable Flutter.
// https://github.com/flutter/flutter/issues/26431
// ignore: strong_mode_implicit_dynamic_method
.invokeMethod('clearCookies')
.then<bool>((dynamic result) => result);
}
// 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);
}
}