idle-detection: Block permission requests from incognito profiles

This change implements automatic blocking of requests for the
"idle-detection" permission from incognito profiles in order to prevent
the feature from being used to correlate incognito and non-incognito
sessions.

As for requests for the "notifications" permission rather than simply
automatically denying the request (which would be an incognito oracle)
this patch waits a random amount of time before triggering the denial.
The code from the NotificationsPermissionContext is cleaned up and
reused for this.

Bug: 878979
Change-Id: I57c57f39457932f570d7094882dc00d9c0eafa16
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2360943
Commit-Queue: Reilly Grant <[email protected]>
Reviewed-by: Scott Violet <[email protected]>
Reviewed-by: Balazs Engedy <[email protected]>
Cr-Commit-Position: refs/heads/master@{#807660}
diff --git a/chrome/browser/BUILD.gn b/chrome/browser/BUILD.gn
index 9bec634..74c3e26 100644
--- a/chrome/browser/BUILD.gn
+++ b/chrome/browser/BUILD.gn
@@ -1806,6 +1806,8 @@
     "usb/web_usb_service_impl.h",
     "video_tutorials/video_tutorial_service_factory.cc",
     "video_tutorials/video_tutorial_service_factory.h",
+    "visibility_timer_tab_helper.cc",
+    "visibility_timer_tab_helper.h",
     "vr/ui_suppressed_element.h",
     "vr/vr_tab_helper.cc",
     "vr/vr_tab_helper.h",
diff --git a/chrome/browser/idle/idle_detection_permission_context.cc b/chrome/browser/idle/idle_detection_permission_context.cc
index 1042b71..258b92fe 100644
--- a/chrome/browser/idle/idle_detection_permission_context.cc
+++ b/chrome/browser/idle/idle_detection_permission_context.cc
@@ -4,8 +4,13 @@
 
 #include "chrome/browser/idle/idle_detection_permission_context.h"
 
+#include "base/bind.h"
+#include "base/location.h"
+#include "base/rand_util.h"
+#include "chrome/browser/visibility_timer_tab_helper.h"
 #include "components/content_settings/browser/page_specific_content_settings.h"
 #include "components/permissions/permission_request_id.h"
+#include "content/public/browser/browser_context.h"
 #include "url/gurl.h"
 
 IdleDetectionPermissionContext::IdleDetectionPermissionContext(
@@ -35,3 +40,37 @@
 bool IdleDetectionPermissionContext::IsRestrictedToSecureOrigins() const {
   return true;
 }
+
+void IdleDetectionPermissionContext::DecidePermission(
+    content::WebContents* web_contents,
+    const permissions::PermissionRequestID& id,
+    const GURL& requesting_origin,
+    const GURL& embedding_origin,
+    bool user_gesture,
+    permissions::BrowserPermissionCallback callback) {
+  // Idle detection permission is always denied in incognito. To prevent sites
+  // from using that to detect whether incognito mode is active, we deny after a
+  // random time delay, to simulate a user clicking a bubble/infobar. See also
+  // ContentSettingsRegistry::Init, which marks idle detection as
+  // INHERIT_IF_LESS_PERMISSIVE, and
+  // PermissionMenuModel::PermissionMenuModel which prevents users from manually
+  // allowing the permission.
+  if (browser_context()->IsOffTheRecord()) {
+    // Random number of seconds in the range [1.0, 2.0).
+    double delay_seconds = 1.0 + 1.0 * base::RandDouble();
+    VisibilityTimerTabHelper::CreateForWebContents(web_contents);
+    VisibilityTimerTabHelper::FromWebContents(web_contents)
+        ->PostTaskAfterVisibleDelay(
+            FROM_HERE,
+            base::BindOnce(&IdleDetectionPermissionContext::NotifyPermissionSet,
+                           weak_factory_.GetWeakPtr(), id, requesting_origin,
+                           embedding_origin, std::move(callback),
+                           /*persist=*/true, CONTENT_SETTING_BLOCK),
+            base::TimeDelta::FromSecondsD(delay_seconds));
+    return;
+  }
+
+  PermissionContextBase::DecidePermission(web_contents, id, requesting_origin,
+                                          embedding_origin, user_gesture,
+                                          std::move(callback));
+}
diff --git a/chrome/browser/idle/idle_detection_permission_context.h b/chrome/browser/idle/idle_detection_permission_context.h
index 1265309e..a0c0ac5c 100644
--- a/chrome/browser/idle/idle_detection_permission_context.h
+++ b/chrome/browser/idle/idle_detection_permission_context.h
@@ -6,6 +6,7 @@
 #define CHROME_BROWSER_IDLE_IDLE_DETECTION_PERMISSION_CONTEXT_H_
 
 #include "base/macros.h"
+#include "base/memory/weak_ptr.h"
 #include "components/permissions/permission_context_base.h"
 
 class IdleDetectionPermissionContext
@@ -21,6 +22,15 @@
                         const GURL& requesting_frame,
                         bool allowed) override;
   bool IsRestrictedToSecureOrigins() const override;
+  void DecidePermission(
+      content::WebContents* web_contents,
+      const permissions::PermissionRequestID& id,
+      const GURL& requesting_origin,
+      const GURL& embedding_origin,
+      bool user_gesture,
+      permissions::BrowserPermissionCallback callback) override;
+
+  base::WeakPtrFactory<IdleDetectionPermissionContext> weak_factory_{this};
 
   DISALLOW_COPY_AND_ASSIGN(IdleDetectionPermissionContext);
 };
diff --git a/chrome/browser/idle/idle_detection_permission_context_unittest.cc b/chrome/browser/idle/idle_detection_permission_context_unittest.cc
new file mode 100644
index 0000000..05ecde76
--- /dev/null
+++ b/chrome/browser/idle/idle_detection_permission_context_unittest.cc
@@ -0,0 +1,190 @@
+// 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 "chrome/browser/idle/idle_detection_permission_context.h"
+
+#include "base/test/task_environment.h"
+#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
+#include "chrome/test/base/chrome_render_view_host_test_harness.h"
+#include "components/content_settings/core/browser/host_content_settings_map.h"
+#include "components/permissions/permission_request_id.h"
+#include "content/public/browser/render_process_host.h"
+#include "content/public/browser/web_contents.h"
+
+namespace {
+
+class TestIdleDetectionPermissionContext
+    : public IdleDetectionPermissionContext {
+ public:
+  explicit TestIdleDetectionPermissionContext(Profile* profile)
+      : IdleDetectionPermissionContext(profile),
+        permission_set_count_(0),
+        last_permission_set_persisted_(false),
+        last_permission_set_setting_(CONTENT_SETTING_DEFAULT) {}
+
+  int permission_set_count() const { return permission_set_count_; }
+  bool last_permission_set_persisted() const {
+    return last_permission_set_persisted_;
+  }
+  ContentSetting last_permission_set_setting() const {
+    return last_permission_set_setting_;
+  }
+
+  ContentSetting GetContentSettingFromMap(const GURL& url_a,
+                                          const GURL& url_b) {
+    return HostContentSettingsMapFactory::GetForProfile(browser_context())
+        ->GetContentSetting(url_a.GetOrigin(), url_b.GetOrigin(),
+                            content_settings_type(), std::string());
+  }
+
+ private:
+  // IdleDetectionPermissionContext:
+  void NotifyPermissionSet(const permissions::PermissionRequestID& id,
+                           const GURL& requesting_origin,
+                           const GURL& embedder_origin,
+                           permissions::BrowserPermissionCallback callback,
+                           bool persist,
+                           ContentSetting content_setting) override {
+    permission_set_count_++;
+    last_permission_set_persisted_ = persist;
+    last_permission_set_setting_ = content_setting;
+    IdleDetectionPermissionContext::NotifyPermissionSet(
+        id, requesting_origin, embedder_origin, std::move(callback), persist,
+        content_setting);
+  }
+
+  int permission_set_count_;
+  bool last_permission_set_persisted_;
+  ContentSetting last_permission_set_setting_;
+};
+
+}  // namespace
+
+class IdleDetectionPermissionContextTest
+    : public ChromeRenderViewHostTestHarness {
+ public:
+  IdleDetectionPermissionContextTest()
+      : ChromeRenderViewHostTestHarness(
+            base::test::TaskEnvironment::TimeSource::MOCK_TIME) {}
+};
+
+// Tests auto-denial after a time delay in incognito.
+TEST_F(IdleDetectionPermissionContextTest, TestDenyInIncognitoAfterDelay) {
+  TestIdleDetectionPermissionContext permission_context(
+      profile()->GetPrimaryOTRProfile());
+  GURL url("https://www.example.com");
+  NavigateAndCommit(url);
+
+  const permissions::PermissionRequestID id(
+      web_contents()->GetMainFrame()->GetProcess()->GetID(),
+      web_contents()->GetMainFrame()->GetRoutingID(), -1);
+
+  ASSERT_EQ(0, permission_context.permission_set_count());
+  ASSERT_FALSE(permission_context.last_permission_set_persisted());
+  ASSERT_EQ(CONTENT_SETTING_DEFAULT,
+            permission_context.last_permission_set_setting());
+
+  permission_context.RequestPermission(
+      web_contents(), id, url, true /* user_gesture */, base::DoNothing());
+
+  // Should be blocked after 1-2 seconds, but the timer is reset whenever the
+  // tab is not visible, so these 500ms never add up to >= 1 second.
+  for (int n = 0; n < 10; n++) {
+    web_contents()->WasShown();
+    task_environment()->FastForwardBy(base::TimeDelta::FromMilliseconds(500));
+    web_contents()->WasHidden();
+  }
+
+  EXPECT_EQ(0, permission_context.permission_set_count());
+  EXPECT_EQ(CONTENT_SETTING_ASK,
+            permission_context.GetContentSettingFromMap(url, url));
+
+  // Time elapsed whilst hidden is not counted.
+  // n.b. This line also clears out any old scheduled timer tasks. This is
+  // important, because otherwise Timer::Reset (triggered by
+  // VisibilityTimerTabHelper::WasShown) may choose to re-use an existing
+  // scheduled task, and when it fires Timer::RunScheduledTask will call
+  // TimeTicks::Now() (which unlike task_environment()->NowTicks(), we can't
+  // fake), and miscalculate the remaining delay at which to fire the timer.
+  task_environment()->FastForwardBy(base::TimeDelta::FromDays(1));
+
+  EXPECT_EQ(0, permission_context.permission_set_count());
+  EXPECT_EQ(CONTENT_SETTING_ASK,
+            permission_context.GetContentSettingFromMap(url, url));
+
+  // Should be blocked after 1-2 seconds. So 500ms is not enough.
+  web_contents()->WasShown();
+  task_environment()->FastForwardBy(base::TimeDelta::FromMilliseconds(500));
+
+  EXPECT_EQ(0, permission_context.permission_set_count());
+  EXPECT_EQ(CONTENT_SETTING_ASK,
+            permission_context.GetContentSettingFromMap(url, url));
+
+  // But 5*500ms > 2 seconds, so it should now be blocked.
+  for (int n = 0; n < 4; n++)
+    task_environment()->FastForwardBy(base::TimeDelta::FromMilliseconds(500));
+
+  EXPECT_EQ(1, permission_context.permission_set_count());
+  EXPECT_TRUE(permission_context.last_permission_set_persisted());
+  EXPECT_EQ(CONTENT_SETTING_BLOCK,
+            permission_context.last_permission_set_setting());
+  EXPECT_EQ(CONTENT_SETTING_BLOCK,
+            permission_context.GetContentSettingFromMap(url, url));
+}
+
+// Tests how multiple parallel permission requests get auto-denied in incognito.
+TEST_F(IdleDetectionPermissionContextTest, TestParallelDenyInIncognito) {
+  TestIdleDetectionPermissionContext permission_context(
+      profile()->GetPrimaryOTRProfile());
+  GURL url("https://www.example.com");
+  NavigateAndCommit(url);
+  web_contents()->WasShown();
+
+  const permissions::PermissionRequestID id0(
+      web_contents()->GetMainFrame()->GetProcess()->GetID(),
+      web_contents()->GetMainFrame()->GetRoutingID(), 0);
+  const permissions::PermissionRequestID id1(
+      web_contents()->GetMainFrame()->GetProcess()->GetID(),
+      web_contents()->GetMainFrame()->GetRoutingID(), 1);
+
+  ASSERT_EQ(0, permission_context.permission_set_count());
+  ASSERT_FALSE(permission_context.last_permission_set_persisted());
+  ASSERT_EQ(CONTENT_SETTING_DEFAULT,
+            permission_context.last_permission_set_setting());
+
+  permission_context.RequestPermission(
+      web_contents(), id0, url, /*user_gesture=*/true, base::DoNothing());
+  permission_context.RequestPermission(
+      web_contents(), id1, url, /*user_gesture=*/true, base::DoNothing());
+
+  EXPECT_EQ(0, permission_context.permission_set_count());
+  EXPECT_EQ(CONTENT_SETTING_ASK,
+            permission_context.GetContentSettingFromMap(url, url));
+
+  // Fast forward up to 2.5 seconds. Stop as soon as the first permission
+  // request is auto-denied.
+  for (int n = 0; n < 5; n++) {
+    task_environment()->FastForwardBy(base::TimeDelta::FromMilliseconds(500));
+    if (permission_context.permission_set_count())
+      break;
+  }
+
+  // Only the first permission request receives a response (crbug.com/577336).
+  EXPECT_EQ(1, permission_context.permission_set_count());
+  EXPECT_TRUE(permission_context.last_permission_set_persisted());
+  EXPECT_EQ(CONTENT_SETTING_BLOCK,
+            permission_context.last_permission_set_setting());
+  EXPECT_EQ(CONTENT_SETTING_BLOCK,
+            permission_context.GetContentSettingFromMap(url, url));
+
+  // After another 2.5 seconds, the second permission request should also have
+  // received a response.
+  task_environment()->FastForwardBy(base::TimeDelta::FromMilliseconds(2500));
+  EXPECT_EQ(2, permission_context.permission_set_count());
+  EXPECT_TRUE(permission_context.last_permission_set_persisted());
+  EXPECT_EQ(CONTENT_SETTING_BLOCK,
+            permission_context.last_permission_set_setting());
+  EXPECT_EQ(CONTENT_SETTING_BLOCK,
+            permission_context.GetContentSettingFromMap(url, url));
+}
diff --git a/chrome/browser/notifications/notification_permission_context.cc b/chrome/browser/notifications/notification_permission_context.cc
index 9f0c710d..642d937 100644
--- a/chrome/browser/notifications/notification_permission_context.cc
+++ b/chrome/browser/notifications/notification_permission_context.cc
@@ -7,24 +7,19 @@
 #include "base/bind.h"
 #include "base/callback.h"
 #include "base/callback_helpers.h"
-#include "base/containers/circular_deque.h"
 #include "base/location.h"
 #include "base/rand_util.h"
 #include "base/single_thread_task_runner.h"
 #include "base/stl_util.h"
 #include "base/threading/thread_task_runner_handle.h"
-#include "base/timer/timer.h"
 #include "chrome/browser/content_settings/host_content_settings_map_factory.h"
 #include "chrome/browser/profiles/profile.h"
+#include "chrome/browser/visibility_timer_tab_helper.h"
 #include "components/content_settings/core/browser/host_content_settings_map.h"
 #include "components/content_settings/core/common/content_settings_pattern.h"
 #include "components/content_settings/core/common/content_settings_types.h"
 #include "components/permissions/permission_request_id.h"
 #include "content/public/browser/browser_thread.h"
-#include "content/public/browser/render_frame_host.h"
-#include "content/public/browser/web_contents_observer.h"
-#include "content/public/browser/web_contents_user_data.h"
-#include "content/public/common/page_visibility_state.h"
 #include "url/gurl.h"
 
 #if BUILDFLAG(ENABLE_EXTENSIONS)
@@ -38,146 +33,6 @@
 #include "ui/message_center/public/cpp/notifier_id.h"
 #endif  // BUILDFLAG(ENABLE_EXTENSIONS)
 
-namespace {
-
-// At most one of these is attached to each WebContents. It allows posting
-// delayed tasks whose timer only counts down whilst the WebContents is visible
-// (and whose timer is reset whenever the WebContents stops being visible).
-class VisibilityTimerTabHelper
-    : public content::WebContentsObserver,
-      public content::WebContentsUserData<VisibilityTimerTabHelper> {
- public:
-  VisibilityTimerTabHelper(const VisibilityTimerTabHelper&) = delete;
-  VisibilityTimerTabHelper& operator=(const VisibilityTimerTabHelper&) = delete;
-  ~VisibilityTimerTabHelper() override {}
-
-  // Runs |task| after the WebContents has been visible for a consecutive
-  // duration of at least |visible_delay|.
-  void PostTaskAfterVisibleDelay(const base::Location& from_here,
-                                 base::OnceClosure task,
-                                 base::TimeDelta visible_delay,
-                                 const permissions::PermissionRequestID& id);
-
-  // Deletes any earlier task(s) that match |id|.
-  void CancelTask(const permissions::PermissionRequestID& id);
-
-  // WebContentsObserver:
-  void OnVisibilityChanged(content::Visibility visibility) override;
-  void WebContentsDestroyed() override;
-
- private:
-  friend class content::WebContentsUserData<VisibilityTimerTabHelper>;
-  explicit VisibilityTimerTabHelper(content::WebContents* contents);
-
-  void RunTask(base::OnceClosure task);
-
-  bool is_visible_;
-
-  struct Task {
-    Task(const permissions::PermissionRequestID& id,
-         std::unique_ptr<base::RetainingOneShotTimer> timer)
-        : id(id), timer(std::move(timer)) {}
-
-    // Move-only.
-    Task(Task&&) noexcept = default;
-    Task(const Task&) = delete;
-
-    Task& operator=(Task&& other) {
-      id = other.id;
-      timer = std::move(other.timer);
-      return *this;
-    }
-
-    permissions::PermissionRequestID id;
-    std::unique_ptr<base::RetainingOneShotTimer> timer;
-  };
-  base::circular_deque<Task> task_queue_;
-
-  WEB_CONTENTS_USER_DATA_KEY_DECL();
-};
-
-WEB_CONTENTS_USER_DATA_KEY_IMPL(VisibilityTimerTabHelper)
-
-VisibilityTimerTabHelper::VisibilityTimerTabHelper(
-    content::WebContents* contents)
-    : content::WebContentsObserver(contents) {
-  if (!contents->GetMainFrame()) {
-    is_visible_ = false;
-  } else {
-    switch (contents->GetMainFrame()->GetVisibilityState()) {
-      case content::PageVisibilityState::kHidden:
-      case content::PageVisibilityState::kHiddenButPainting:
-        is_visible_ = false;
-        break;
-      case content::PageVisibilityState::kVisible:
-        is_visible_ = true;
-        break;
-    }
-  }
-}
-
-void VisibilityTimerTabHelper::PostTaskAfterVisibleDelay(
-    const base::Location& from_here,
-    base::OnceClosure task,
-    base::TimeDelta visible_delay,
-    const permissions::PermissionRequestID& id) {
-  if (web_contents()->IsBeingDestroyed())
-    return;
-
-  // Safe to use Unretained, as destroying |this| will destroy task_queue_,
-  // hence cancelling all timers.
-  // RetainingOneShotTimer is used which needs a RepeatingCallback, but we
-  // only have it run this callback a single time, and destroy it after.
-  auto timer = std::make_unique<base::RetainingOneShotTimer>(
-      from_here, visible_delay,
-      base::AdaptCallbackForRepeating(
-          base::BindOnce(&VisibilityTimerTabHelper::RunTask,
-                         base::Unretained(this), std::move(task))));
-  DCHECK(!timer->IsRunning());
-
-  task_queue_.emplace_back(id, std::move(timer));
-
-  if (is_visible_ && task_queue_.size() == 1)
-    task_queue_.front().timer->Reset();
-}
-
-void VisibilityTimerTabHelper::CancelTask(
-    const permissions::PermissionRequestID& id) {
-  bool deleting_front = task_queue_.front().id == id;
-
-  base::EraseIf(task_queue_, [id](const Task& task) { return task.id == id; });
-
-  if (!task_queue_.empty() && is_visible_ && deleting_front)
-    task_queue_.front().timer->Reset();
-}
-
-void VisibilityTimerTabHelper::OnVisibilityChanged(
-    content::Visibility visibility) {
-  if (visibility == content::Visibility::VISIBLE) {
-    if (!is_visible_ && !task_queue_.empty())
-      task_queue_.front().timer->Reset();
-    is_visible_ = true;
-  } else {
-    if (is_visible_ && !task_queue_.empty())
-      task_queue_.front().timer->Stop();
-    is_visible_ = false;
-  }
-}
-
-void VisibilityTimerTabHelper::WebContentsDestroyed() {
-  task_queue_.clear();
-}
-
-void VisibilityTimerTabHelper::RunTask(base::OnceClosure task) {
-  DCHECK(is_visible_);
-  std::move(task).Run();
-  task_queue_.pop_front();
-  if (!task_queue_.empty())
-    task_queue_.front().timer->Reset();
-}
-
-}  // namespace
-
 // static
 void NotificationPermissionContext::UpdatePermission(
     content::BrowserContext* browser_context,
@@ -300,7 +155,7 @@
                            requesting_origin, embedding_origin,
                            std::move(callback), true /* persist */,
                            CONTENT_SETTING_BLOCK),
-            base::TimeDelta::FromSecondsD(delay_seconds), id);
+            base::TimeDelta::FromSecondsD(delay_seconds));
     return;
   }
 
diff --git a/chrome/browser/ui/page_info/permission_menu_model.cc b/chrome/browser/ui/page_info/permission_menu_model.cc
index b95c7fe..53528b2 100644
--- a/chrome/browser/ui/page_info/permission_menu_model.cc
+++ b/chrome/browser/ui/page_info/permission_menu_model.cc
@@ -79,8 +79,10 @@
 
 bool PermissionMenuModel::ShouldShowAllow(const GURL& url) {
   switch (permission_.type) {
-    // Notifications does not support CONTENT_SETTING_ALLOW in incognito.
+    // Notifications and idle detection do not support CONTENT_SETTING_ALLOW in
+    // incognito.
     case ContentSettingsType::NOTIFICATIONS:
+    case ContentSettingsType::IDLE_DETECTION:
       return !permission_.is_incognito;
     // Media only supports CONTENT_SETTING_ALLOW for secure origins.
     case ContentSettingsType::MEDIASTREAM_MIC:
diff --git a/chrome/browser/visibility_timer_tab_helper.cc b/chrome/browser/visibility_timer_tab_helper.cc
new file mode 100644
index 0000000..fe9be91
--- /dev/null
+++ b/chrome/browser/visibility_timer_tab_helper.cc
@@ -0,0 +1,65 @@
+// 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 "chrome/browser/visibility_timer_tab_helper.h"
+
+#include <utility>
+
+#include "base/callback_helpers.h"
+#include "base/logging.h"
+#include "base/timer/timer.h"
+#include "content/public/browser/visibility.h"
+#include "content/public/browser/web_contents.h"
+
+WEB_CONTENTS_USER_DATA_KEY_IMPL(VisibilityTimerTabHelper)
+
+VisibilityTimerTabHelper::~VisibilityTimerTabHelper() = default;
+
+void VisibilityTimerTabHelper::PostTaskAfterVisibleDelay(
+    const base::Location& from_here,
+    base::OnceClosure task,
+    base::TimeDelta visible_delay) {
+  if (web_contents()->IsBeingDestroyed())
+    return;
+
+  // Safe to use Unretained, as destroying |this| will destroy task_queue_,
+  // hence cancelling all timers.
+  // RetainingOneShotTimer is used which needs a RepeatingCallback, but we
+  // only have it run this callback a single time, and destroy it after.
+  task_queue_.push_back(std::make_unique<base::RetainingOneShotTimer>(
+      from_here, visible_delay,
+      base::AdaptCallbackForRepeating(
+          base::BindOnce(&VisibilityTimerTabHelper::RunTask,
+                         base::Unretained(this), std::move(task)))));
+  DCHECK(!task_queue_.back()->IsRunning());
+
+  if (web_contents()->GetVisibility() == content::Visibility::VISIBLE &&
+      task_queue_.size() == 1) {
+    task_queue_.front()->Reset();
+  }
+}
+
+void VisibilityTimerTabHelper::OnVisibilityChanged(
+    content::Visibility visibility) {
+  if (!task_queue_.empty()) {
+    if (visibility == content::Visibility::VISIBLE)
+      task_queue_.front()->Reset();
+    else
+      task_queue_.front()->Stop();
+  }
+}
+
+VisibilityTimerTabHelper::VisibilityTimerTabHelper(
+    content::WebContents* contents)
+    : content::WebContentsObserver(contents) {}
+
+void VisibilityTimerTabHelper::RunTask(base::OnceClosure task) {
+  DCHECK_EQ(web_contents()->GetVisibility(), content::Visibility::VISIBLE);
+
+  task_queue_.pop_front();
+  if (!task_queue_.empty())
+    task_queue_.front()->Reset();
+
+  std::move(task).Run();
+}
diff --git a/chrome/browser/visibility_timer_tab_helper.h b/chrome/browser/visibility_timer_tab_helper.h
new file mode 100644
index 0000000..28ffbae
--- /dev/null
+++ b/chrome/browser/visibility_timer_tab_helper.h
@@ -0,0 +1,52 @@
+// 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 CHROME_BROWSER_VISIBILITY_TIMER_TAB_HELPER_H_
+#define CHROME_BROWSER_VISIBILITY_TIMER_TAB_HELPER_H_
+
+#include <memory>
+
+#include "base/containers/circular_deque.h"
+#include "content/public/browser/web_contents_observer.h"
+#include "content/public/browser/web_contents_user_data.h"
+
+namespace base {
+class RetainingOneShotTimer;
+}
+
+// At most one of these is attached to each WebContents. It allows posting
+// delayed tasks whose timer only counts down whilst the WebContents is visible
+// (and whose timer is reset whenever the WebContents stops being visible).
+class VisibilityTimerTabHelper
+    : public content::WebContentsObserver,
+      public content::WebContentsUserData<VisibilityTimerTabHelper> {
+ public:
+  VisibilityTimerTabHelper(const VisibilityTimerTabHelper&&) = delete;
+  VisibilityTimerTabHelper& operator=(const VisibilityTimerTabHelper&&) =
+      delete;
+
+  ~VisibilityTimerTabHelper() override;
+
+  // Runs |task| after the WebContents has been visible for a consecutive
+  // duration of at least |visible_delay|.
+  void PostTaskAfterVisibleDelay(const base::Location& from_here,
+                                 base::OnceClosure task,
+                                 base::TimeDelta visible_delay);
+
+  // WebContentsObserver:
+  void OnVisibilityChanged(content::Visibility visibility) override;
+
+ private:
+  friend class content::WebContentsUserData<VisibilityTimerTabHelper>;
+  explicit VisibilityTimerTabHelper(content::WebContents* contents);
+
+  void RunTask(base::OnceClosure task);
+
+  base::circular_deque<std::unique_ptr<base::RetainingOneShotTimer>>
+      task_queue_;
+
+  WEB_CONTENTS_USER_DATA_KEY_DECL();
+};
+
+#endif  // CHROME_BROWSER_VISIBILITY_TIMER_TAB_HELPER_H_
diff --git a/chrome/browser/visibility_timer_tab_helper_unittest.cc b/chrome/browser/visibility_timer_tab_helper_unittest.cc
new file mode 100644
index 0000000..ba5aecb
--- /dev/null
+++ b/chrome/browser/visibility_timer_tab_helper_unittest.cc
@@ -0,0 +1,64 @@
+// 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 "chrome/browser/visibility_timer_tab_helper.h"
+
+#include "base/test/bind_test_util.h"
+#include "base/test/task_environment.h"
+#include "chrome/test/base/chrome_render_view_host_test_harness.h"
+#include "content/public/browser/web_contents.h"
+
+class VisibilityTimerTabHelperTest : public ChromeRenderViewHostTestHarness {
+ public:
+  VisibilityTimerTabHelperTest()
+      : ChromeRenderViewHostTestHarness(
+            base::test::TaskEnvironment::TimeSource::MOCK_TIME) {}
+};
+
+TEST_F(VisibilityTimerTabHelperTest, Delay) {
+  bool task_executed = false;
+  VisibilityTimerTabHelper::CreateForWebContents(web_contents());
+  VisibilityTimerTabHelper::FromWebContents(web_contents())
+      ->PostTaskAfterVisibleDelay(FROM_HERE,
+                                  base::BindLambdaForTesting([&task_executed] {
+                                    EXPECT_FALSE(task_executed);
+                                    task_executed = true;
+                                  }),
+                                  base::TimeDelta::FromSecondsD(1));
+
+  EXPECT_FALSE(task_executed);
+
+  // The task will be executed after 1 second, but the timer is reset whenever
+  // the tab is not visible, so these 500ms never add up to >= 1 second.
+  for (int n = 0; n < 10; n++) {
+    web_contents()->WasShown();
+    task_environment()->FastForwardBy(base::TimeDelta::FromMilliseconds(500));
+    web_contents()->WasHidden();
+  }
+
+  EXPECT_FALSE(task_executed);
+
+  // Time elapsed whilst hidden is not counted.
+  // n.b. This line also clears out any old scheduled timer tasks. This is
+  // important, because otherwise Timer::Reset (triggered by
+  // VisibilityTimerTabHelper::WasShown) may choose to re-use an existing
+  // scheduled task, and when it fires Timer::RunScheduledTask will call
+  // TimeTicks::Now() (which unlike task_environment()->NowTicks(), we can't
+  // fake), and miscalculate the remaining delay at which to fire the timer.
+  task_environment()->FastForwardBy(base::TimeDelta::FromDays(1));
+
+  EXPECT_FALSE(task_executed);
+
+  // So 500ms is still not enough.
+  web_contents()->WasShown();
+  task_environment()->FastForwardBy(base::TimeDelta::FromMilliseconds(500));
+
+  EXPECT_FALSE(task_executed);
+
+  // But 5*500ms > 1 second, so it should now be blocked.
+  for (int n = 0; n < 4; n++)
+    task_environment()->FastForwardBy(base::TimeDelta::FromMilliseconds(500));
+
+  EXPECT_TRUE(task_executed);
+}
diff --git a/chrome/test/BUILD.gn b/chrome/test/BUILD.gn
index f497bedc..8588170 100644
--- a/chrome/test/BUILD.gn
+++ b/chrome/test/BUILD.gn
@@ -3376,6 +3376,7 @@
     "../browser/history/android/visit_sql_handler_unittest.cc",
     "../browser/history/domain_diversity_reporter_unittest.cc",
     "../browser/history/history_tab_helper_unittest.cc",
+    "../browser/idle/idle_detection_permission_context_unittest.cc",
     "../browser/infobars/mock_infobar_service.cc",
     "../browser/infobars/mock_infobar_service.h",
     "../browser/install_verification/win/module_info_unittest.cc",
@@ -3715,6 +3716,7 @@
     "../browser/ui/webui/local_state/local_state_ui_unittest.cc",
     "../browser/ui/webui/log_web_ui_url_unittest.cc",
     "../browser/update_client/chrome_update_query_params_delegate_unittest.cc",
+    "../browser/visibility_timer_tab_helper_unittest.cc",
     "../browser/vr/vr_tab_helper_unittest.cc",
     "../browser/wake_lock/wake_lock_permission_context_unittest.cc",
     "../browser/win/chrome_elf_init_unittest.cc",