[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