Window Placement: Allow requestFullscreen from onscreenschange

Similar to allowing requestFullscreen on screen orientation changes.
Allows sites to requestFullscreen when the user changes screen config
(e.g. when the user connects an external display to a laptop)

Add TransientAllowFullscreen for async support like UserActivationState.
(ScopedAllowFullscreen only supports sync, stack-allocation scopes)
Add a basic unit test for the new class, and an integration test.

(cherry picked from commit db8f4f0a098d52c66855b62234ecec005e1ff572)

Bug: 1077402
Test: window.onscreenschange = async () => { element.requestFullscreen({screen:(await getScreens())[1]}); };
Change-Id: Iffc5bb419e2b016704b923bd8454cb422672b5e7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2429967
Commit-Queue: Michael Wasserman <[email protected]>
Auto-Submit: Michael Wasserman <[email protected]>
Reviewed-by: Daniel Cheng <[email protected]>
Reviewed-by: Mustaq Ahmed <[email protected]>
Reviewed-by: John Abd-El-Malek <[email protected]>
Cr-Original-Commit-Position: refs/heads/master@{#814036}
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2458777
Reviewed-by: Michael Wasserman <[email protected]>
Cr-Commit-Position: refs/branch-heads/4280@{#109}
Cr-Branched-From: ea420fb963f9658c9969b6513c56b8f47efa1a2a-refs/heads/master@{#812852}
diff --git a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc
index d9e3dcd4..42902ef 100644
--- a/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc
+++ b/chrome/browser/ui/exclusive_access/fullscreen_controller_interactive_browsertest.cc
@@ -30,6 +30,7 @@
 #include "net/test/embedded_test_server/embedded_test_server.h"
 #include "third_party/blink/public/mojom/frame/fullscreen.mojom.h"
 #include "ui/display/screen_base.h"
+#include "ui/display/test/test_screen.h"
 
 #if defined(OS_CHROMEOS)
 #include "ash/shell.h"
@@ -684,9 +685,7 @@
                        MAYBE_FullscreenOnSecondDisplay) {
   // Updates the display configuration to add a secondary display.
 #if defined(OS_CHROMEOS)
-  display::DisplayManager* display_manager =
-      ash::Shell::Get()->display_manager();
-  display::test::DisplayManagerTestApi(display_manager)
+  display::test::DisplayManagerTestApi(ash::Shell::Get()->display_manager())
       .UpdateDisplay("100+100-801x802,901+100-801x802");
 #else
   display::Screen* original_screen = display::Screen::GetScreen();
@@ -709,13 +708,14 @@
   EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), url));
   auto* tab = browser()->tab_strip_model()->GetActiveWebContents();
 
-  // Auto-accept the permission request.
+  // Auto-accept the Window Placement permission request.
   permissions::PermissionRequestManager* permission_request_manager =
       permissions::PermissionRequestManager::FromWebContents(tab);
   permission_request_manager->set_auto_response_for_test(
       permissions::PermissionRequestManager::ACCEPT_ALL);
 
   // Execute JS to request fullscreen on the second display (on the right).
+  FullscreenNotificationObserver enter_fullscreen_observer(browser());
   const std::string request_fullscreen_script = R"(
       (async () => {
           const screens = await self.getScreens();
@@ -725,7 +725,8 @@
       })();
   )";
   EXPECT_EQ(true, EvalJs(tab, request_fullscreen_script));
-  EXPECT_TRUE(IsExclusiveAccessBubbleDisplayed());
+  enter_fullscreen_observer.Wait();
+  EXPECT_TRUE(browser()->window()->IsFullscreen());
 #if defined(OS_CHROMEOS)
   EXPECT_EQ(gfx::Rect(801, 0, 801, 802), browser()->window()->GetBounds());
 #else
@@ -733,6 +734,7 @@
 #endif  // OS_CHROMEOS
 
   // Execute JS to exit fullscreen.
+  FullscreenNotificationObserver exit_fullscreen_observer(browser());
   const std::string exit_fullscreen_script = R"(
       (async () => {
           await document.exitFullscreen();
@@ -740,10 +742,71 @@
       })();
   )";
   EXPECT_EQ(false, EvalJs(tab, exit_fullscreen_script));
-  EXPECT_FALSE(IsExclusiveAccessBubbleDisplayed());
+  exit_fullscreen_observer.Wait();
+  EXPECT_FALSE(browser()->window()->IsFullscreen());
   EXPECT_EQ(original_bounds, browser()->window()->GetBounds());
 
 #if !defined(OS_CHROMEOS)
   display::Screen::SetScreenInstance(original_screen);
 #endif  // !OS_CHROMEOS
 }
+
+// Tests async fullscreen requests on screenschange event.
+// TODO(crbug.com/1134731): Disabled on Windows, where RenderWidgetHostViewAura
+// blindly casts display::Screen::GetScreen() to display::win::ScreenWin*.
+#if defined(OS_WIN)
+#define MAYBE_FullscreenOnScreensChange DISABLED_FullscreenOnScreensChange
+#else
+#define MAYBE_FullscreenOnScreensChange FullscreenOnScreensChange
+#endif
+IN_PROC_BROWSER_TEST_F(ExperimentalFullscreenControllerInteractiveTest,
+                       MAYBE_FullscreenOnScreensChange) {
+#if !defined(OS_CHROMEOS)
+  // Install a mock screen object to be monitored by a new web contents.
+  display::Screen* original_screen = display::Screen::GetScreen();
+  display::ScreenBase screen;
+  screen.display_list().AddDisplay({1, gfx::Rect(100, 100, 801, 802)},
+                                   display::DisplayList::Type::PRIMARY);
+  display::Screen::SetScreenInstance(&screen);
+#endif  // OS_CHROMEOS
+
+  // Open a new foreground tab that will observe the mock screen object.
+  ASSERT_TRUE(embedded_test_server()->Start());
+  const GURL url(embedded_test_server()->GetURL("/simple.html"));
+  AddTabAtIndex(1, url, PAGE_TRANSITION_TYPED);
+  auto* tab = browser()->tab_strip_model()->GetActiveWebContents();
+
+  // Auto-accept the Window Placement permission request.
+  permissions::PermissionRequestManager* permission_request_manager =
+      permissions::PermissionRequestManager::FromWebContents(tab);
+  permission_request_manager->set_auto_response_for_test(
+      permissions::PermissionRequestManager::ACCEPT_ALL);
+
+  // Add a screenschange handler to requestFullscreen after awaiting getScreens.
+  const std::string request_fullscreen_script = R"(
+      window.onscreenschange = async () => {
+        const screens = await self.getScreens();
+        await document.body.requestFullscreen();
+      };
+  )";
+  EXPECT_TRUE(EvalJs(tab, request_fullscreen_script).error.empty());
+  EXPECT_FALSE(browser()->window()->IsFullscreen());
+
+  FullscreenNotificationObserver fullscreen_observer(browser());
+
+  // Update the display configuration to trigger window.onscreenschange.
+#if defined(OS_CHROMEOS)
+  display::test::DisplayManagerTestApi(ash::Shell::Get()->display_manager())
+      .UpdateDisplay("100+100-801x802,901+100-801x802");
+#else
+  screen.display_list().AddDisplay({2, gfx::Rect(901, 100, 801, 802)},
+                                   display::DisplayList::Type::NOT_PRIMARY);
+#endif  // OS_CHROMEOS
+
+  fullscreen_observer.Wait();
+  EXPECT_TRUE(browser()->window()->IsFullscreen());
+
+#if !defined(OS_CHROMEOS)
+  display::Screen::SetScreenInstance(original_screen);
+#endif  // !OS_CHROMEOS
+}
diff --git a/content/browser/renderer_host/render_frame_host_delegate.cc b/content/browser/renderer_host/render_frame_host_delegate.cc
index 80ec918..f28463e 100644
--- a/content/browser/renderer_host/render_frame_host_delegate.cc
+++ b/content/browser/renderer_host/render_frame_host_delegate.cc
@@ -170,6 +170,10 @@
   return false;
 }
 
+bool RenderFrameHostDelegate::IsTransientAllowFullscreenActive() const {
+  return false;
+}
+
 bool RenderFrameHostDelegate::ShowPopupMenu(
     RenderFrameHostImpl* render_frame_host,
     mojo::PendingRemote<blink::mojom::PopupMenuClient>* popup_client,
diff --git a/content/browser/renderer_host/render_frame_host_delegate.h b/content/browser/renderer_host/render_frame_host_delegate.h
index 41efd11..11aec6f 100644
--- a/content/browser/renderer_host/render_frame_host_delegate.h
+++ b/content/browser/renderer_host/render_frame_host_delegate.h
@@ -564,6 +564,10 @@
   // decide if we should consume user activation when entering fullscreen.
   virtual bool HasSeenRecentScreenOrientationChange();
 
+  // Return true if the page has a transient affordance to enter fullscreen
+  // without consuming user activation.
+  virtual bool IsTransientAllowFullscreenActive() const;
+
   // The page is trying to open a new widget (e.g. a select popup). The
   // widget should be created associated with the given
   // |agent_scheduling_group|, but it should not be shown yet. That should
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 0c57f10..146a38a 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -4351,19 +4351,15 @@
     blink::mojom::FullscreenOptionsPtr options,
     EnterFullscreenCallback callback) {
   // Consume the user activation when entering fullscreen mode in the browser
-  // side when the renderer is compromised and the fullscreen is not triggered
-  // by a user generated orientation change, because the fullscreen can be
-  // triggered by either a user activation or a user generated orientation
-  // change.
-  // CanEnterFullscreenWithoutUserActivation is always false by default, so it
-  // keeps the current logic that we can enter fullscreen mode either by the
-  // orientation change or successfully consuming the user activation. This
-  // function is used for layout tests to allow fullscreen when mocking screen
-  // screen orientation changes.
+  // side when the renderer is compromised and the fullscreen request is denied.
+  // Fullscreen can only be triggered by: a user activation, a user-generated
+  // screen orientation change, or another feature-specific transient allowance.
+  // CanEnterFullscreenWithoutUserActivation is only ever true in tests, to
+  // allow fullscreen when mocking screen orientation changes.
   // TODO(lanwei): Investigate whether we can terminate the renderer when the
   // user activation has already been consumed.
   if (!delegate_->HasSeenRecentScreenOrientationChange() &&
-      !HasSeenRecentXrOverlaySetup() &&
+      !WindowPlacementAllowsFullscreen() && !HasSeenRecentXrOverlaySetup() &&
       !GetContentClient()
            ->browser()
            ->CanEnterFullscreenWithoutUserActivation()) {
@@ -7115,6 +7111,17 @@
     GrantFileAccessFromResourceRequestBody(*common_params.post_data);
 }
 
+bool RenderFrameHostImpl::WindowPlacementAllowsFullscreen() {
+  if (!delegate_->IsTransientAllowFullscreenActive())
+    return false;
+  auto* controller =
+      PermissionControllerImpl::FromBrowserContext(GetBrowserContext());
+  return controller &&
+         controller->GetPermissionStatusForFrame(
+             PermissionType::WINDOW_PLACEMENT, this, GetLastCommittedURL()) ==
+             blink::mojom::PermissionStatus::GRANTED;
+}
+
 mojo::AssociatedRemote<mojom::NavigationClient>
 RenderFrameHostImpl::GetNavigationClientFromInterfaceProvider() {
   mojo::AssociatedRemote<mojom::NavigationClient> navigation_client_remote;
diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h
index f1ac18ef..d365748 100644
--- a/content/browser/renderer_host/render_frame_host_impl.h
+++ b/content/browser/renderer_host/render_frame_host_impl.h
@@ -2126,6 +2126,10 @@
       const mojom::CommonNavigationParams& common_params,
       const mojom::CommitNavigationParams& commit_params);
 
+  // Returns true if there is an active transient fullscreen allowance for the
+  // Window Placement feature (i.e. on screen configuration changes).
+  bool WindowPlacementAllowsFullscreen();
+
   // Returns the latest NavigationRequest that has resulted in sending a Commit
   // IPC to the renderer process that hasn't yet been acked by the DidCommit IPC
   // from the renderer process.  Returns null if no such NavigationRequest
diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc
index c3a2e5c2..a24036b6 100644
--- a/content/browser/web_contents/web_contents_impl.cc
+++ b/content/browser/web_contents/web_contents_impl.cc
@@ -462,7 +462,7 @@
 }
 
 // Returns true if |host| has the Window Placement permission granted.
-bool WindowPlacementGranted(RenderFrameHost* host) {
+bool IsWindowPlacementGranted(RenderFrameHost* host) {
   auto* controller =
       PermissionControllerImpl::FromBrowserContext(host->GetBrowserContext());
   return controller && controller->GetPermissionStatusForFrame(
@@ -488,7 +488,7 @@
   // Check, but do not prompt, for permission to place windows on other screens.
   // Sites generally need permission to get such bounds in the first place.
   // Also clamp offscreen bounds to the window's current screen.
-  if (!bounds->Intersects(display.bounds()) || !WindowPlacementGranted(host))
+  if (!bounds->Intersects(display.bounds()) || !IsWindowPlacementGranted(host))
     display = screen->GetDisplayNearestView(host->GetNativeView());
 
   bounds->AdjustToFit(display.work_area());
@@ -1359,6 +1359,8 @@
 void WebContentsImpl::OnScreensChange(bool is_multi_screen_changed) {
   OPTIONAL_TRACE_EVENT1("content", "WebContentsImpl::OnScreensChange",
                         "is_multi_screen_changed", is_multi_screen_changed);
+  // Allow fullscreen requests shortly after user-generated screens changes.
+  transient_allow_fullscreen_.Activate();
   // Send |is_multi_screen_changed| events to all visible frames, but limit
   // other events to frames with the Window Placement permission. This obviates
   // the most pressing need for sites to poll isMultiScreen(), which is exposed
@@ -1368,7 +1370,7 @@
     RenderFrameHostImpl* rfh = node->current_frame_host();
     if ((is_multi_screen_changed &&
          rfh->GetVisibilityState() == PageVisibilityState::kVisible) ||
-        WindowPlacementGranted(rfh)) {
+        IsWindowPlacementGranted(rfh)) {
       rfh->GetAssociatedLocalFrame()->OnScreensChange();
     }
   }
@@ -8684,11 +8686,14 @@
       base::TimeDelta::FromSeconds(1);
   base::TimeDelta delta =
       ui::EventTimeForNow() - last_screen_orientation_change_time_;
-  // Return whether there is a screen orientation change happened in the recent
-  // 1 second.
+  // Return whether a screen orientation change happened in the last 1 second.
   return delta <= kMaxInterval;
 }
 
+bool WebContentsImpl::IsTransientAllowFullscreenActive() const {
+  return transient_allow_fullscreen_.IsActive();
+}
+
 void WebContentsImpl::DidChangeScreenOrientation() {
   last_screen_orientation_change_time_ = ui::EventTimeForNow();
 }
diff --git a/content/browser/web_contents/web_contents_impl.h b/content/browser/web_contents/web_contents_impl.h
index 4021af3..3cdec617 100644
--- a/content/browser/web_contents/web_contents_impl.h
+++ b/content/browser/web_contents/web_contents_impl.h
@@ -65,6 +65,7 @@
 #include "services/device/public/mojom/geolocation_context.mojom.h"
 #include "services/metrics/public/cpp/ukm_recorder.h"
 #include "services/network/public/mojom/fetch_api.mojom-forward.h"
+#include "third_party/blink/public/common/frame/transient_allow_fullscreen.h"
 #include "third_party/blink/public/common/page/drag_operation.h"
 #include "third_party/blink/public/common/web_preferences/web_preferences.h"
 #include "third_party/blink/public/mojom/choosers/color_chooser.mojom.h"
@@ -726,6 +727,7 @@
       RenderFrameHostImpl* source,
       blink::mojom::TextAutosizerPageInfoPtr page_info) override;
   bool HasSeenRecentScreenOrientationChange() override;
+  bool IsTransientAllowFullscreenActive() const override;
   void CreateNewWidget(AgentSchedulingGroupHost& agent_scheduling_group,
                        int32_t route_id,
                        mojo::PendingAssociatedReceiver<blink::mojom::WidgetHost>
@@ -2136,10 +2138,12 @@
   // Monitors system screen info changes to notify the renderer.
   std::unique_ptr<ScreenChangeMonitor> screen_change_monitor_;
 
-  // This time is used to record the last time we saw a screen orientation
-  // change.
+  // Records the last time we saw a screen orientation change.
   base::TimeTicks last_screen_orientation_change_time_;
 
+  // Manages a transient affordance for this page's frames to enter fullscreen.
+  blink::TransientAllowFullscreen transient_allow_fullscreen_;
+
   // Indicates how many sources are currently suppressing the unresponsive
   // renderer dialog.
   int suppress_unresponsive_renderer_count_ = 0;
diff --git a/third_party/blink/common/BUILD.gn b/third_party/blink/common/BUILD.gn
index df56c4f3..330261b 100644
--- a/third_party/blink/common/BUILD.gn
+++ b/third_party/blink/common/BUILD.gn
@@ -88,6 +88,7 @@
     "frame/frame_policy_mojom_traits.cc",
     "frame/frame_visual_properties.cc",
     "frame/from_ad_state.cc",
+    "frame/transient_allow_fullscreen.cc",
     "frame/user_activation_state.cc",
     "indexeddb/indexed_db_default_mojom_traits.cc",
     "indexeddb/indexeddb_key.cc",
@@ -237,6 +238,7 @@
     "feature_policy/document_policy_unittest.cc",
     "feature_policy/feature_policy_unittest.cc",
     "feature_policy/policy_value_unittest.cc",
+    "frame/transient_allow_fullscreen_unittest.cc",
     "frame/user_activation_state_unittest.cc",
     "indexeddb/indexeddb_key_unittest.cc",
     "input/synthetic_web_input_event_builders_unittest.cc",
diff --git a/third_party/blink/common/frame/transient_allow_fullscreen.cc b/third_party/blink/common/frame/transient_allow_fullscreen.cc
new file mode 100644
index 0000000..57f67d5
--- /dev/null
+++ b/third_party/blink/common/frame/transient_allow_fullscreen.cc
@@ -0,0 +1,22 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "third_party/blink/public/common/frame/transient_allow_fullscreen.h"
+
+namespace blink {
+
+// static
+constexpr base::TimeDelta TransientAllowFullscreen::kActivationLifespan;
+
+TransientAllowFullscreen::TransientAllowFullscreen() = default;
+
+void TransientAllowFullscreen::Activate() {
+  transient_state_expiry_time_ = base::TimeTicks::Now() + kActivationLifespan;
+}
+
+bool TransientAllowFullscreen::IsActive() const {
+  return base::TimeTicks::Now() <= transient_state_expiry_time_;
+}
+
+}  // namespace blink
diff --git a/third_party/blink/common/frame/transient_allow_fullscreen_unittest.cc b/third_party/blink/common/frame/transient_allow_fullscreen_unittest.cc
new file mode 100644
index 0000000..865601b
--- /dev/null
+++ b/third_party/blink/common/frame/transient_allow_fullscreen_unittest.cc
@@ -0,0 +1,45 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "third_party/blink/public/common/frame/transient_allow_fullscreen.h"
+
+#include "base/test/task_environment.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace blink {
+
+using TransientAllowFullscreenTest = testing::Test;
+
+// A test of basic functionality.
+TEST_F(TransientAllowFullscreenTest, Basic) {
+  base::test::TaskEnvironment task_environment(
+      base::test::TaskEnvironment::TimeSource::MOCK_TIME);
+
+  // By default, the object is not active.
+  TransientAllowFullscreen transient_allow_fullscreen;
+  EXPECT_FALSE(transient_allow_fullscreen.IsActive());
+
+  // Activation works as expected.
+  transient_allow_fullscreen.Activate();
+  EXPECT_TRUE(transient_allow_fullscreen.IsActive());
+
+  // Test the activation state immediately before expiration.
+  const base::TimeDelta kEpsilon = base::TimeDelta::FromMilliseconds(10);
+  task_environment.FastForwardBy(TransientAllowFullscreen::kActivationLifespan -
+                                 kEpsilon);
+  EXPECT_TRUE(transient_allow_fullscreen.IsActive());
+
+  // Test the activation state immediately after expiration.
+  task_environment.FastForwardBy(2 * kEpsilon);
+  EXPECT_FALSE(transient_allow_fullscreen.IsActive());
+
+  // Repeated activation works as expected.
+  transient_allow_fullscreen.Activate();
+  EXPECT_TRUE(transient_allow_fullscreen.IsActive());
+  task_environment.FastForwardBy(TransientAllowFullscreen::kActivationLifespan +
+                                 kEpsilon);
+  EXPECT_FALSE(transient_allow_fullscreen.IsActive());
+}
+
+}  // namespace blink
diff --git a/third_party/blink/common/frame/user_activation_state.cc b/third_party/blink/common/frame/user_activation_state.cc
index 17cadc9..4b9b9115 100644
--- a/third_party/blink/common/frame/user_activation_state.cc
+++ b/third_party/blink/common/frame/user_activation_state.cc
@@ -10,7 +10,7 @@
 
 // The expiry time should be long enough to allow network round trips even in a
 // very slow connection (to support xhr-like calls with user activation), yet
-// not too long to make an "unattneded" page feel activated.
+// not too long to make an "unattended" page feel activated.
 constexpr base::TimeDelta kActivationLifespan = base::TimeDelta::FromSeconds(5);
 
 UserActivationState::UserActivationState()
diff --git a/third_party/blink/public/common/BUILD.gn b/third_party/blink/public/common/BUILD.gn
index b1592364..6e7ef7b 100644
--- a/third_party/blink/public/common/BUILD.gn
+++ b/third_party/blink/public/common/BUILD.gn
@@ -90,6 +90,7 @@
     "frame/frame_policy.h",
     "frame/frame_visual_properties.h",
     "frame/from_ad_state.h",
+    "frame/transient_allow_fullscreen.h",
     "frame/user_activation_state.h",
     "frame/user_activation_update_source.h",
     "indexeddb/indexed_db_default_mojom_traits.h",
diff --git a/third_party/blink/public/common/frame/transient_allow_fullscreen.h b/third_party/blink/public/common/frame/transient_allow_fullscreen.h
new file mode 100644
index 0000000..7cb5dbe3
--- /dev/null
+++ b/third_party/blink/public/common/frame/transient_allow_fullscreen.h
@@ -0,0 +1,36 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef THIRD_PARTY_BLINK_PUBLIC_COMMON_FRAME_TRANSIENT_ALLOW_FULLSCREEN_H_
+#define THIRD_PARTY_BLINK_PUBLIC_COMMON_FRAME_TRANSIENT_ALLOW_FULLSCREEN_H_
+
+#include "base/time/time.h"
+#include "third_party/blink/public/common/common_export.h"
+
+namespace blink {
+
+// This class manages a transient affordance for a frame to enter fullscreen.
+// This is helpful for user-generated events that do not constitute activation,
+// but could still be used to grant an element fullscreen request.
+class BLINK_COMMON_EXPORT TransientAllowFullscreen {
+ public:
+  TransientAllowFullscreen();
+
+  // The lifespan should be just long enough to allow brief async script calls.
+  static constexpr base::TimeDelta kActivationLifespan =
+      base::TimeDelta::FromSeconds(1);
+
+  // Activate the transient state.
+  void Activate();
+
+  // Returns the transient state; |true| if this object was recently activated.
+  bool IsActive() const;
+
+ private:
+  base::TimeTicks transient_state_expiry_time_;
+};
+
+}  // namespace blink
+
+#endif  // THIRD_PARTY_BLINK_PUBLIC_COMMON_FRAME_TRANSIENT_ALLOW_FULLSCREEN_H_
diff --git a/third_party/blink/public/mojom/web_feature/web_feature.mojom b/third_party/blink/public/mojom/web_feature/web_feature.mojom
index ff2a338..4ecb64e9 100644
--- a/third_party/blink/public/mojom/web_feature/web_feature.mojom
+++ b/third_party/blink/public/mojom/web_feature/web_feature.mojom
@@ -3023,6 +3023,10 @@
   kAddressSpacePrivateEmbeddedInPublicNonSecureContext = 3695,
   kAddressSpacePrivateEmbeddedInUnknownSecureContext = 3696,
   kAddressSpacePrivateEmbeddedInUnknownNonSecureContext = 3697,
+  kThirdPartyAccess = 3698,
+  kThirdPartyActivation = 3699,
+  kThirdPartyAccessAndActivation = 3700,
+  kFullscreenAllowedByScreensChange = 3701,
 
   // Add new features immediately above this line. Don't change assigned
   // numbers of any item, and don't reuse removed slots.
diff --git a/third_party/blink/renderer/core/frame/local_frame.cc b/third_party/blink/renderer/core/frame/local_frame.cc
index c00d6f8..f532469a 100644
--- a/third_party/blink/renderer/core/frame/local_frame.cc
+++ b/third_party/blink/renderer/core/frame/local_frame.cc
@@ -119,6 +119,7 @@
 #include "third_party/blink/renderer/core/frame/visual_viewport.h"
 #include "third_party/blink/renderer/core/frame/web_frame_widget_base.h"
 #include "third_party/blink/renderer/core/frame/web_local_frame_impl.h"
+#include "third_party/blink/renderer/core/fullscreen/scoped_allow_fullscreen.h"
 #include "third_party/blink/renderer/core/html/html_frame_element_base.h"
 #include "third_party/blink/renderer/core/html/html_plugin_element.h"
 #include "third_party/blink/renderer/core/html/media/html_media_element.h"
@@ -717,6 +718,10 @@
   }
 }
 
+bool LocalFrame::IsTransientAllowFullscreenActive() const {
+  return transient_allow_fullscreen_.IsActive();
+}
+
 void LocalFrame::SetOptimizationGuideHints(
     mojom::blink::BlinkOptimizationGuideHintsPtr hints) {
   DCHECK(hints);
@@ -3040,6 +3045,8 @@
 
 void LocalFrame::OnScreensChange() {
   if (RuntimeEnabledFeatures::WindowPlacementEnabled(DomWindow())) {
+    // Allow fullscreen requests shortly after user-generated screens changes.
+    transient_allow_fullscreen_.Activate();
     DomWindow()->DispatchEvent(
         *Event::Create(event_type_names::kScreenschange));
   }
diff --git a/third_party/blink/renderer/core/frame/local_frame.h b/third_party/blink/renderer/core/frame/local_frame.h
index be44c9a..cebf2f1 100644
--- a/third_party/blink/renderer/core/frame/local_frame.h
+++ b/third_party/blink/renderer/core/frame/local_frame.h
@@ -38,6 +38,7 @@
 #include "mojo/public/cpp/bindings/pending_associated_receiver.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 #include "mojo/public/cpp/bindings/unique_receiver_set.h"
+#include "third_party/blink/public/common/frame/transient_allow_fullscreen.h"
 #include "third_party/blink/public/mojom/blob/blob_url_store.mojom-blink.h"
 #include "third_party/blink/public/mojom/frame/back_forward_cache_controller.mojom-blink.h"
 #include "third_party/blink/public/mojom/frame/frame.mojom-blink.h"
@@ -684,6 +685,9 @@
   // access).
   bool CanAccessEvent(const WebInputEventAttribution&) const;
 
+  // Return true if the frame has a transient affordance to enter fullscreen.
+  bool IsTransientAllowFullscreenActive() const;
+
   void SetOptimizationGuideHints(
       mojom::blink::BlinkOptimizationGuideHintsPtr hints);
   mojom::blink::BlinkOptimizationGuideHints* GetOptimizationGuideHints() {
@@ -919,6 +923,9 @@
   mojom::blink::BlinkOptimizationGuideHintsPtr optimization_guide_hints_;
 
   Member<TextFragmentSelectorGenerator> text_fragment_selector_generator_;
+
+  // Manages a transient affordance for this frame to enter fullscreen.
+  TransientAllowFullscreen transient_allow_fullscreen_;
 };
 
 inline FrameLoader& LocalFrame::Loader() const {
diff --git a/third_party/blink/renderer/core/fullscreen/fullscreen.cc b/third_party/blink/renderer/core/fullscreen/fullscreen.cc
index 8519f9a..9f57c5d 100644
--- a/third_party/blink/renderer/core/fullscreen/fullscreen.cc
+++ b/third_party/blink/renderer/core/fullscreen/fullscreen.cc
@@ -264,7 +264,7 @@
   if (LocalFrame::HasTransientUserActivation(document.GetFrame()))
     return true;
 
-  //  The algorithm is triggered by a user generated orientation change.
+  // The algorithm is triggered by a user-generated orientation change.
   if (ScopedAllowFullscreen::FullscreenAllowedReason() ==
       ScopedAllowFullscreen::kOrientationChange) {
     UseCounter::Count(document,
@@ -272,6 +272,13 @@
     return true;
   }
 
+  // The algorithm is triggered by another event with transient affordances,
+  // e.g. permission-gated events for user-generated screens changes.
+  if (document.GetFrame()->IsTransientAllowFullscreenActive()) {
+    UseCounter::Count(document, WebFeature::kFullscreenAllowedByScreensChange);
+    return true;
+  }
+
   String message = ExceptionMessages::FailedToExecute(
       "requestFullscreen", "Element",
       "API can only be initiated by a user gesture.");
diff --git a/tools/metrics/histograms/enums.xml b/tools/metrics/histograms/enums.xml
index a9cfaf69..29fddee1 100644
--- a/tools/metrics/histograms/enums.xml
+++ b/tools/metrics/histograms/enums.xml
@@ -29547,6 +29547,10 @@
   <int value="3696" label="AddressSpacePrivateEmbeddedInUnknownSecureContext"/>
   <int value="3697"
       label="AddressSpacePrivateEmbeddedInUnknownNonSecureContext"/>
+  <int value="3698" label="ThirdPartyAccess"/>
+  <int value="3699" label="ThirdPartyActivation"/>
+  <int value="3700" label="ThirdPartyAccessAndActivation"/>
+  <int value="3701" label="FullscreenAllowedByScreensChange"/>
 </enum>
 
 <enum name="FeaturePolicyAllowlistType">