Bug 1476555 - Show notification when autoplay blocked globally. r=cpearce, r=johannh draft
authorDale Harvey <dale@arandomurl.com>
Mon, 23 Jul 2018 16:43:08 +0100
changeset 825901 6b1999ea36bfa153e161a88329654931b180164a
parent 825900 6a4be2e4b872a5aaff6a5969d2f82fbb03a9cc3b
child 825902 c9070bf6d2893d332e5b38d66ea79bef135c23ff
push id118196
push userbmo:dharvey@mozilla.com
push dateThu, 02 Aug 2018 15:27:37 +0000
reviewerscpearce, johannh
bugs1476555
milestone63.0a1
Bug 1476555 - Show notification when autoplay blocked globally. r=cpearce, r=johannh MozReview-Commit-ID: BMGJ0J5p9rw
browser/base/content/test/permissions/browser.ini
browser/base/content/test/permissions/browser_autoplay_blocked.html
browser/base/content/test/permissions/browser_autoplay_blocked.js
browser/modules/PermissionUI.jsm
browser/modules/SitePermissions.jsm
dom/html/HTMLMediaElement.cpp
dom/media/AutoplayPolicy.cpp
dom/media/AutoplayPolicy.h
--- a/browser/base/content/test/permissions/browser.ini
+++ b/browser/base/content/test/permissions/browser.ini
@@ -5,11 +5,15 @@ support-files=
 
 [browser_canvas_fingerprinting_resistance.js]
 [browser_permissions.js]
 [browser_reservedkey.js]
 [browser_temporary_permissions.js]
 support-files =
   temporary_permissions_subframe.html
   ../webrtc/get_user_media.html
+[browser_autoplay_blocked.js]
+support-files =
+  browser_autoplay_blocked.html
+  ../general/audio.ogg
 [browser_temporary_permissions_expiry.js]
 [browser_temporary_permissions_navigation.js]
 [browser_temporary_permissions_tabs.js]
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_autoplay_blocked.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+  <head>
+    <meta charset="utf8">
+  </head>
+  <body>
+    <audio autoplay="autoplay" >
+      <source src="audio.ogg" />
+    </audio>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_autoplay_blocked.js
@@ -0,0 +1,51 @@
+/*
+ * Test that a blocked request to autoplay media is shown to the user
+ */
+
+const AUTOPLAY_PAGE  = getRootDirectory(gTestPath).replace("chrome://mochitests/content", "https://example.com") + "browser_autoplay_blocked.html";
+
+function openIdentityPopup() {
+  let promise = BrowserTestUtils.waitForEvent(gIdentityHandler._identityPopup, "popupshown");
+  gIdentityHandler._identityBox.click();
+  return promise;
+}
+
+function closeIdentityPopup() {
+  let promise = BrowserTestUtils.waitForEvent(gIdentityHandler._identityPopup, "popuphidden");
+  gIdentityHandler._identityPopup.hidePopup();
+  return promise;
+}
+
+add_task(async function testMainViewVisible() {
+
+  Services.prefs.setIntPref("media.autoplay.default", Ci.nsIAutoplay.ALLOWED);
+
+  await BrowserTestUtils.withNewTab(AUTOPLAY_PAGE, async function() {
+    let permissionsList = document.getElementById("identity-popup-permission-list");
+    let emptyLabel = permissionsList.nextSibling.nextSibling;
+
+    await openIdentityPopup();
+    ok(!BrowserTestUtils.is_hidden(emptyLabel), "List of permissions is empty");
+    await closeIdentityPopup();
+  });
+
+  Services.prefs.setIntPref("media.autoplay.default", Ci.nsIAutoplay.BLOCKED);
+
+  await BrowserTestUtils.withNewTab(AUTOPLAY_PAGE, async function() {
+    let permissionsList = document.getElementById("identity-popup-permission-list");
+    let emptyLabel = permissionsList.nextSibling.nextSibling;
+
+    await openIdentityPopup();
+
+    ok(BrowserTestUtils.is_hidden(emptyLabel), "List of permissions is not empty");
+
+    let labelText = SitePermissions.getPermissionLabel("autoplay-media");
+    let labels = permissionsList.querySelectorAll(".identity-popup-permission-label");
+    is(labels.length, 1, "One permission visible in main view");
+    is(labels[0].textContent, labelText, "Correct value");
+
+    await closeIdentityPopup();
+  });
+
+  Services.prefs.clearUserPref("media.autoplay.default");
+});
--- a/browser/modules/PermissionUI.jsm
+++ b/browser/modules/PermissionUI.jsm
@@ -274,16 +274,30 @@ var PermissionPromptPrototype = {
       // If we're reading and setting permissions, then we need
       // to check to see if we already have a permission setting
       // for this particular principal.
       let {state} = SitePermissions.get(requestingURI,
                                         this.permissionKey,
                                         this.browser);
 
       if (state == SitePermissions.BLOCK) {
+
+        // If the request is blocked by a global setting then we record
+        // a flag that lasts for the duration of the current page load
+        // to notify the user that the permission has been blocked.
+        // Currently only applies to autoplay-media
+        if (state == SitePermissions.getDefault(this.permissionKey) &&
+            this.permissionKey === "autoplay-media") {
+          SitePermissions.set(this.principal.URI,
+                              this.permissionKey,
+                              state,
+                              SitePermissions.SCOPE_POLICY,
+                              this.browser);
+        }
+
         this.cancel();
         return;
       }
 
       if (state == SitePermissions.ALLOW) {
         this.allow();
         return;
       }
--- a/browser/modules/SitePermissions.jsm
+++ b/browser/modules/SitePermissions.jsm
@@ -124,16 +124,81 @@ const TemporaryBlockedPermissions = {
   copy(browser, newBrowser) {
     let entry = this._stateByBrowser.get(browser);
     if (entry) {
       this._stateByBrowser.set(newBrowser, entry);
     }
   },
 };
 
+// This hold a flag per browser to indicate whether we should show the
+// user a notification as a permission has been requested that has been
+// blocked globally. We only want to notify the user in the case that
+// they actually requested the permission within the current page load
+// so will clear the flag on navigation
+const GloballyBlockedPermissions = {
+
+  _stateByBrowser: new WeakMap(),
+
+  set(browser, id) {
+    if (!this._stateByBrowser.has(browser)) {
+      this._stateByBrowser.set(browser, {});
+    }
+    let entry = this._stateByBrowser.get(browser);
+    let prePath = browser.currentURI.prePath;
+    if (!entry[prePath]) {
+      entry[prePath] = {};
+    }
+
+    entry[prePath][id] = true;
+
+    // Listen to any top level navigations, once we so one, clear the flag
+    // and remove the listener
+    browser.addProgressListener({
+      QueryInterface: ChromeUtils.generateQI([Ci.nsIWebProgressListener,
+                                              Ci.nsISupportsWeakReference]),
+      onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+        if (aWebProgress.isTopLevel) {
+          GloballyBlockedPermissions.remove(browser, id);
+          browser.removeProgressListener(this);
+        }
+      },
+    });
+  },
+
+  // Removes a permission with the specified id for the specified browser.
+  remove(browser, id) {
+    let entry = this._stateByBrowser.get(browser);
+    let prePath = browser.currentURI.prePath;
+    if (entry && entry[prePath]) {
+      delete entry[prePath][id];
+    }
+  },
+
+  // Gets all permissions for the specified browser.
+  // Note that only permissions that apply to the current URI
+  // of the passed browser element will be returned.
+  getAll(browser) {
+    let permissions = [];
+    let entry = this._stateByBrowser.get(browser);
+    let prePath = browser.currentURI.prePath;
+    if (entry && entry[prePath]) {
+      let timeStamps = entry[prePath];
+      for (let id of Object.keys(timeStamps)) {
+        permissions.push({
+          id,
+          state: SitePermissions.BLOCK,
+          scope: SitePermissions.SCOPE_POLICY
+        });
+      }
+    }
+    return permissions;
+  },
+};
+
 /**
  * A module to manage permanent and temporary permissions
  * by URI and browser.
  *
  * Some methods have the side effect of dispatching a "PermissionStateChange"
  * event on changes to temporary permissions, as mentioned in the respective docs.
  */
 var SitePermissions = {
@@ -226,16 +291,20 @@ var SitePermissions = {
   getAllForBrowser(browser) {
     let permissions = {};
 
     for (let permission of TemporaryBlockedPermissions.getAll(browser)) {
       permission.scope = this.SCOPE_TEMPORARY;
       permissions[permission.id] = permission;
     }
 
+    for (let permission of GloballyBlockedPermissions.getAll(browser)) {
+      permissions[permission.id] = permission;
+    }
+
     for (let permission of this.getAllByURI(browser.currentURI)) {
       permissions[permission.id] = permission;
     }
 
     return Object.values(permissions);
   },
 
   /**
@@ -399,16 +468,24 @@ var SitePermissions = {
    *        The state of the permission.
    * @param {SitePermissions scope} scope (optional)
    *        The scope of the permission. Defaults to SCOPE_PERSISTENT.
    * @param {Browser} browser (optional)
    *        The browser object to set temporary permissions on.
    *        This needs to be provided if the scope is SCOPE_TEMPORARY!
    */
   set(uri, permissionID, state, scope = this.SCOPE_PERSISTENT, browser = null) {
+
+    if (scope == this.SCOPE_POLICY && state == this.BLOCK) {
+      GloballyBlockedPermissions.set(browser, permissionID);
+      browser.dispatchEvent(new browser.ownerGlobal
+                            .CustomEvent("PermissionStateChange"));
+      return;
+    }
+
     if (state == this.UNKNOWN || state == this.getDefault(permissionID)) {
       // Because they are controlled by two prefs with many states that do not
       // correspond to the classical ALLOW/DENY/PROMPT model, we want to always
       // allow the user to add exceptions to their cookie rules without removing them.
       if (permissionID != "cookie") {
         this.remove(uri, permissionID, browser);
         return;
       }
@@ -605,20 +682,20 @@ var gPermissionObject = {
    *    don't want to expose a "Hide Prompt" button to the user through pageinfo.
    */
 
   "autoplay-media": {
     exactHostMatch: true,
     getDefault() {
       let state = Services.prefs.getIntPref("media.autoplay.default",
                                             Ci.nsIAutoplay.PROMPT);
-      if (state == Ci.nsIAutoplay.ALLOW) {
+      if (state == Ci.nsIAutoplay.ALLOWED) {
         return SitePermissions.ALLOW;
-      } if (state == Ci.nsIAutoplay.BLOCK) {
-        return SitePermissions.DENY;
+      } if (state == Ci.nsIAutoplay.BLOCKED) {
+        return SitePermissions.BLOCK;
       }
       return SitePermissions.UNKNOWN;
     },
     labelID: "autoplay-media"
   },
 
   "image": {
     states: [ SitePermissions.ALLOW, SitePermissions.BLOCK ],
--- a/dom/html/HTMLMediaElement.cpp
+++ b/dom/html/HTMLMediaElement.cpp
@@ -2001,17 +2001,17 @@ HTMLMediaElement::Load()
        "ownerDoc=%p (%s) ownerDocUserActivated=%d "
        "muted=%d volume=%f",
        this,
        !!mSrcAttrStream,
        HasAttr(kNameSpaceID_None, nsGkAtoms::src),
        HasSourceChildren(this),
        EventStateManager::IsHandlingUserInput(),
        HasAttr(kNameSpaceID_None, nsGkAtoms::autoplay),
-       AutoplayPolicy::IsAllowedToPlay(*this) == nsIAutoplay::ALLOWED,
+       AutoplayPolicy::IsAllowedToPlay(*this),
        OwnerDoc(),
        DocumentOrigin(OwnerDoc()).get(),
        OwnerDoc() ? OwnerDoc()->HasBeenUserGestureActivated() : 0,
        mMuted,
        mVolume));
 
   if (mIsRunningLoadMethod) {
     return;
@@ -2529,17 +2529,17 @@ HTMLMediaElement::AllowedToPlay() const
 }
 
 void
 HTMLMediaElement::UpdatePreloadAction()
 {
   PreloadAction nextAction = PRELOAD_UNDEFINED;
   // If autoplay is set, or we're playing, we should always preload data,
   // as we'll need it to play.
-  if ((AutoplayPolicy::IsAllowedToPlay(*this) == nsIAutoplay::ALLOWED &&
+  if ((AutoplayPolicy::IsAllowedToPlay(*this) &&
        HasAttr(kNameSpaceID_None, nsGkAtoms::autoplay)) ||
       !mPaused) {
     nextAction = HTMLMediaElement::PRELOAD_ENOUGH;
   } else {
     // Find the appropriate preload action by looking at the attribute.
     const nsAttrValue* val =
       mAttrsAndChildren.GetAttr(nsGkAtoms::preload, kNameSpaceID_None);
     // MSE doesn't work if preload is none, so it ignores the pref when src is
@@ -4090,37 +4090,24 @@ HTMLMediaElement::Play(ErrorResult& aRv)
       DispatchAsyncEvent(NS_LITERAL_STRING("blocked"));
     }
     return promise.forget();
   }
 
   UpdateHadAudibleAutoplayState();
 
   const bool handlingUserInput = EventStateManager::IsHandlingUserInput();
-  switch (AutoplayPolicy::IsAllowedToPlay(*this)) {
-    case nsIAutoplay::ALLOWED: {
-      mPendingPlayPromises.AppendElement(promise);
-      PlayInternal(handlingUserInput);
-      UpdateCustomPolicyAfterPlayed();
-      break;
-    }
-    case nsIAutoplay::BLOCKED: {
-      LOG(LogLevel::Debug, ("%p play not blocked.", this));
-      promise->MaybeReject(NS_ERROR_DOM_MEDIA_NOT_ALLOWED_ERR);
-      if (StaticPrefs::MediaBlockEventEnabled()) {
-        DispatchAsyncEvent(NS_LITERAL_STRING("blocked"));
-      }
-      break;
-    }
-    case nsIAutoplay::PROMPT: {
-      // Prompt the user for permission to play.
-      mPendingPlayPromises.AppendElement(promise);
-      EnsureAutoplayRequested(handlingUserInput);
-      break;
-    }
+  if (AutoplayPolicy::IsAllowedToPlay(*this)) {
+    mPendingPlayPromises.AppendElement(promise);
+    PlayInternal(handlingUserInput);
+    UpdateCustomPolicyAfterPlayed();
+  } else {
+    // Prompt the user for permission to play.
+    mPendingPlayPromises.AppendElement(promise);
+    EnsureAutoplayRequested(handlingUserInput);
   }
   return promise.forget();
 }
 
 void
 HTMLMediaElement::EnsureAutoplayRequested(bool aHandlingUserInput)
 {
   if (mAutoplayPermissionRequest.Exists()) {
@@ -6134,18 +6121,17 @@ HTMLMediaElement::ChangeReadyState(nsMed
     DispatchAsyncEvent(NS_LITERAL_STRING("loadeddata"));
     mLoadedDataFired = true;
   }
 
   if (oldState < HAVE_FUTURE_DATA && mReadyState >= HAVE_FUTURE_DATA) {
     DispatchAsyncEvent(NS_LITERAL_STRING("canplay"));
     if (!mPaused) {
       if (mDecoder && !mPausedForInactiveDocumentOrChannel) {
-        MOZ_ASSERT(AutoplayPolicy::IsAllowedToPlay(*this) ==
-                   nsIAutoplay::ALLOWED);
+        MOZ_ASSERT(AutoplayPolicy::IsAllowedToPlay(*this));
         mDecoder->Play();
       }
       NotifyAboutPlaying();
     }
   }
 
   CheckAutoplayDataReady();
 
@@ -6247,24 +6233,19 @@ HTMLMediaElement::CanActivateAutoplay()
 void
 HTMLMediaElement::CheckAutoplayDataReady()
 {
   if (!CanActivateAutoplay()) {
     return;
   }
 
   UpdateHadAudibleAutoplayState();
-  switch (AutoplayPolicy::IsAllowedToPlay(*this)) {
-    case nsIAutoplay::BLOCKED:
-      return;
-    case nsIAutoplay::PROMPT:
-      EnsureAutoplayRequested(false);
-      return;
-    case nsIAutoplay::ALLOWED:
-      break;
+  if (!AutoplayPolicy::IsAllowedToPlay(*this)) {
+    EnsureAutoplayRequested(false);
+    return;
   }
 
   mPaused = false;
   // We changed mPaused which can affect AddRemoveSelfReference
   AddRemoveSelfReference();
   UpdateSrcMediaStreamPlaying();
   UpdateAudioChannelPlayingState();
 
--- a/dom/media/AutoplayPolicy.cpp
+++ b/dom/media/AutoplayPolicy.cpp
@@ -115,35 +115,48 @@ IsMediaElementAllowedToPlay(const HTMLMe
 }
 
 /* static */ bool
 AutoplayPolicy::WouldBeAllowedToPlayIfAutoplayDisabled(const HTMLMediaElement& aElement)
 {
   return IsMediaElementAllowedToPlay(aElement);
 }
 
-/* static */ uint32_t
+/* static */ bool
 AutoplayPolicy::IsAllowedToPlay(const HTMLMediaElement& aElement)
 {
   const uint32_t autoplayDefault = DefaultAutoplayBehaviour();
   // TODO : this old way would be removed when user-gestures-needed becomes
   // as a default option to block autoplay.
   if (!Preferences::GetBool("media.autoplay.enabled.user-gestures-needed", false)) {
     // If element is blessed, it would always be allowed to play().
     return (autoplayDefault == nsIAutoplay::ALLOWED ||
             aElement.IsBlessed() ||
-            EventStateManager::IsHandlingUserInput())
-              ? nsIAutoplay::ALLOWED : nsIAutoplay::BLOCKED;
+            EventStateManager::IsHandlingUserInput());
   }
 
   if (IsMediaElementAllowedToPlay(aElement)) {
-    return nsIAutoplay::ALLOWED;
+    return true;
+  }
+
+  // Muted content
+  if ((aElement.Volume() == 0.0 || aElement.Muted()) &&
+      Preferences::GetBool("media.autoplay.allow-muted", true)) {
+    return true;
   }
 
-  return autoplayDefault;
+  if (IsWindowAllowedToPlay(aElement.OwnerDoc()->GetInnerWindow())) {
+    return true;
+  }
+
+  if (autoplayDefault == nsIAutoplay::ALLOWED) {
+    return true;
+  }
+
+  return false;
 }
 
 /* static */ bool
 AutoplayPolicy::IsAudioContextAllowedToPlay(NotNull<AudioContext*> aContext)
 {
   if (!Preferences::GetBool("media.autoplay.block-webaudio", false)) {
     return true;
   }
--- a/dom/media/AutoplayPolicy.h
+++ b/dom/media/AutoplayPolicy.h
@@ -31,17 +31,17 @@ class AudioContext;
  *    We restrict user gestures to "mouse click", "keyboard press" and "touch".
  * 2) Muted media content or video without audio content.
  * 3) Document's origin has the "autoplay-media" permission.
  */
 class AutoplayPolicy
 {
 public:
   // Returns whether a given media element is allowed to play.
-  static uint32_t IsAllowedToPlay(const HTMLMediaElement& aElement);
+  static bool IsAllowedToPlay(const HTMLMediaElement& aElement);
 
   // Returns true if a given media element would be allowed to play
   // if block autoplay was enabled. If this returns false, it means we would
   // either block or ask for permission.
   // Note: this is for telemetry purposes, and doesn't check the prefs
   // which enable/disable block autoplay. Do not use for blocking logic!
   static bool WouldBeAllowedToPlayIfAutoplayDisabled(const HTMLMediaElement& aElement);