603 lines
21 KiB
Dart
Executable File
603 lines
21 KiB
Dart
Executable File
// 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<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;
|
|
|
|
/// 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 -> <your webview page>
|
|
///
|
|
/// By default `debuggingEnabled` is false.
|
|
final bool debuggingEnabled;
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _WebViewState();
|
|
}
|
|
|
|
class _WebViewState extends State<WebView> {
|
|
final Completer<WebViewController> _controller =
|
|
Completer<WebViewController>();
|
|
|
|
_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<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 _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler {
|
|
_PlatformCallbacksHandler(this._widget) {
|
|
_updateJavascriptChannelsFromSet(_widget.javascriptChannels);
|
|
}
|
|
|
|
WebView _widget;
|
|
|
|
// Maps a channel name to a channel.
|
|
final Map<String, JavascriptChannel> _javascriptChannels =
|
|
<String, JavascriptChannel>{};
|
|
|
|
@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<JavascriptChannel> 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<void> loadUrl(
|
|
String url, {
|
|
Map<String, String> 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<String> 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<bool> 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<bool> 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<void> 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<void> goForward() {
|
|
return _webViewPlatformController.goForward();
|
|
}
|
|
|
|
/// Reloads the current URL.
|
|
Future<void> 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<void> clearCache() async {
|
|
await _webViewPlatformController.clearCache();
|
|
return reload();
|
|
}
|
|
|
|
Future<void> _updateWidget(WebView widget) async {
|
|
_widget = widget;
|
|
await _updateSettings(_webSettingsFromWidget(widget));
|
|
await _updateJavascriptChannels(widget.javascriptChannels);
|
|
}
|
|
|
|
Future<void> _updateSettings(WebSettings newSettings) {
|
|
final WebSettings update =
|
|
_clearUnchangedWebSettings(_settings, newSettings);
|
|
_settings = newSettings;
|
|
return _webViewPlatformController.updateSettings(update);
|
|
}
|
|
|
|
Future<void> _updateJavascriptChannels(
|
|
Set<JavascriptChannel> newChannels) async {
|
|
final Set<String> currentChannels =
|
|
_platformCallbacksHandler._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) {
|
|
_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<String> evaluateJavascript(String javascriptString) {
|
|
if (_settings.javascriptMode == JavascriptMode.disabled) {
|
|
return Future<String>.error(FlutterError(
|
|
'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.'));
|
|
}
|
|
if (javascriptString == null) {
|
|
return Future<String>.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<bool> 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);
|
|
}
|
|
}
|