Bug 1470346 - Gesture activate all documents in tab, even across origins, upon user interaction. r=smaug draft
authorChris Pearce <cpearce@mozilla.com>
Fri, 22 Jun 2018 11:52:20 +1200
changeset 810527 d6b75732548cb1d73b9f82dce60a5e6e97d1da14
parent 809559 6b6f3f6ecf142908b3e437d3bc3fac75540a9bcb
child 811164 14558402124c3c2fdb0c67adab89b35f4d5ac3e4
child 811172 abefff91d90a16a37c8038b66dffdd16031564e7
push id114019
push userbmo:cpearce@mozilla.com
push dateTue, 26 Jun 2018 01:55:28 +0000
reviewerssmaug
bugs1470346
milestone62.0a1
Bug 1470346 - Gesture activate all documents in tab, even across origins, upon user interaction. r=smaug Sometimes when video is playing, a preroll ad plays, and that may be in a cross origin iframe. If autoplay media is disabled, we require a user gesture in a document before playback in that document is permitted, and we require each origin to be gesture activated separately. So in the cross origin preroll video add case, then the user will have to click once to unblock playback for the cross origin ad, and then once the preroll ad finishes, the user will have to click again to activate playback of the same origin content video. This is a bad user experience. So we should instead make gesture activation propagate up the doc tree irrespective of crossing origins. This way, when the user clicks to activate, all documents in that tab are also also effectively gesture activated, and so can autoplay. MozReview-Commit-ID: 1HZQ5zkubR
dom/base/nsDocument.cpp
dom/base/nsIDocument.h
dom/events/EventStateManager.cpp
dom/html/HTMLMediaElement.cpp
dom/media/AutoplayPolicy.cpp
dom/media/test/test_autoplay_policy_activation.html
dom/webidl/Document.webidl
toolkit/content/tests/browser/browser_autoplay_policy_iframe_hierarchy.js
--- a/dom/base/nsDocument.cpp
+++ b/dom/base/nsDocument.cpp
@@ -1489,17 +1489,17 @@ nsIDocument::nsIDocument()
     mWindow(nullptr),
     mBFCacheEntry(nullptr),
     mInSyncOperationCount(0),
     mBlockDOMContentLoaded(0),
     mUseCounters(0),
     mChildDocumentUseCounters(0),
     mNotifiedPageForUseCounter(0),
     mUserHasInteracted(false),
-    mUserHasActivatedInteraction(false),
+    mUserGestureActivated(false),
     mStackRefCnt(0),
     mUpdateNestLevel(0),
     mViewportType(Unknown),
     mViewportOverflowType(ViewportOverflowType::NoOverflow),
     mSubDocuments(nullptr),
     mHeaderData(nullptr),
     mFlashClassification(FlashClassification::Unclassified),
     mBoxObjectTable(nullptr),
@@ -12485,91 +12485,50 @@ void
 nsIDocument::SetUserHasInteracted(bool aUserHasInteracted)
 {
   MOZ_LOG(gUserInteractionPRLog, LogLevel::Debug,
           ("Document %p has been interacted by user.", this));
   mUserHasInteracted = aUserHasInteracted;
 }
 
 void
-nsIDocument::NotifyUserActivation()
-{
-  ActivateByUserGesture();
-  // Activate parent document which has same principle on the parent chain.
-  nsCOMPtr<nsIPrincipal> principal = NodePrincipal();
-  nsCOMPtr<nsIDocument> parent = GetSameTypeParentDocument();
-  while (parent) {
-    parent->MaybeActivateByUserGesture(principal);
-    parent = parent->GetSameTypeParentDocument();
-  }
-}
-
-void
-nsIDocument::MaybeActivateByUserGesture(nsIPrincipal* aPrincipal)
-{
-  bool isEqual = false;
-  nsresult rv = aPrincipal->Equals(NodePrincipal(), &isEqual);
-  if (NS_WARN_IF(NS_FAILED(rv))) {
-    return;
-  }
-
-  // If a child frame is actived, it would always activate the top frame and its
-  // parent frames which has same priciple.
-  if (isEqual || IsTopLevelContentDocument()) {
-    ActivateByUserGesture();
-  }
-}
-
-void
-nsIDocument::ActivateByUserGesture()
-{
-  if (mUserHasActivatedInteraction) {
-    return;
-  }
-
-  MOZ_LOG(gUserInteractionPRLog, LogLevel::Debug,
-          ("Document %p has been activated by user.", this));
-  mUserHasActivatedInteraction = true;
+nsIDocument::NotifyUserGestureActivation()
+{
+  // Activate this document and all documents up to the top level
+  // content document.
+  nsIDocument* doc = this;
+  while (doc && !doc->mUserGestureActivated) {
+    MOZ_LOG(gUserInteractionPRLog,
+            LogLevel::Debug,
+            ("Document %p has been activated by user.", this));
+    doc->mUserGestureActivated = true;
+    doc = doc->GetSameTypeParentDocument();
+  }
 }
 
 bool
-nsIDocument::HasBeenUserActivated()
-{
-  if (!mUserHasActivatedInteraction) {
-    // If one of its parent on the parent chain has been activated and has same
-    // principal, then this child would also be treated as activated.
-    nsIDocument* parent =
-      GetFirstParentDocumentWithSamePrincipal(NodePrincipal());
-    if (parent) {
-      mUserHasActivatedInteraction = parent->HasBeenUserActivated();
-    }
-  }
-
-  return mUserHasActivatedInteraction;
-}
-
-nsIDocument*
-nsIDocument::GetFirstParentDocumentWithSamePrincipal(nsIPrincipal* aPrincipal)
-{
-  MOZ_ASSERT(aPrincipal);
-  nsIDocument* parent = GetSameTypeParentDocument();
-  while (parent) {
-    bool isEqual = false;
-    nsresult rv = aPrincipal->Equals(parent->NodePrincipal(), &isEqual);
-    if (NS_WARN_IF(NS_FAILED(rv))) {
-      return nullptr;
-    }
-
-    if (isEqual) {
-      return parent;
-    }
-    parent = parent->GetSameTypeParentDocument();
-  }
-  MOZ_ASSERT(!parent);
-  return nullptr;
+nsIDocument::HasBeenUserGestureActivated()
+{
+  if (mUserGestureActivated) {
+    return true;
+  }
+
+  // If any ancestor document is activated, so are we.
+  nsIDocument* doc = GetSameTypeParentDocument();
+  while (doc) {
+    if (doc->mUserGestureActivated) {
+      // An ancestor is also activated. Record activation on the unactivated
+      // sub-branch to speed up future queries.
+      NotifyUserGestureActivation();
+      break;
+    }
+    doc = doc->GetSameTypeParentDocument();
+  }
+
+  return mUserGestureActivated;
 }
 
 nsIDocument*
 nsIDocument::GetSameTypeParentDocument()
 {
   nsCOMPtr<nsIDocShellTreeItem> current = GetDocShell();
   if (!current) {
     return nullptr;
--- a/dom/base/nsIDocument.h
+++ b/dom/base/nsIDocument.h
@@ -3391,28 +3391,35 @@ public:
   void SetDocumentAndPageUseCounter(mozilla::UseCounter aUseCounter)
   {
     SetDocumentUseCounter(aUseCounter);
     SetPageUseCounter(aUseCounter);
   }
 
   void PropagateUseCounters(nsIDocument* aParentDocument);
 
+  // Called to track whether this document has had any interaction.
+  // This is used to track whether we should permit "beforeunload".
   void SetUserHasInteracted(bool aUserHasInteracted);
   bool UserHasInteracted()
   {
     return mUserHasInteracted;
   }
 
-  // This would be called when document get activated by specific user gestures
-  // and propagate the user activation flag to its parent.
-  void NotifyUserActivation();
-
-  // Return true if document has interacted by specific user gestures.
-  bool HasBeenUserActivated();
+  // This should be called when this document receives events which are likely
+  // to be user interaction with the document, rather than the byproduct of
+  // interaction with the browser (i.e. a keypress to scroll the view port,
+  // keyboard shortcuts, etc). This is used to decide whether we should
+  // permit autoplay audible media. This also gesture activates all other
+  // content documents in this tab.
+  void NotifyUserGestureActivation();
+
+  // Return true if NotifyUserGestureActivation() has been called on any
+  // document in the document tree.
+  bool HasBeenUserGestureActivated();
 
   bool HasScriptsBlockedBySandbox();
 
   bool InlineScriptAllowedByCSP();
 
   void ReportHasScrollLinkedEffect();
   bool HasScrollLinkedEffect() const
   {
@@ -3700,24 +3707,16 @@ protected:
   void UpdateFrameRequestCallbackSchedulingState(nsIPresShell* aOldShell = nullptr);
 
   // Helper for GetScrollingElement/IsScrollingElement.
   bool IsPotentiallyScrollable(mozilla::dom::HTMLBodyElement* aBody);
 
   // Return the same type parent docuement if exists, or return null.
   nsIDocument* GetSameTypeParentDocument();
 
-  // Return the first parent document with same pricipal, return nullptr if we
-  // can't find it.
-  nsIDocument* GetFirstParentDocumentWithSamePrincipal(nsIPrincipal* aPrincipal);
-
-  // Activate the flag 'mUserHasActivatedInteraction' by specific user gestures.
-  void ActivateByUserGesture();
-  void MaybeActivateByUserGesture(nsIPrincipal* aPrincipal);
-
   // Helpers for GetElementsByName.
   static bool MatchNameAttribute(mozilla::dom::Element* aElement,
                                  int32_t aNamespaceID,
                                  nsAtom* aAtom, void* aData);
   static void* UseExistingNameString(nsINode* aRootNode, const nsString* aName);
 
   void MaybeResolveReadyForIdle();
 
@@ -4247,19 +4246,22 @@ protected:
   std::bitset<mozilla::eUseCounter_Count> mChildDocumentUseCounters;
   // Flags for whether we've notified our top-level "page" of a use counter
   // for this child document.
   std::bitset<mozilla::eUseCounter_Count> mNotifiedPageForUseCounter;
 
   // Whether the user has interacted with the document or not:
   bool mUserHasInteracted;
 
-  // Whether the user has interacted with the document via some specific user
-  // gestures.
-  bool mUserHasActivatedInteraction;
+  // Whether the user has interacted with the document via a restricted
+  // set of gestures which are likely to be interaction with the document,
+  // and not events that are fired as a byproduct of the user interacting
+  // with the browser (events for like scrolling the page, keyboard short
+  // cuts, etc).
+  bool mUserGestureActivated;
 
   mozilla::TimeStamp mPageUnloadingEventTimeStamp;
 
   RefPtr<mozilla::dom::DocGroup> mDocGroup;
 
   // The set of all the tracking script URLs.  URLs are added to this set by
   // calling NoteScriptTrackingStatus().  Currently we assume that a URL not
   // existing in the set means the corresponding script isn't a tracking script.
--- a/dom/events/EventStateManager.cpp
+++ b/dom/events/EventStateManager.cpp
@@ -874,17 +874,17 @@ EventStateManager::NotifyTargetUserActiv
   }
 
   nsCOMPtr<nsINode> node = do_QueryInterface(aTargetContent);
   if (!node) {
     return;
   }
 
   nsIDocument* doc = node->OwnerDoc();
-  if (!doc || doc->HasBeenUserActivated()) {
+  if (!doc || doc->HasBeenUserGestureActivated()) {
     return;
   }
 
   // Don't activate if the target content of the event is contentEditable or
   // is inside an editable document, or is a text input control. Activating
   // due to typing/clicking on a text input would be surprising user experience.
   if (aTargetContent->IsEditable() ||
       IsTextInput(aTargetContent)) {
@@ -910,17 +910,17 @@ EventStateManager::NotifyTargetUserActiv
       IsEventOutsideDragThreshold(aEvent->AsTouchEvent())) {
     return;
   }
 
   MOZ_ASSERT(aEvent->mMessage == eKeyDown   ||
              aEvent->mMessage == eMouseDown ||
              aEvent->mMessage == ePointerDown ||
              aEvent->mMessage == eTouchEnd);
-  doc->NotifyUserActivation();
+  doc->NotifyUserGestureActivation();
 }
 
 already_AddRefed<EventStateManager>
 EventStateManager::ESMFromContentOrThis(nsIContent* aContent)
 {
   if (aContent) {
     nsIPresShell* shell = aContent->OwnerDoc()->GetShell();
     if (shell) {
--- a/dom/html/HTMLMediaElement.cpp
+++ b/dom/html/HTMLMediaElement.cpp
@@ -1991,17 +1991,17 @@ HTMLMediaElement::Load()
        !!mSrcAttrStream,
        HasAttr(kNameSpaceID_None, nsGkAtoms::src),
        HasSourceChildren(this),
        EventStateManager::IsHandlingUserInput(),
        HasAttr(kNameSpaceID_None, nsGkAtoms::autoplay),
        IsAllowedToPlay(),
        OwnerDoc(),
        DocumentOrigin(OwnerDoc()).get(),
-       OwnerDoc() ? OwnerDoc()->HasBeenUserActivated() : 0,
+       OwnerDoc() ? OwnerDoc()->HasBeenUserGestureActivated() : 0,
        mMuted,
        mVolume));
 
   if (mIsRunningLoadMethod) {
     return;
   }
 
   mIsDoingExplicitLoad = true;
--- a/dom/media/AutoplayPolicy.cpp
+++ b/dom/media/AutoplayPolicy.cpp
@@ -50,17 +50,17 @@ AutoplayPolicy::IsMediaElementAllowedToP
 
   // Whitelisted.
   if (nsContentUtils::IsExactSitePermAllow(
         aElement->NodePrincipal(), "autoplay-media")) {
     return true;
   }
 
   // Activated by user gesture.
-  if (aElement->OwnerDoc()->HasBeenUserActivated()) {
+  if (aElement->OwnerDoc()->HasBeenUserGestureActivated()) {
     return true;
   }
 
   return false;
 }
 
 /* static */ bool
 AutoplayPolicy::IsAudioContextAllowedToPlay(NotNull<AudioContext*> aContext)
@@ -96,17 +96,17 @@ AutoplayPolicy::IsAudioContextAllowedToP
 
   // Whitelisted.
   if (principal &&
       nsContentUtils::IsExactSitePermAllow(principal, "autoplay-media")) {
     return true;
   }
 
   // Activated by user gesture.
-  if (window->GetExtantDoc()->HasBeenUserActivated()) {
+  if (window->GetExtantDoc()->HasBeenUserGestureActivated()) {
     return true;
   }
 
   return false;
 }
 
 } // namespace dom
 } // namespace mozilla
--- a/dom/media/test/test_autoplay_policy_activation.html
+++ b/dom/media/test/test_autoplay_policy_activation.html
@@ -72,22 +72,22 @@
             muted: true,
             same_origin_child: false,
             activated_child: false,
             activated_parent: false,
             should_play: true,
           },
 
           {
-            name: "audible playback in unactivated cross-origin iframe in activated parent blocked",
+            name: "audible playback in unactivated cross-origin iframe in activated parent allowed",
             muted: false,
             same_origin_child: false,
             activated_child: false,
             activated_parent: true,
-            should_play: false,
+            should_play: true,
           },
 
           {
             name: "audible playback in unactivated cross-origin iframe in unactivated parent blocked",
             muted: false,
             same_origin_child: false,
             activated_child: false,
             activated_parent: false,
--- a/dom/webidl/Document.webidl
+++ b/dom/webidl/Document.webidl
@@ -467,17 +467,17 @@ partial interface Document {
 partial interface Document {
   [ChromeOnly] readonly attribute boolean userHasInteracted;
 };
 
 // Extension to give chrome JS the ability to simulate activate the docuement
 // by user gesture.
 partial interface Document {
   [ChromeOnly]
-  void notifyUserActivation();
+  void notifyUserGestureActivation();
 };
 
 // Extension to give chrome and XBL JS the ability to determine whether
 // the document is sandboxed without permission to run scripts
 // and whether inline scripts are blocked by the document's CSP.
 partial interface Document {
   [Func="IsChromeOrXBL"] readonly attribute boolean hasScriptsBlockedBySandbox;
   [Func="IsChromeOrXBL"] readonly attribute boolean inlineScriptAllowedByCSP;
--- a/toolkit/content/tests/browser/browser_autoplay_policy_iframe_hierarchy.js
+++ b/toolkit/content/tests/browser/browser_autoplay_policy_iframe_hierarchy.js
@@ -5,23 +5,19 @@
  * propagated to its parent and child frames.
  *
  * In this test, I use A/B/C to indicate different domain frames, and the number
  * after the name is which layer the frame is in.
  * Ex. A1 -> B2 -> A3,
  * Top frame and grandchild frame is in the domain A, and child frame is in the
  * domain B.
  *
- * Child frames could get permission if they have same origin as target frame's
- * Parent frames could get permission if they have same origin as target frame's
- * or the frame is in the top level.
- * Ex. A1 -> B2 -> B3,
- * A1 will always be activated no matter which level frame user activates with,
- * since it's in the top level.
- * B2/B3 will only be activated when user activates frame B2 or B3.
+ * Once any document in a tab is activated, all other documents in that tab
+ * should be considered activated, even if they're cross origin from the
+ * originally activated document.
  */
 const PAGE_A1_A2 = "https://example.com/browser/toolkit/content/tests/browser/file_autoplay_two_layers_frame1.html";
 const PAGE_A1_B2 = "https://example.com/browser/toolkit/content/tests/browser/file_autoplay_two_layers_frame2.html";
 const PAGE_A1_B2_C3 = "https://test1.example.org/browser/toolkit/content/tests/browser/file_autoplay_three_layers_frame1.html";
 const PAGE_A1_B2_A3 = "https://example.org/browser/toolkit/content/tests/browser/file_autoplay_three_layers_frame1.html";
 const PAGE_A1_B2_B3 = "https://example.org/browser/toolkit/content/tests/browser/file_autoplay_three_layers_frame2.html";
 const PAGE_A1_A2_A3 = "https://example.com/browser/toolkit/content/tests/browser/file_autoplay_three_layers_frame2.html";
 const PAGE_A1_A2_B3 = "https://example.com/browser/toolkit/content/tests/browser/file_autoplay_three_layers_frame1.html";
@@ -75,72 +71,40 @@ async function test_permission_propagati
       info(`- activate frame in layer ${layerIdx} (${testInfo[1]}) -`);
       let doc;
       if (layerIdx == 1) {
         doc = content.document;
       } else {
         doc = layerIdx == 2 ? content.frames[0].document :
                               content.frames[0].frames[0].document;
       }
-      doc.notifyUserActivation();
+      doc.notifyUserGestureActivation();
     }
     await ContentTask.spawn(tab.linkedBrowser, [layerIdx, testName],
                             activate_frame);
 
-    // If frame is activated, the video play will succeed.
+    // If frame is activated, the video play will succeed, as interaction
+    // anywhere in the frame activates the entire doctree, irrespective
+    // of whether the documents are cross origin or not.
     async function playing_video_may_success(testInfo) {
-      let activeLayerIdx = testInfo[0];
-      let testName = testInfo[1];
       let layersNum = testInfo[2];
       for (let layerIdx = 1; layerIdx <= layersNum; layerIdx++) {
         let doc;
         if (layerIdx == 1) {
           doc = content.document;
         } else {
           doc = layerIdx == 2 ? content.frames[0].document :
                                 content.frames[0].frames[0].document;
         }
         let video = doc.getElementById("v");
-        let shouldSuccess = false;
-        let isActiveLayer = layerIdx == activeLayerIdx;
-        switch (testName) {
-          case "A1_A2":
-          case "A1_A2_A3":
-            // always success to play.
-            shouldSuccess = true;
-            break;
-          case "A1_B2":
-            shouldSuccess = layerIdx == 1 ||
-                            (layerIdx == 2 && isActiveLayer);
-            break;
-          case "A1_B2_C3":
-            shouldSuccess = layerIdx == 1 ||
-                            (layerIdx >= 2 && isActiveLayer);
-            break;
-          case "A1_B2_A3":
-            shouldSuccess = layerIdx != 2 ||
-                            (layerIdx == 2 && isActiveLayer);
-            break;
-          case "A1_B2_B3":
-            shouldSuccess = layerIdx == 1 ||
-                            (layerIdx >= 2 && activeLayerIdx != 1);
-            break;
-          case "A1_A2_B3":
-            shouldSuccess = layerIdx <= 2 ||
-                            (layerIdx == 3 && isActiveLayer);
-            break;
-          default:
-            ok(false, "wrong test name.");
-            break;
-        }
         try {
           await video.play();
-          ok(shouldSuccess, `video in layer ${layerIdx} starts playing.`);
+          ok(true, `video in layer ${layerIdx} starts playing.`);
         } catch (e) {
-          ok(!shouldSuccess, `video in layer ${layerIdx} fails to start.`);
+          ok(false, `video in layer ${layerIdx} fails to start.`);
         }
       }
     }
     await ContentTask.spawn(tab.linkedBrowser,
                             [layerIdx, testName, layersNum],
                             playing_video_may_success);
 
     info("- remove tab -");