[webview_flutter] Add an initialAutoMediaPlaybackPolicy setting (#1951) Controls whether a user action (e.g is touch event) is required in order to start media playback.
diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index d5801c7..eb7b1bc 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md
@@ -1,3 +1,8 @@ +## 0.3.11 + +* Add an initialAutoMediaPlaybackPolicy setting for controlling how auto media + playback is restricted. + ## 0.3.10+5 * Add dependency on `androidx.annotation:annotation:1.0.0`.
diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java index 8389877..80431ad 100644 --- a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java +++ b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java
@@ -51,6 +51,7 @@ registerJavaScriptChannelNames((List<String>) params.get(JS_CHANNEL_NAMES_FIELD)); } + updateAutoMediaPlaybackPolicy((Integer) params.get("autoMediaPlaybackPolicy")); if (params.containsKey("initialUrl")) { String url = (String) params.get("initialUrl"); webView.loadUrl(url); @@ -251,6 +252,13 @@ } } + private void updateAutoMediaPlaybackPolicy(int mode) { + // This is the index of the AutoMediaPlaybackPolicy enum, index 1 is always_allow, for all + // other values we require a user gesture. + boolean requireUserGesture = mode != 1; + webView.getSettings().setMediaPlaybackRequiresUserGesture(requireUserGesture); + } + private void registerJavaScriptChannelNames(List<String> channelNames) { for (String channelName : channelNames) { webView.addJavascriptInterface(
diff --git a/packages/webview_flutter/example/assets/sample_audio.ogg b/packages/webview_flutter/example/assets/sample_audio.ogg new file mode 100644 index 0000000..27e1710 --- /dev/null +++ b/packages/webview_flutter/example/assets/sample_audio.ogg Binary files differ
diff --git a/packages/webview_flutter/example/pubspec.yaml b/packages/webview_flutter/example/pubspec.yaml index 6ec8a1d..8657cfd 100644 --- a/packages/webview_flutter/example/pubspec.yaml +++ b/packages/webview_flutter/example/pubspec.yaml
@@ -1,7 +1,7 @@ name: webview_flutter_example description: Demonstrates how to use the webview_flutter plugin. -version: 1.0.0+1 +version: 1.0.1 environment: sdk: ">=2.0.0-dev.68.0 <3.0.0" @@ -20,3 +20,5 @@ flutter: uses-material-design: true + assets: + - assets/sample_audio.ogg
diff --git a/packages/webview_flutter/example/test_driver/webview.dart b/packages/webview_flutter/example/test_driver/webview.dart index 7a290a0..b6abff9 100644 --- a/packages/webview_flutter/example/test_driver/webview.dart +++ b/packages/webview_flutter/example/test_driver/webview.dart
@@ -3,7 +3,11 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_driver/driver_extension.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -132,9 +136,166 @@ await controller.evaluateJavascript('Echo.postMessage("hello");'); expect(messagesReceived, equals(<String>['hello'])); }); + + group('Media playback policy', () { + String audioTestBase64; + setUpAll(() async { + final ByteData audioData = + await rootBundle.load('assets/sample_audio.ogg'); + final String base64AudioData = + base64Encode(Uint8List.view(audioData.buffer)); + final String audioTest = ''' + <!DOCTYPE html><html> + <head><title>Audio auto play</title> + <script type="text/javascript"> + function play() { + var audio = document.getElementById("audio"); + audio.play(); + } + function isPaused() { + var audio = document.getElementById("audio"); + return audio.paused; + } + </script> + </head> + <body onload="play();"> + <audio controls id="audio"> + <source src="data:audio/ogg;charset=utf-8;base64,$base64AudioData"> + </audio> + </body> + </html> + '''; + audioTestBase64 = base64Encode(const Utf8Encoder().convert(audioTest)); + }); + + test('Auto media playback', () async { + Completer<WebViewController> controllerCompleter = + Completer<WebViewController>(); + Completer<void> pageLoaded = Completer<void>(); + + await pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer<WebViewController>(); + pageLoaded = Completer<void>(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + test('Changes to initialMediaPlaybackPolocy are ignored', () async { + final Completer<WebViewController> controllerCompleter = + Completer<WebViewController>(); + Completer<void> pageLoaded = Completer<void>(); + + final GlobalKey key = GlobalKey(); + await pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageLoaded = Completer<void>(); + + await pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + }); } Future<void> pumpWidget(Widget widget) { runApp(widget); return WidgetsBinding.instance.endOfFrame; } + +// JavaScript booleans evaluate to different string values on Android and iOS. +// This utility method returns the string boolean value of the current platform. +String _webviewBool(bool value) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return value ? '1' : '0'; + } + return value ? 'true' : 'false'; +}
diff --git a/packages/webview_flutter/ios/Classes/FlutterWebView.m b/packages/webview_flutter/ios/Classes/FlutterWebView.m index c56d5c7..87cb0f5 100644 --- a/packages/webview_flutter/ios/Classes/FlutterWebView.m +++ b/packages/webview_flutter/ios/Classes/FlutterWebView.m
@@ -62,8 +62,12 @@ [self registerJavaScriptChannels:_javaScriptChannelNames controller:userContentController]; } + NSDictionary<NSString*, id>* settings = args[@"settings"]; + WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init]; configuration.userContentController = userContentController; + [self updateAutoMediaPlaybackPolicy:args[@"autoMediaPlaybackPolicy"] + inConfiguration:configuration]; _webView = [[WKWebView alloc] initWithFrame:frame configuration:configuration]; _navigationDelegate = [[FLTWKNavigationDelegate alloc] initWithChannel:_channel]; @@ -72,7 +76,7 @@ [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { [weakSelf onMethodCall:call result:result]; }]; - NSDictionary<NSString*, id>* settings = args[@"settings"]; + [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 @@ -271,6 +275,30 @@ } } +- (void)updateAutoMediaPlaybackPolicy:(NSNumber*)policy + inConfiguration:(WKWebViewConfiguration*)configuration { + switch ([policy integerValue]) { + case 0: // require_user_action_for_all_media_types + NSLog(@"requiring user action for all types"); + if (@available(iOS 10.0, *)) { + configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; + } else { + configuration.mediaPlaybackRequiresUserAction = true; + } + break; + case 1: // always_allow + NSLog(@"allowing auto playback"); + if (@available(iOS 10.0, *)) { + configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; + } else { + configuration.mediaPlaybackRequiresUserAction = false; + } + break; + default: + NSLog(@"webview_flutter: unknown auto media playback policy: %@", policy); + } +} + - (bool)loadRequest:(NSDictionary<NSString*, id>*)request { if (!request) { return false;
diff --git a/packages/webview_flutter/lib/platform_interface.dart b/packages/webview_flutter/lib/platform_interface.dart index bfb5b62..4e6b8b8 100644 --- a/packages/webview_flutter/lib/platform_interface.dart +++ b/packages/webview_flutter/lib/platform_interface.dart
@@ -183,9 +183,16 @@ } /// Configuration to use when creating a new [WebViewPlatformController]. +/// +/// The `autoMediaPlaybackPolicy` parameter must not be null. class CreationParams { - CreationParams( - {this.initialUrl, this.webSettings, this.javascriptChannelNames}); + CreationParams({ + this.initialUrl, + this.webSettings, + this.javascriptChannelNames, + this.autoMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + }) : assert(autoMediaPlaybackPolicy != null); /// The initialUrl to load in the webview. /// @@ -210,6 +217,9 @@ // to PlatformWebView. final Set<String> javascriptChannelNames; + /// Which restrictions apply on automatic media playback. + final AutoMediaPlaybackPolicy autoMediaPlaybackPolicy; + @override String toString() { return '$runtimeType(initialUrl: $initialUrl, settings: $webSettings, javascriptChannelNames: $javascriptChannelNames)';
diff --git a/packages/webview_flutter/lib/src/webview_method_channel.dart b/packages/webview_flutter/lib/src/webview_method_channel.dart index ce99887..a914e18 100644 --- a/packages/webview_flutter/lib/src/webview_method_channel.dart +++ b/packages/webview_flutter/lib/src/webview_method_channel.dart
@@ -103,7 +103,7 @@ 'removeJavascriptChannels', javascriptChannelNames.toList()); } - /// Method channel mplementation for [WebViewPlatform.clearCookies]. + /// Method channel implementation for [WebViewPlatform.clearCookies]. static Future<bool> clearCookies() { return _cookieManagerChannel .invokeMethod<bool>('clearCookies') @@ -135,6 +135,7 @@ 'initialUrl': creationParams.initialUrl, 'settings': _webSettingsToMap(creationParams.webSettings), 'javascriptChannelNames': creationParams.javascriptChannelNames.toList(), + 'autoMediaPlaybackPolicy': creationParams.autoMediaPlaybackPolicy.index, }; } }
diff --git a/packages/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/lib/webview_flutter.dart index 3c0175e..4335ed2 100644 --- a/packages/webview_flutter/lib/webview_flutter.dart +++ b/packages/webview_flutter/lib/webview_flutter.dart
@@ -72,6 +72,25 @@ /// Signature for when a [WebView] has finished loading a page. typedef void PageFinishedCallback(String url); +/// Specifies possible restrictions on automatic media playback. +/// +/// This is typically used in [WebView.initialMediaPlaybackPolicy]. +// The method channel implementation is marshalling this enum to the value's index, so the order +// is important. +enum AutoMediaPlaybackPolicy { + /// Starting any kind of media playback requires a user action. + /// + /// For example: JavaScript code cannot start playing media unless the code was executed + /// as a result of a user action (like a touch event). + require_user_action_for_all_media_types, + + /// Starting any kind of media playback is always allowed. + /// + /// For example: JavaScript code that's triggered when the page is loaded can start playing + /// video or audio. + always_allow, +} + 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. @@ -110,7 +129,7 @@ /// 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. + /// The `javascriptMode` and `autoMediaPlaybackPolicy` parameters must not be null. const WebView({ Key key, this.onWebViewCreated, @@ -121,7 +140,10 @@ this.gestureRecognizers, this.onPageFinished, this.debuggingEnabled = false, + this.initialMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, }) : assert(javascriptMode != null), + assert(initialMediaPlaybackPolicy != null), super(key: key); static WebViewPlatform _platform; @@ -255,6 +277,14 @@ /// By default `debuggingEnabled` is false. final bool debuggingEnabled; + /// Which restrictions apply on automatic media playback. + /// + /// This initial value is applied to the platform's webview upon creation. Any following + /// changes to this parameter are ignored (as long as the state of the [WebView] is preserved). + /// + /// The default policy is [AutoMediaPlaybackPolicy.require_user_action_for_all_media_types]. + final AutoMediaPlaybackPolicy initialMediaPlaybackPolicy; + @override State<StatefulWidget> createState() => _WebViewState(); } @@ -317,6 +347,7 @@ initialUrl: widget.initialUrl, webSettings: _webSettingsFromWidget(widget), javascriptChannelNames: _extractChannelNames(widget.javascriptChannels), + autoMediaPlaybackPolicy: widget.initialMediaPlaybackPolicy, ); } @@ -351,9 +382,10 @@ } return WebSettings( - javascriptMode: javascriptMode, - hasNavigationDelegate: hasNavigationDelegate, - debuggingEnabled: debuggingEnabled); + javascriptMode: javascriptMode, + hasNavigationDelegate: hasNavigationDelegate, + debuggingEnabled: debuggingEnabled, + ); } Set<String> _extractChannelNames(Set<JavascriptChannel> channels) {
diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index c4940c2..6cde699 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.10+5 +version: 0.3.11 author: Flutter Team <flutter-dev@googlegroups.com> homepage: https://github.com/flutter/plugins/tree/master/packages/webview_flutter