Add WebView JavaScript channels (Dart side). (#1116)
Allows JavaScript code running in the WebView to send messages that will be received by the Flutter app's Dart code.
To keep the PRs smaller this does not include the platform side implementations which will be sent in following PRs.
Right now there are no return values on the JavaScript side, we could think of returning a JavaScript promise (or a promise equivalent) in the future, but as a first step I start without it (`evaluateJavascript` can be used to pass messages in the other direction so this shouldn't be a blocking limitation).
I believe that if we end up adding a return value on the JavaScript side this could be done without making a breaking change.
### Sample usage
Dart:
```dart
WebView(
javascriptChannels: <JavascriptChannel>[
JavascriptChannel(name: 'Print', onMessageReceived: (String msg) {print(msg); }),
].toSet(),
),
```
JavaScript (running inside the WebView):
```javascript
Print.postMessage('Hello!');
```
Fixes: https://github.com/flutter/flutter/issues/24837
diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md
index d90be52..07a69c0 100644
--- a/packages/webview_flutter/CHANGELOG.md
+++ b/packages/webview_flutter/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.3.1
+
+* Added JavaScript channels to facilitate message passing from JavaScript code running inside
+ the WebView to the Flutter app's Dart code.
+
## 0.3.0
* **Breaking change**. Migrate from the deprecated original Android Support
diff --git a/packages/webview_flutter/example/lib/main.dart b/packages/webview_flutter/example/lib/main.dart
index 9befe4b..d8a4e8c 100644
--- a/packages/webview_flutter/example/lib/main.dart
+++ b/packages/webview_flutter/example/lib/main.dart
@@ -23,17 +23,34 @@
SampleMenu(_controller.future),
],
),
- body: WebView(
- initialUrl: 'https://flutter.io',
- javascriptMode: JavascriptMode.unrestricted,
- onWebViewCreated: (WebViewController webViewController) {
- _controller.complete(webViewController);
- },
- ),
+ // We're using a Builder here so we have a context that is below the Scaffold
+ // to allow calling Scaffold.of(context) so we can show a snackbar.
+ body: Builder(builder: (BuildContext context) {
+ return WebView(
+ initialUrl: 'https://flutter.io',
+ javascriptMode: JavascriptMode.unrestricted,
+ onWebViewCreated: (WebViewController webViewController) {
+ _controller.complete(webViewController);
+ },
+ javascriptChannels: <JavascriptChannel>[
+ _toasterJavascriptChannel(context),
+ ].toSet(),
+ );
+ }),
floatingActionButton: favoriteButton(),
);
}
+ JavascriptChannel _toasterJavascriptChannel(BuildContext context) {
+ return JavascriptChannel(
+ name: 'Toaster',
+ onMessageReceived: (JavascriptMessage message) {
+ Scaffold.of(context).showSnackBar(
+ SnackBar(content: Text(message.message)),
+ );
+ });
+ }
+
Widget favoriteButton() {
return FutureBuilder<WebViewController>(
future: _controller.future,
@@ -44,7 +61,7 @@
onPressed: () async {
final String url = await controller.data.currentUrl();
Scaffold.of(context).showSnackBar(
- SnackBar(content: Text("Favorited $url")),
+ SnackBar(content: Text('Favorited $url')),
);
},
child: const Icon(Icons.favorite),
@@ -56,7 +73,7 @@
}
enum MenuOptions {
- evaluateJavascript,
+ showUserAgent,
toast,
}
@@ -73,8 +90,8 @@
return PopupMenuButton<MenuOptions>(
onSelected: (MenuOptions value) {
switch (value) {
- case MenuOptions.evaluateJavascript:
- _onEvaluateJavascript(controller.data, context);
+ case MenuOptions.showUserAgent:
+ _onShowUserAgent(controller.data, context);
break;
case MenuOptions.toast:
Scaffold.of(context).showSnackBar(
@@ -87,8 +104,8 @@
},
itemBuilder: (BuildContext context) => <PopupMenuItem<MenuOptions>>[
PopupMenuItem<MenuOptions>(
- value: MenuOptions.evaluateJavascript,
- child: const Text('Evaluate JavaScript'),
+ value: MenuOptions.showUserAgent,
+ child: const Text('Show user agent'),
enabled: controller.hasData,
),
const PopupMenuItem<MenuOptions>(
@@ -101,15 +118,12 @@
);
}
- void _onEvaluateJavascript(
+ void _onShowUserAgent(
WebViewController controller, BuildContext context) async {
- final String result = await controller
- .evaluateJavascript("document.body.style.backgroundColor = 'red'");
- Scaffold.of(context).showSnackBar(
- SnackBar(
- content: Text('JavaScript evaluated, the result is: $result'),
- ),
- );
+ // Send a message with the user agent string to the Toaster JavaScript channel we registered
+ // with the WebView.
+ controller.evaluateJavascript(
+ 'Toaster.postMessage("User Agent: " + navigator.userAgent);');
}
}
diff --git a/packages/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/lib/webview_flutter.dart
index 43f88b6..8d5a746 100644
--- a/packages/webview_flutter/lib/webview_flutter.dart
+++ b/packages/webview_flutter/lib/webview_flutter.dart
@@ -19,6 +19,51 @@
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);
+
+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.
@@ -32,6 +77,7 @@
this.onWebViewCreated,
this.initialUrl,
this.javascriptMode = JavascriptMode.disabled,
+ this.javascriptChannels,
this.gestureRecognizers,
}) : assert(javascriptMode != null),
super(key: key);
@@ -56,6 +102,35 @@
/// 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: (String message) { print(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;
+
@override
State<StatefulWidget> createState() => _WebViewState();
}
@@ -104,44 +179,78 @@
}
@override
- void didUpdateWidget(WebView oldWidget) {
- super.didUpdateWidget(oldWidget);
- _updateSettings(_WebSettings.fromWidget(widget));
+ void initState() {
+ super.initState();
+ _assertJavascriptChannelNamesAreUnique();
}
- Future<void> _updateSettings(_WebSettings settings) async {
+ @override
+ void didUpdateWidget(WebView oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ _assertJavascriptChannelNamesAreUnique();
+ _updateConfiguration(_WebSettings.fromWidget(widget));
+ }
+
+ Future<void> _updateConfiguration(_WebSettings settings) async {
_settings = settings;
final WebViewController controller = await _controller.future;
controller._updateSettings(settings);
+ controller._updateJavascriptChannels(widget.javascriptChannels);
}
void _onPlatformViewCreated(int id) {
- final WebViewController controller =
- WebViewController._(id, _WebSettings.fromWidget(widget));
+ final WebViewController controller = WebViewController._(
+ id,
+ _WebSettings.fromWidget(widget),
+ widget.javascriptChannels,
+ );
_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
+ ? Set<String>()
+ : channels.map((JavascriptChannel channel) => channel.name).toSet();
+ return channelNames;
}
class _CreationParams {
- _CreationParams({this.initialUrl, this.settings});
+ _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,
};
}
}
@@ -178,14 +287,32 @@
/// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated]
/// callback for a [WebView] widget.
class WebViewController {
- WebViewController._(int id, _WebSettings settings)
- : _channel = MethodChannel('plugins.flutter.io/webview_$id'),
- _settings = settings;
+ WebViewController._(
+ int id, this._settings, Set<JavascriptChannel> javascriptChannels)
+ : _channel = MethodChannel('plugins.flutter.io/webview_$id') {
+ _updateJavascriptChannelsFromSet(javascriptChannels);
+ _channel.setMethodCallHandler(_onMethodCall);
+ }
final MethodChannel _channel;
_WebSettings _settings;
+ // Maps a channel name to a channel.
+ Map<String, JavascriptChannel> _javascriptChannels =
+ <String, JavascriptChannel>{};
+
+ Future<void> _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));
+ break;
+ }
+ }
+
/// Loads the specified URL.
///
/// `url` must not be null.
@@ -279,6 +406,40 @@
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.
diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml
index 2ed52c9..329159e 100644
--- a/packages/webview_flutter/pubspec.yaml
+++ b/packages/webview_flutter/pubspec.yaml
@@ -1,6 +1,6 @@
name: webview_flutter
description: A Flutter plugin that provides a WebView widget on Android and iOS.
-version: 0.3.0
+version: 0.3.1
author: Flutter Team <flutter-dev@googlegroups.com>
homepage: https://github.com/flutter/plugins/tree/master/packages/webview_flutter
diff --git a/packages/webview_flutter/test/webview_flutter_test.dart b/packages/webview_flutter/test/webview_flutter_test.dart
index ae286bf..12b4831 100644
--- a/packages/webview_flutter/test/webview_flutter_test.dart
+++ b/packages/webview_flutter/test/webview_flutter_test.dart
@@ -9,6 +9,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:webview_flutter/webview_flutter.dart';
+typedef void VoidCallback();
+
void main() {
final _FakePlatformViewsController fakePlatformViewsController =
_FakePlatformViewsController();
@@ -353,6 +355,164 @@
throwsA(anything),
);
});
+
+ testWidgets('Initial JavaScript channels', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ WebView(
+ initialUrl: 'https://youtube.com',
+ javascriptChannels: <JavascriptChannel>[
+ JavascriptChannel(
+ name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}),
+ JavascriptChannel(
+ name: 'Alarm', onMessageReceived: (JavascriptMessage msg) {}),
+ ].toSet(),
+ ),
+ );
+
+ final FakePlatformWebView platformWebView =
+ fakePlatformViewsController.lastCreatedView;
+
+ expect(platformWebView.javascriptChannelNames,
+ unorderedEquals(<String>['Tts', 'Alarm']));
+ });
+
+ test('Only valid JavaScript channel names are allowed', () {
+ final JavascriptMessageHandler noOp = (JavascriptMessage msg) {};
+ JavascriptChannel(name: 'Tts1', onMessageReceived: noOp);
+ JavascriptChannel(name: '_Alarm', onMessageReceived: noOp);
+
+ VoidCallback createChannel(String name) {
+ return () {
+ JavascriptChannel(name: name, onMessageReceived: noOp);
+ };
+ }
+
+ expect(createChannel('1Alarm'), throwsAssertionError);
+ expect(createChannel('foo.bar'), throwsAssertionError);
+ expect(createChannel(''), throwsAssertionError);
+ });
+
+ testWidgets('Unique JavaScript channel names are required',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ WebView(
+ initialUrl: 'https://youtube.com',
+ javascriptChannels: <JavascriptChannel>[
+ JavascriptChannel(
+ name: 'Alarm', onMessageReceived: (JavascriptMessage msg) {}),
+ JavascriptChannel(
+ name: 'Alarm', onMessageReceived: (JavascriptMessage msg) {}),
+ ].toSet(),
+ ),
+ );
+ expect(tester.takeException(), isNot(null));
+ });
+
+ testWidgets('JavaScript channels update', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ WebView(
+ initialUrl: 'https://youtube.com',
+ javascriptChannels: <JavascriptChannel>[
+ JavascriptChannel(
+ name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}),
+ JavascriptChannel(
+ name: 'Alarm', onMessageReceived: (JavascriptMessage msg) {}),
+ ].toSet(),
+ ),
+ );
+
+ await tester.pumpWidget(
+ WebView(
+ initialUrl: 'https://youtube.com',
+ javascriptChannels: <JavascriptChannel>[
+ JavascriptChannel(
+ name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}),
+ JavascriptChannel(
+ name: 'Alarm2', onMessageReceived: (JavascriptMessage msg) {}),
+ JavascriptChannel(
+ name: 'Alarm3', onMessageReceived: (JavascriptMessage msg) {}),
+ ].toSet(),
+ ),
+ );
+
+ final FakePlatformWebView platformWebView =
+ fakePlatformViewsController.lastCreatedView;
+
+ expect(platformWebView.javascriptChannelNames,
+ unorderedEquals(<String>['Tts', 'Alarm2', 'Alarm3']));
+ });
+
+ testWidgets('Remove all JavaScript channels and then add',
+ (WidgetTester tester) async {
+ // This covers a specific bug we had where after updating javascriptChannels to null,
+ // updating it again with a subset of the previously registered channels fails as the
+ // widget's cache of current channel wasn't properly updated when updating javascriptChannels to
+ // null.
+ await tester.pumpWidget(
+ WebView(
+ initialUrl: 'https://youtube.com',
+ javascriptChannels: <JavascriptChannel>[
+ JavascriptChannel(
+ name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}),
+ ].toSet(),
+ ),
+ );
+
+ await tester.pumpWidget(
+ const WebView(
+ initialUrl: 'https://youtube.com',
+ ),
+ );
+
+ await tester.pumpWidget(
+ WebView(
+ initialUrl: 'https://youtube.com',
+ javascriptChannels: <JavascriptChannel>[
+ JavascriptChannel(
+ name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}),
+ ].toSet(),
+ ),
+ );
+
+ final FakePlatformWebView platformWebView =
+ fakePlatformViewsController.lastCreatedView;
+
+ expect(platformWebView.javascriptChannelNames,
+ unorderedEquals(<String>['Tts']));
+ });
+
+ testWidgets('JavaScript channel messages', (WidgetTester tester) async {
+ final List<String> ttsMessagesReceived = <String>[];
+ final List<String> alarmMessagesReceived = <String>[];
+ await tester.pumpWidget(
+ WebView(
+ initialUrl: 'https://youtube.com',
+ javascriptChannels: <JavascriptChannel>[
+ JavascriptChannel(
+ name: 'Tts',
+ onMessageReceived: (JavascriptMessage msg) {
+ ttsMessagesReceived.add(msg.message);
+ }),
+ JavascriptChannel(
+ name: 'Alarm',
+ onMessageReceived: (JavascriptMessage msg) {
+ alarmMessagesReceived.add(msg.message);
+ }),
+ ].toSet(),
+ ),
+ );
+
+ final FakePlatformWebView platformWebView =
+ fakePlatformViewsController.lastCreatedView;
+
+ expect(ttsMessagesReceived, isEmpty);
+ expect(alarmMessagesReceived, isEmpty);
+
+ platformWebView.fakeJavascriptPostMessage('Tts', 'Hello');
+ platformWebView.fakeJavascriptPostMessage('Tts', 'World');
+
+ expect(ttsMessagesReceived, <String>['Hello', 'World']);
+ });
}
class FakePlatformWebView {
@@ -365,6 +525,10 @@
}
javascriptMode = JavascriptMode.values[params['settings']['jsMode']];
}
+ if (params.containsKey('javascriptChannelNames')) {
+ javascriptChannelNames =
+ List<String>.from(params['javascriptChannelNames']);
+ }
channel = MethodChannel(
'plugins.flutter.io/webview_$id', const StandardMethodCodec());
channel.setMockMethodCallHandler(onMethodCall);
@@ -378,6 +542,7 @@
String get currentUrl => history.isEmpty ? null : history[currentPosition];
JavascriptMode javascriptMode;
+ List<String> javascriptChannelNames;
Future<dynamic> onMethodCall(MethodCall call) {
switch (call.method) {
@@ -416,9 +581,31 @@
break;
case 'evaluateJavascript':
return Future<dynamic>.value(call.arguments);
+ break;
+ case 'addJavascriptChannels':
+ final List<String> channelNames = List<String>.from(call.arguments);
+ javascriptChannelNames.addAll(channelNames);
+ break;
+ case 'removeJavascriptChannels':
+ final List<String> channelNames = List<String>.from(call.arguments);
+ javascriptChannelNames
+ .removeWhere((String channel) => channelNames.contains(channel));
+ break;
}
return Future<void>.sync(() {});
}
+
+ void fakeJavascriptPostMessage(String jsChannel, String message) {
+ final StandardMethodCodec codec = const StandardMethodCodec();
+ final Map<String, dynamic> arguments = <String, dynamic>{
+ 'channel': jsChannel,
+ 'message': message
+ };
+ final ByteData data = codec
+ .encodeMethodCall(MethodCall('javascriptChannelMessage', arguments));
+ BinaryMessages.handlePlatformMessage(
+ channel.name, data, (ByteData data) {});
+ }
}
class _FakePlatformViewsController {