[User Education] Add Per-App and Critical Notice IPH Modes

This CL adds a "promo subtype" to IPH which allows two new modes which can be applied to most IPH types:
 - Per-App: This IPH can show once per App or PWA, and will not be
   shown for that app once it is completed or dismissed.
 - Legal Notice: This IPH can show any number of times until it is
   dismissed; furthermore it takes precedence over any
   non-legal-notice IPH (but not the legacy critical promos, or other
   legal messaging). It does not show again once dismissed.

Certain types of IPH are not supported in these modes; specifically:
 - Legacy (because nobody should be creating new legacy promos)
 - Snooze (because the behavior is incompatible); note that other
   IPH which would be snoozable now only have the "show me how" and
   "got it" options.

In addition to adding these modes and a large number of tests to ensure they work correctly, this CL also adds auto-configuration and configuration consistency checks for these new promo subtypes.

These promos are still constrained by their Feature Engagement (FE) configuration, but the allowed FE configurations are much broader.

This CL also rearchitects some of the internals of FeaturePromoControllerCommon and FeaturePromoSnoozeService to offload specific IPH lifecycle tracking onto a new FeaturePromoLifecycle class. It also renames the stripped down snooze service to FeaturePromoStorageService, as it now stores all non-transient state information not already handled by feature_engagement::Tracker. These architectural changes not only enable the new behaviors, but also simplify the code and make the business logic much easier to read and understand.

Future work:
 - Determine if we need a combination of the Per-App and Legal Notice subtype.
 - Determine if we need a dismissal that times out (e.g. for a legal notice that must be shown once per year).

Note: Legal notices could theoretically be implemented via extremely specific FE configuration using unlimited trigger and limited used events, with the used event coming from certain kinds of bubble dismissal. However, this would force FE auto-configuration and make Finch much harder; instead I opted to use the existing snooze machinery.

Bug: 1479993, 1479773, 1482527

Change-Id: I498874c12eb41bea58c46e89416583380c3066c8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4855259
Commit-Queue: Dana Fried <[email protected]>
Reviewed-by: Mickey Burks <[email protected]>
Reviewed-by: Tommy Nyquist <[email protected]>
Code-Coverage: [email protected] <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1197988}
diff --git a/chrome/browser/prefs/browser_prefs.cc b/chrome/browser/prefs/browser_prefs.cc
index f36a367..3efc2e8 100644
--- a/chrome/browser/prefs/browser_prefs.cc
+++ b/chrome/browser/prefs/browser_prefs.cc
@@ -85,7 +85,7 @@
 #include "chrome/browser/ui/tabs/pinned_tab_codec.h"
 #include "chrome/browser/ui/toolbar/chrome_labs_prefs.h"
 #include "chrome/browser/ui/toolbar/chrome_location_bar_model_delegate.h"
-#include "chrome/browser/ui/user_education/browser_feature_promo_snooze_service.h"
+#include "chrome/browser/ui/user_education/browser_feature_promo_storage_service.h"
 #include "chrome/browser/ui/webui/bookmarks/bookmark_prefs.h"
 #include "chrome/browser/ui/webui/flags/flags_ui.h"
 #include "chrome/browser/ui/webui/ntp/new_tab_ui.h"
@@ -1717,7 +1717,7 @@
 #else  // BUILDFLAG(IS_ANDROID)
   bookmarks_webui::RegisterProfilePrefs(registry);
   browser_sync::ForeignSessionHandler::RegisterProfilePrefs(registry);
-  BrowserFeaturePromoSnoozeService::RegisterProfilePrefs(registry);
+  BrowserFeaturePromoStorageService::RegisterProfilePrefs(registry);
   captions::LiveTranslateController::RegisterProfilePrefs(registry);
   ChromeAuthenticatorRequestDelegate::RegisterProfilePrefs(registry);
   companion::PromoHandler::RegisterProfilePrefs(registry);
diff --git a/chrome/browser/ui/BUILD.gn b/chrome/browser/ui/BUILD.gn
index 3aedcff..d041862 100644
--- a/chrome/browser/ui/BUILD.gn
+++ b/chrome/browser/ui/BUILD.gn
@@ -1528,8 +1528,8 @@
       "uma_browsing_activity_observer.h",
       "unload_controller.cc",
       "unload_controller.h",
-      "user_education/browser_feature_promo_snooze_service.cc",
-      "user_education/browser_feature_promo_snooze_service.h",
+      "user_education/browser_feature_promo_storage_service.cc",
+      "user_education/browser_feature_promo_storage_service.h",
       "user_education/scoped_new_badge_tracker.cc",
       "user_education/scoped_new_badge_tracker.h",
       "user_education/show_promo_in_page.cc",
diff --git a/chrome/browser/ui/user_education/browser_feature_promo_snooze_service.cc b/chrome/browser/ui/user_education/browser_feature_promo_snooze_service.cc
deleted file mode 100644
index 3fcb4ab1..0000000
--- a/chrome/browser/ui/user_education/browser_feature_promo_snooze_service.cc
+++ /dev/null
@@ -1,132 +0,0 @@
-// Copyright 2021 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#include "chrome/browser/ui/user_education/browser_feature_promo_snooze_service.h"
-
-#include <ostream>
-
-#include "base/feature_list.h"
-#include "base/json/values_util.h"
-#include "base/metrics/field_trial_params.h"
-#include "base/metrics/histogram_functions.h"
-#include "base/time/time.h"
-#include "base/values.h"
-#include "chrome/browser/profiles/profile.h"
-#include "components/prefs/pref_registry_simple.h"
-#include "components/prefs/scoped_user_pref_update.h"
-#include "third_party/abseil-cpp/absl/types/optional.h"
-
-namespace {
-// Snooze data will be saved as a dictionary in the PrefService of a profile.
-
-// PrefService path.
-const char kIPHSnoozeDataPath[] = "in_product_help.snoozed_feature";
-
-// Path to the boolean indicates if an IPH was dismissed.
-// in_product_help.snoozed_feature.[iph_name].is_dismissed
-constexpr char kIPHIsDismissedPath[] = "is_dismissed";
-// Path to the timestamp an IPH was last shown.
-// in_product_help.snoozed_feature.[iph_name].last_show_time
-constexpr char kIPHLastShowTimePath[] = "last_show_time";
-// Path to the timestamp an IPH was last snoozed.
-// in_product_help.snoozed_feature.[iph_name].last_snooze_time
-constexpr char kIPHLastSnoozeTimePath[] = "last_snooze_time";
-// Path to the duration of snooze.
-// in_product_help.snoozed_feature.[iph_name].last_snooze_duration
-constexpr char kIPHLastSnoozeDurationPath[] = "last_snooze_duration";
-// Path to the count of how many times this IPH has been snoozed.
-// in_product_help.snoozed_feature.[iph_name].snooze_count
-constexpr char kIPHSnoozeCountPath[] = "snooze_count";
-// Path to the count of how many times this IPH has been shown.
-// in_product_help.snoozed_feature.[iph_name].show_count
-constexpr char kIPHShowCountPath[] = "show_count";
-
-}  // namespace
-
-BrowserFeaturePromoSnoozeService::BrowserFeaturePromoSnoozeService(
-    Profile* profile)
-    : profile_(profile) {}
-BrowserFeaturePromoSnoozeService::~BrowserFeaturePromoSnoozeService() = default;
-
-// static
-void BrowserFeaturePromoSnoozeService::RegisterProfilePrefs(
-    PrefRegistrySimple* registry) {
-  registry->RegisterDictionaryPref(kIPHSnoozeDataPath);
-}
-
-void BrowserFeaturePromoSnoozeService::Reset(const base::Feature& iph_feature) {
-  ScopedDictPrefUpdate update(profile_->GetPrefs(), kIPHSnoozeDataPath);
-  update->RemoveByDottedPath(iph_feature.name);
-}
-
-absl::optional<user_education::FeaturePromoSnoozeService::SnoozeData>
-BrowserFeaturePromoSnoozeService::ReadSnoozeData(
-    const base::Feature& iph_feature) {
-  std::string path_prefix = std::string(iph_feature.name) + ".";
-
-  const auto& pref_data = profile_->GetPrefs()->GetDict(kIPHSnoozeDataPath);
-  absl::optional<bool> is_dismissed =
-      pref_data.FindBoolByDottedPath(path_prefix + kIPHIsDismissedPath);
-  absl::optional<base::Time> show_time = base::ValueToTime(
-      pref_data.FindByDottedPath(path_prefix + kIPHLastShowTimePath));
-  absl::optional<base::Time> snooze_time = base::ValueToTime(
-      pref_data.FindByDottedPath(path_prefix + kIPHLastSnoozeTimePath));
-  absl::optional<base::TimeDelta> snooze_duration = base::ValueToTimeDelta(
-      pref_data.FindByDottedPath(path_prefix + kIPHLastSnoozeDurationPath));
-  absl::optional<int> snooze_count =
-      pref_data.FindIntByDottedPath(path_prefix + kIPHSnoozeCountPath);
-  absl::optional<int> show_count =
-      pref_data.FindIntByDottedPath(path_prefix + kIPHShowCountPath);
-
-  absl::optional<SnoozeData> snooze_data;
-
-  if (!is_dismissed)
-    return snooze_data;
-
-  if (!snooze_time || !snooze_count || !snooze_duration) {
-    // IPH snooze data is corrupt. Clear data of this feature.
-    Reset(iph_feature);
-    return snooze_data;
-  }
-
-  if (!show_time || !show_count) {
-    // This feature was shipped before without handling
-    // non-clickers. Assume previous displays were all snooozed.
-    show_time = *snooze_time - base::Seconds(1);
-    show_count = *snooze_count;
-  }
-
-  snooze_data = SnoozeData();
-  snooze_data->is_dismissed = *is_dismissed;
-  snooze_data->last_show_time = *show_time;
-  snooze_data->last_snooze_time = *snooze_time;
-  snooze_data->last_snooze_duration = *snooze_duration;
-  snooze_data->snooze_count = *snooze_count;
-  snooze_data->show_count = *show_count;
-
-  return snooze_data;
-}
-
-void BrowserFeaturePromoSnoozeService::SaveSnoozeData(
-    const base::Feature& iph_feature,
-    const user_education::FeaturePromoSnoozeService::SnoozeData& snooze_data) {
-  std::string path_prefix = std::string(iph_feature.name) + ".";
-
-  ScopedDictPrefUpdate update(profile_->GetPrefs(), kIPHSnoozeDataPath);
-  auto& pref_data = update.Get();
-
-  pref_data.SetByDottedPath(path_prefix + kIPHIsDismissedPath,
-                            snooze_data.is_dismissed);
-  pref_data.SetByDottedPath(path_prefix + kIPHLastShowTimePath,
-                            base::TimeToValue(snooze_data.last_show_time));
-  pref_data.SetByDottedPath(path_prefix + kIPHLastSnoozeTimePath,
-                            base::TimeToValue(snooze_data.last_snooze_time));
-  pref_data.SetByDottedPath(
-      path_prefix + kIPHLastSnoozeDurationPath,
-      base::TimeDeltaToValue(snooze_data.last_snooze_duration));
-  pref_data.SetByDottedPath(path_prefix + kIPHSnoozeCountPath,
-                            snooze_data.snooze_count);
-  pref_data.SetByDottedPath(path_prefix + kIPHShowCountPath,
-                            snooze_data.show_count);
-}
diff --git a/chrome/browser/ui/user_education/browser_feature_promo_snooze_service.h b/chrome/browser/ui/user_education/browser_feature_promo_snooze_service.h
deleted file mode 100644
index d73664d..0000000
--- a/chrome/browser/ui/user_education/browser_feature_promo_snooze_service.h
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright 2021 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#ifndef CHROME_BROWSER_UI_USER_EDUCATION_BROWSER_FEATURE_PROMO_SNOOZE_SERVICE_H_
-#define CHROME_BROWSER_UI_USER_EDUCATION_BROWSER_FEATURE_PROMO_SNOOZE_SERVICE_H_
-
-#include "base/memory/raw_ptr.h"
-#include "base/time/time.h"
-#include "components/user_education/common/feature_promo_snooze_service.h"
-#include "third_party/abseil-cpp/absl/types/optional.h"
-
-class Profile;
-class PrefRegistrySimple;
-
-class BrowserFeaturePromoSnoozeService
-    : public user_education::FeaturePromoSnoozeService {
- public:
-  explicit BrowserFeaturePromoSnoozeService(Profile* profile);
-  ~BrowserFeaturePromoSnoozeService() override;
-
-  static void RegisterProfilePrefs(PrefRegistrySimple* registry);
-
-  // FeaturePromoSnoozeService:
-  void Reset(const base::Feature& iph_feature) override;
-  absl::optional<SnoozeData> ReadSnoozeData(
-      const base::Feature& iph_feature) override;
-  void SaveSnoozeData(const base::Feature& iph_feature,
-                      const SnoozeData& snooze_data) override;
-
- private:
-  // TODO(crbug.com/1121399): refactor prefs code so friending tests
-  // isn't necessary.
-  friend class FeaturePromoSnoozeInteractiveTest;
-
-  const raw_ptr<Profile> profile_;
-};
-
-#endif  // CHROME_BROWSER_UI_USER_EDUCATION_BROWSER_FEATURE_PROMO_SNOOZE_SERVICE_H_
diff --git a/chrome/browser/ui/user_education/browser_feature_promo_snooze_service_unittest.cc b/chrome/browser/ui/user_education/browser_feature_promo_snooze_service_unittest.cc
deleted file mode 100644
index 5cbf11b..0000000
--- a/chrome/browser/ui/user_education/browser_feature_promo_snooze_service_unittest.cc
+++ /dev/null
@@ -1,98 +0,0 @@
-// Copyright 2020 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#include "chrome/browser/ui/user_education/browser_feature_promo_snooze_service.h"
-
-#include <memory>
-
-#include "base/feature_list.h"
-#include "base/metrics/field_trial_param_associator.h"
-#include "base/test/scoped_feature_list.h"
-#include "base/test/task_environment.h"
-#include "base/time/time.h"
-#include "chrome/test/base/testing_profile.h"
-#include "components/feature_engagement/public/feature_constants.h"
-#include "content/public/test/browser_task_environment.h"
-#include "testing/gtest/include/gtest/gtest.h"
-
-namespace {
-BASE_FEATURE(kTestIPHFeature,
-             "TestIPHFeature",
-             base::FEATURE_ENABLED_BY_DEFAULT);
-BASE_FEATURE(kTestIPHFeature2,
-             "TestIPHFeature2",
-             base::FEATURE_ENABLED_BY_DEFAULT);
-
-}  // namespace
-
-// Repeats some of the tests in FeaturePromoSnoozeServiceTest except that a live
-// test profile is used to back the service instead of a dummy data map.
-class BrowserFeaturePromoSnoozeServiceTest : public testing::Test {
- public:
-  BrowserFeaturePromoSnoozeServiceTest()
-      : task_environment_{base::test::SingleThreadTaskEnvironment::TimeSource::
-                              MOCK_TIME},
-        service_{&profile_} {}
-
- protected:
-  content::BrowserTaskEnvironment task_environment_;
-  TestingProfile profile_;
-  BrowserFeaturePromoSnoozeService service_;
-};
-
-TEST_F(BrowserFeaturePromoSnoozeServiceTest, AllowFirstTimeIPH) {
-  service_.Reset(kTestIPHFeature);
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-}
-
-TEST_F(BrowserFeaturePromoSnoozeServiceTest, BlockDismissedIPH) {
-  service_.Reset(kTestIPHFeature);
-  service_.OnPromoShown(kTestIPHFeature);
-  service_.OnUserDismiss(kTestIPHFeature);
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-  service_.Reset(kTestIPHFeature);
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-}
-
-TEST_F(BrowserFeaturePromoSnoozeServiceTest, BlockSnoozedIPH) {
-  service_.Reset(kTestIPHFeature);
-  service_.OnPromoShown(kTestIPHFeature);
-  service_.OnUserSnooze(kTestIPHFeature);
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-}
-
-TEST_F(BrowserFeaturePromoSnoozeServiceTest, ReleaseSnoozedIPH) {
-  service_.Reset(kTestIPHFeature);
-  service_.OnPromoShown(kTestIPHFeature);
-  service_.OnUserSnooze(kTestIPHFeature, base::Hours(1));
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-  task_environment_.FastForwardBy(base::Hours(2));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-}
-
-TEST_F(BrowserFeaturePromoSnoozeServiceTest, MultipleIPH) {
-  service_.Reset(kTestIPHFeature);
-  service_.Reset(kTestIPHFeature2);
-  service_.OnPromoShown(kTestIPHFeature);
-  service_.OnUserSnooze(kTestIPHFeature, base::Hours(1));
-  service_.OnPromoShown(kTestIPHFeature2);
-  service_.OnUserSnooze(kTestIPHFeature2, base::Hours(3));
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature2));
-  task_environment_.FastForwardBy(base::Hours(2));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature2));
-  task_environment_.FastForwardBy(base::Hours(2));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature2));
-}
-
-TEST_F(BrowserFeaturePromoSnoozeServiceTest, SnoozeNonClicker) {
-  base::test::ScopedFeatureList feature_list;
-  service_.Reset(kTestIPHFeature);
-  service_.OnPromoShown(kTestIPHFeature);
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-  task_environment_.FastForwardBy(base::Days(15));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-}
diff --git a/chrome/browser/ui/user_education/browser_feature_promo_storage_service.cc b/chrome/browser/ui/user_education/browser_feature_promo_storage_service.cc
new file mode 100644
index 0000000..f7ae065
--- /dev/null
+++ b/chrome/browser/ui/user_education/browser_feature_promo_storage_service.cc
@@ -0,0 +1,166 @@
+// Copyright 2021 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "chrome/browser/ui/user_education/browser_feature_promo_storage_service.h"
+
+#include <ostream>
+
+#include "base/feature_list.h"
+#include "base/json/values_util.h"
+#include "base/time/time.h"
+#include "base/values.h"
+#include "chrome/browser/profiles/profile.h"
+#include "components/prefs/pref_registry_simple.h"
+#include "components/prefs/scoped_user_pref_update.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
+#include "third_party/abseil-cpp/absl/types/optional.h"
+
+namespace {
+// Promo data will be saved as a dictionary in the PrefService of a profile.
+
+// PrefService path. The "snooze" bit is a legacy implementation detail.
+const char kIPHPromoDataPath[] = "in_product_help.snoozed_feature";
+
+// Path to the boolean indicates if an IPH was dismissed.
+// in_product_help.snoozed_feature.[iph_name].is_dismissed
+constexpr char kIPHIsDismissedPath[] = "is_dismissed";
+// Path to the enum that indicates how an IPH was dismissed.
+// in_product_help.snoozed_feature.[iph_name].last_dismissed_by
+constexpr char kIPHLastDismissedByPath[] = "last_dismissed_by";
+// Path to the timestamp an IPH was last shown.
+// in_product_help.snoozed_feature.[iph_name].last_show_time
+constexpr char kIPHLastShowTimePath[] = "last_show_time";
+// Path to the timestamp an IPH was last snoozed.
+// in_product_help.snoozed_feature.[iph_name].last_snooze_time
+constexpr char kIPHLastSnoozeTimePath[] = "last_snooze_time";
+// Path to the duration of snooze.
+// in_product_help.snoozed_feature.[iph_name].last_snooze_duration
+constexpr char kIPHLastSnoozeDurationPath[] = "last_snooze_duration";
+// Path to the count of how many times this IPH has been snoozed.
+// in_product_help.snoozed_feature.[iph_name].snooze_count
+constexpr char kIPHSnoozeCountPath[] = "snooze_count";
+// Path to the count of how many times this IPH has been shown.
+// in_product_help.snoozed_feature.[iph_name].show_count
+constexpr char kIPHShowCountPath[] = "show_count";
+// Path to a list of app IDs that the IPH was shown for; applies to app-specific
+// IPH only.
+constexpr char kIPHShownForAppsPath[] = "shown_for_apps";
+
+}  // namespace
+
+BrowserFeaturePromoStorageService::BrowserFeaturePromoStorageService(
+    Profile* profile)
+    : profile_(profile) {}
+BrowserFeaturePromoStorageService::~BrowserFeaturePromoStorageService() =
+    default;
+
+// static
+void BrowserFeaturePromoStorageService::RegisterProfilePrefs(
+    PrefRegistrySimple* registry) {
+  registry->RegisterDictionaryPref(kIPHPromoDataPath);
+}
+
+void BrowserFeaturePromoStorageService::Reset(
+    const base::Feature& iph_feature) {
+  ScopedDictPrefUpdate update(profile_->GetPrefs(), kIPHPromoDataPath);
+  update->RemoveByDottedPath(iph_feature.name);
+}
+
+absl::optional<user_education::FeaturePromoStorageService::PromoData>
+BrowserFeaturePromoStorageService::ReadPromoData(
+    const base::Feature& iph_feature) const {
+  const std::string path_prefix = std::string(iph_feature.name) + ".";
+
+  const auto& pref_data = profile_->GetPrefs()->GetDict(kIPHPromoDataPath);
+  absl::optional<bool> is_dismissed =
+      pref_data.FindBoolByDottedPath(path_prefix + kIPHIsDismissedPath);
+  absl::optional<int> last_dismissed_by =
+      pref_data.FindIntByDottedPath(path_prefix + kIPHLastDismissedByPath);
+  absl::optional<base::Time> show_time = base::ValueToTime(
+      pref_data.FindByDottedPath(path_prefix + kIPHLastShowTimePath));
+  absl::optional<base::Time> snooze_time = base::ValueToTime(
+      pref_data.FindByDottedPath(path_prefix + kIPHLastSnoozeTimePath));
+  absl::optional<base::TimeDelta> snooze_duration = base::ValueToTimeDelta(
+      pref_data.FindByDottedPath(path_prefix + kIPHLastSnoozeDurationPath));
+  absl::optional<int> snooze_count =
+      pref_data.FindIntByDottedPath(path_prefix + kIPHSnoozeCountPath);
+  absl::optional<int> show_count =
+      pref_data.FindIntByDottedPath(path_prefix + kIPHShowCountPath);
+  const base::Value::List* app_list =
+      pref_data.FindListByDottedPath(path_prefix + kIPHShownForAppsPath);
+
+  absl::optional<PromoData> promo_data;
+
+  if (!is_dismissed || !snooze_time || !snooze_count || !snooze_duration) {
+    // IPH data is corrupt. Ignore the previous data.
+    return promo_data;
+  }
+
+  if (!show_time || !show_count) {
+    // This data was stored by a previous version. Assume previous IPH were
+    // snoozed.
+    show_time = *snooze_time - base::Seconds(1);
+    show_count = *snooze_count;
+  }
+
+  promo_data = PromoData();
+  promo_data->is_dismissed = *is_dismissed;
+  promo_data->last_show_time = *show_time;
+  promo_data->last_snooze_time = *snooze_time;
+  promo_data->last_snooze_duration = *snooze_duration;
+  promo_data->snooze_count = *snooze_count;
+  promo_data->show_count = *show_count;
+
+  // Since `last_dismissed_by` was not previously recorded, default to
+  // "canceled" if the data isn't present or is invalid.
+  if (!last_dismissed_by || *last_dismissed_by < 0 ||
+      *last_dismissed_by > CloseReason::kMaxValue) {
+    promo_data->last_dismissed_by = CloseReason::kCancel;
+  } else if (last_dismissed_by) {
+    promo_data->last_dismissed_by =
+        static_cast<CloseReason>(*last_dismissed_by);
+  }
+
+  if (app_list) {
+    for (auto& app : *app_list) {
+      if (auto* const app_id = app.GetIfString()) {
+        promo_data->shown_for_apps.emplace(*app_id);
+      }
+    }
+  }
+
+  return promo_data;
+}
+
+void BrowserFeaturePromoStorageService::SavePromoData(
+    const base::Feature& iph_feature,
+    const user_education::FeaturePromoStorageService::PromoData& promo_data) {
+  std::string path_prefix = std::string(iph_feature.name) + ".";
+
+  ScopedDictPrefUpdate update(profile_->GetPrefs(), kIPHPromoDataPath);
+  auto& pref_data = update.Get();
+
+  pref_data.SetByDottedPath(path_prefix + kIPHIsDismissedPath,
+                            promo_data.is_dismissed);
+  pref_data.SetByDottedPath(path_prefix + kIPHLastDismissedByPath,
+                            static_cast<int>(promo_data.last_dismissed_by));
+  pref_data.SetByDottedPath(path_prefix + kIPHLastShowTimePath,
+                            base::TimeToValue(promo_data.last_show_time));
+  pref_data.SetByDottedPath(path_prefix + kIPHLastSnoozeTimePath,
+                            base::TimeToValue(promo_data.last_snooze_time));
+  pref_data.SetByDottedPath(
+      path_prefix + kIPHLastSnoozeDurationPath,
+      base::TimeDeltaToValue(promo_data.last_snooze_duration));
+  pref_data.SetByDottedPath(path_prefix + kIPHSnoozeCountPath,
+                            promo_data.snooze_count);
+  pref_data.SetByDottedPath(path_prefix + kIPHShowCountPath,
+                            promo_data.show_count);
+
+  base::Value::List shown_for_apps;
+  for (auto& app_id : promo_data.shown_for_apps) {
+    shown_for_apps.Append(app_id);
+  }
+  pref_data.SetByDottedPath(path_prefix + kIPHShownForAppsPath,
+                            std::move(shown_for_apps));
+}
diff --git a/chrome/browser/ui/user_education/browser_feature_promo_storage_service.h b/chrome/browser/ui/user_education/browser_feature_promo_storage_service.h
new file mode 100644
index 0000000..ee9bae95
--- /dev/null
+++ b/chrome/browser/ui/user_education/browser_feature_promo_storage_service.h
@@ -0,0 +1,38 @@
+// Copyright 2021 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef CHROME_BROWSER_UI_USER_EDUCATION_BROWSER_FEATURE_PROMO_STORAGE_SERVICE_H_
+#define CHROME_BROWSER_UI_USER_EDUCATION_BROWSER_FEATURE_PROMO_STORAGE_SERVICE_H_
+
+#include "base/memory/raw_ptr.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
+#include "third_party/abseil-cpp/absl/types/optional.h"
+
+class Profile;
+class PrefRegistrySimple;
+
+class BrowserFeaturePromoStorageService
+    : public user_education::FeaturePromoStorageService {
+ public:
+  explicit BrowserFeaturePromoStorageService(Profile* profile);
+  ~BrowserFeaturePromoStorageService() override;
+
+  static void RegisterProfilePrefs(PrefRegistrySimple* registry);
+
+  // FeaturePromoStorageService:
+  void Reset(const base::Feature& iph_feature) override;
+  absl::optional<PromoData> ReadPromoData(
+      const base::Feature& iph_feature) const override;
+  void SavePromoData(const base::Feature& iph_feature,
+                     const PromoData& snooze_data) override;
+
+ private:
+  // TODO(crbug.com/1121399): refactor prefs code so friending tests
+  // isn't necessary.
+  friend class FeaturePromoStorageInteractiveTest;
+
+  const raw_ptr<Profile> profile_;
+};
+
+#endif  // CHROME_BROWSER_UI_USER_EDUCATION_BROWSER_FEATURE_PROMO_STORAGE_SERVICE_H_
diff --git a/chrome/browser/ui/user_education/browser_feature_promo_storage_service_unittest.cc b/chrome/browser/ui/user_education/browser_feature_promo_storage_service_unittest.cc
new file mode 100644
index 0000000..3ca72e8
--- /dev/null
+++ b/chrome/browser/ui/user_education/browser_feature_promo_storage_service_unittest.cc
@@ -0,0 +1,119 @@
+// Copyright 2020 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "chrome/browser/ui/user_education/browser_feature_promo_storage_service.h"
+
+#include <memory>
+
+#include "base/feature_list.h"
+#include "base/test/task_environment.h"
+#include "base/time/time.h"
+#include "chrome/test/base/testing_profile.h"
+#include "content/public/test/browser_task_environment.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace {
+BASE_FEATURE(kTestIPHFeature,
+             "TestIPHFeature",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+BASE_FEATURE(kTestIPHFeature2,
+             "TestIPHFeature2",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+constexpr char kAppName1[] = "App1";
+constexpr char kAppName2[] = "App2";
+}  // namespace
+
+// Repeats some of the tests in FeaturePromoStorageServiceTest except that a
+// live test profile is used to back the service instead of a dummy data map.
+class BrowserFeaturePromoStorageServiceTest : public testing::Test {
+ public:
+  BrowserFeaturePromoStorageServiceTest()
+      : task_environment_{base::test::SingleThreadTaskEnvironment::TimeSource::
+                              MOCK_TIME},
+        service_{&profile_} {
+    service_.Reset(kTestIPHFeature);
+    service_.Reset(kTestIPHFeature2);
+  }
+
+  static BrowserFeaturePromoStorageService::PromoData CreateTestData() {
+    BrowserFeaturePromoStorageService::PromoData data;
+    data.is_dismissed = true;
+    data.last_dismissed_by =
+        BrowserFeaturePromoStorageService::CloseReason::kSnooze;
+    data.last_show_time = base::Time::FromJsTime(1);
+    data.last_snooze_time = base::Time::FromJsTime(2);
+    data.snooze_count = 3;
+    data.show_count = 4;
+    data.last_snooze_duration = base::Days(3);
+    data.shown_for_apps.insert(kAppName1);
+    data.shown_for_apps.insert(kAppName2);
+    return data;
+  }
+
+  static void CompareData(
+      const BrowserFeaturePromoStorageService::PromoData& expected,
+      const BrowserFeaturePromoStorageService::PromoData& actual) {
+    EXPECT_EQ(expected.is_dismissed, actual.is_dismissed);
+    EXPECT_EQ(expected.last_show_time, actual.last_show_time);
+    EXPECT_EQ(expected.last_snooze_time, actual.last_snooze_time);
+    EXPECT_EQ(expected.snooze_count, actual.snooze_count);
+    EXPECT_EQ(expected.show_count, actual.show_count);
+    EXPECT_EQ(expected.last_snooze_duration, actual.last_snooze_duration);
+    EXPECT_THAT(actual.shown_for_apps,
+                testing::ContainerEq(expected.shown_for_apps));
+  }
+
+ protected:
+  content::BrowserTaskEnvironment task_environment_;
+  TestingProfile profile_;
+  BrowserFeaturePromoStorageService service_;
+};
+
+TEST_F(BrowserFeaturePromoStorageServiceTest, NoDataByDefault) {
+  EXPECT_FALSE(service_.ReadPromoData(kTestIPHFeature).has_value());
+}
+
+TEST_F(BrowserFeaturePromoStorageServiceTest, SavesAndReadsData) {
+  const auto data = CreateTestData();
+  service_.SavePromoData(kTestIPHFeature, data);
+  const auto result = service_.ReadPromoData(kTestIPHFeature);
+  ASSERT_TRUE(result.has_value());
+  CompareData(data, *result);
+}
+
+TEST_F(BrowserFeaturePromoStorageServiceTest, SaveAgain) {
+  auto data = CreateTestData();
+  service_.SavePromoData(kTestIPHFeature, data);
+  data.shown_for_apps.clear();
+  data.is_dismissed = false;
+  data.show_count++;
+  service_.SavePromoData(kTestIPHFeature, data);
+  const auto result = service_.ReadPromoData(kTestIPHFeature);
+  ASSERT_TRUE(result.has_value());
+  CompareData(data, *result);
+}
+
+TEST_F(BrowserFeaturePromoStorageServiceTest, ResetClearsData) {
+  const auto data = CreateTestData();
+  service_.SavePromoData(kTestIPHFeature, data);
+  service_.Reset(kTestIPHFeature);
+  const auto result = service_.ReadPromoData(kTestIPHFeature);
+  ASSERT_FALSE(result.has_value());
+}
+
+TEST_F(BrowserFeaturePromoStorageServiceTest, SavesAndReadsMultipleFeatures) {
+  const auto data = CreateTestData();
+  service_.SavePromoData(kTestIPHFeature, data);
+  EXPECT_FALSE(service_.ReadPromoData(kTestIPHFeature2).has_value());
+  auto data2 = CreateTestData();
+  data2.is_dismissed = false;
+  data2.last_dismissed_by =
+      BrowserFeaturePromoStorageService::CloseReason::kCancel;
+  data2.shown_for_apps.clear();
+  data2.show_count = 6;
+  service_.SavePromoData(kTestIPHFeature2, data2);
+  CompareData(data, *service_.ReadPromoData(kTestIPHFeature));
+  CompareData(data2, *service_.ReadPromoData(kTestIPHFeature2));
+}
diff --git a/chrome/browser/ui/views/frame/browser_view.cc b/chrome/browser/ui/views/frame/browser_view.cc
index a6e3a81..d4c6520 100644
--- a/chrome/browser/ui/views/frame/browser_view.cc
+++ b/chrome/browser/ui/views/frame/browser_view.cc
@@ -91,7 +91,7 @@
 #include "chrome/browser/ui/tabs/tab_utils.h"
 #include "chrome/browser/ui/toolbar/app_menu_model.h"
 #include "chrome/browser/ui/ui_features.h"
-#include "chrome/browser/ui/user_education/browser_feature_promo_snooze_service.h"
+#include "chrome/browser/ui/user_education/browser_feature_promo_storage_service.h"
 #include "chrome/browser/ui/view_ids.h"
 #include "chrome/browser/ui/views/accelerator_table.h"
 #include "chrome/browser/ui/views/accessibility/accessibility_focus_highlight.h"
@@ -878,8 +878,8 @@
       MaybeRegisterChromeFeaturePromos(
           user_education_service->feature_promo_registry());
       MaybeRegisterChromeTutorials(user_education_service->tutorial_registry());
-      feature_promo_snooze_service_ =
-          std::make_unique<BrowserFeaturePromoSnoozeService>(GetProfile());
+      feature_promo_storage_service_ =
+          std::make_unique<BrowserFeaturePromoStorageService>(GetProfile());
       feature_promo_controller_ =
           std::make_unique<BrowserFeaturePromoController>(
               this,
@@ -887,7 +887,7 @@
                   GetProfile()),
               &user_education_service->feature_promo_registry(),
               &user_education_service->help_bubble_factory_registry(),
-              feature_promo_snooze_service_.get(),
+              feature_promo_storage_service_.get(),
               &user_education_service->tutorial_service());
     }
   }
diff --git a/chrome/browser/ui/views/frame/browser_view.h b/chrome/browser/ui/views/frame/browser_view.h
index 6140d79..75997cc5 100644
--- a/chrome/browser/ui/views/frame/browser_view.h
+++ b/chrome/browser/ui/views/frame/browser_view.h
@@ -29,7 +29,7 @@
 #include "chrome/browser/ui/tabs/tab_renderer_data.h"
 #include "chrome/browser/ui/tabs/tab_strip_model_observer.h"
 #include "chrome/browser/ui/translate/partial_translate_bubble_model.h"
-#include "chrome/browser/ui/user_education/browser_feature_promo_snooze_service.h"
+#include "chrome/browser/ui/user_education/browser_feature_promo_storage_service.h"
 #include "chrome/browser/ui/views/exclusive_access_bubble_views_context.h"
 #include "chrome/browser/ui/views/extensions/extension_keybinding_registry_views.h"
 #include "chrome/browser/ui/views/frame/browser_frame.h"
@@ -1254,8 +1254,8 @@
 
   std::unique_ptr<AccessibilityFocusHighlight> accessibility_focus_highlight_;
 
-  std::unique_ptr<BrowserFeaturePromoSnoozeService>
-      feature_promo_snooze_service_ = nullptr;
+  std::unique_ptr<BrowserFeaturePromoStorageService>
+      feature_promo_storage_service_ = nullptr;
   std::unique_ptr<BrowserFeaturePromoController> feature_promo_controller_ =
       nullptr;
 
diff --git a/chrome/browser/ui/views/frame/webui_tab_strip_interactive_uitest.cc b/chrome/browser/ui/views/frame/webui_tab_strip_interactive_uitest.cc
index ce0da17..a395df48 100644
--- a/chrome/browser/ui/views/frame/webui_tab_strip_interactive_uitest.cc
+++ b/chrome/browser/ui/views/frame/webui_tab_strip_interactive_uitest.cc
@@ -178,10 +178,6 @@
   WebUITabStripContainerView* const container = browser_view->webui_tab_strip();
   ASSERT_NE(nullptr, container);
 
-  // IPH may cause a reveal. Stop it.
-  auto lock =
-      browser_view->GetFeaturePromoController()->BlockPromosForTesting();
-
   EXPECT_FALSE(immersive_mode_controller->IsRevealed());
 
   // Try opening the tab strip.
diff --git a/chrome/browser/ui/views/user_education/browser_feature_promo_controller.cc b/chrome/browser/ui/views/user_education/browser_feature_promo_controller.cc
index 1ccac5a..abe573a 100644
--- a/chrome/browser/ui/views/user_education/browser_feature_promo_controller.cc
+++ b/chrome/browser/ui/views/user_education/browser_feature_promo_controller.cc
@@ -15,6 +15,7 @@
 #include "chrome/browser/search_engine_choice/search_engine_choice_service.h"
 #include "chrome/browser/search_engine_choice/search_engine_choice_service_factory.h"
 #include "chrome/browser/ui/views/frame/browser_view.h"
+#include "chrome/browser/ui/web_applications/app_browser_controller.h"
 #include "chrome/browser/user_education/user_education_service.h"
 #include "chrome/browser/user_education/user_education_service_factory.h"
 #include "chrome/grit/generated_resources.h"
@@ -33,12 +34,12 @@
     feature_engagement::Tracker* feature_engagement_tracker,
     user_education::FeaturePromoRegistry* registry,
     user_education::HelpBubbleFactoryRegistry* help_bubble_registry,
-    user_education::FeaturePromoSnoozeService* snooze_service,
+    user_education::FeaturePromoStorageService* storage_service,
     user_education::TutorialService* tutorial_service)
     : FeaturePromoControllerCommon(feature_engagement_tracker,
                                    registry,
                                    help_bubble_registry,
-                                   snooze_service,
+                                   storage_service,
                                    tutorial_service),
       browser_view_(browser_view) {}
 
@@ -203,3 +204,11 @@
     const {
   return feature_engagement::events::kFocusHelpBubbleAcceleratorPromoRead;
 }
+
+std::string BrowserFeaturePromoController::GetAppId() const {
+  if (const web_app::AppBrowserController* const controller =
+          browser_view_->browser()->app_controller()) {
+    return controller->app_id();
+  }
+  return std::string();
+}
diff --git a/chrome/browser/ui/views/user_education/browser_feature_promo_controller.h b/chrome/browser/ui/views/user_education/browser_feature_promo_controller.h
index 863c93b..22bef0ef 100644
--- a/chrome/browser/ui/views/user_education/browser_feature_promo_controller.h
+++ b/chrome/browser/ui/views/user_education/browser_feature_promo_controller.h
@@ -23,7 +23,7 @@
 
 namespace user_education {
 class FeaturePromoRegistry;
-class FeaturePromoSnoozeService;
+class FeaturePromoStorageService;
 class HelpBubbleFactoryRegistry;
 class TutorialService;
 }  // namespace user_education
@@ -49,7 +49,7 @@
       feature_engagement::Tracker* feature_engagement_tracker,
       user_education::FeaturePromoRegistry* registry,
       user_education::HelpBubbleFactoryRegistry* help_bubble_registry,
-      user_education::FeaturePromoSnoozeService* snooze_service,
+      user_education::FeaturePromoStorageService* storage_service,
       user_education::TutorialService* tutorial_service);
   ~BrowserFeaturePromoController() override;
 
@@ -85,6 +85,7 @@
   std::u16string GetBodyIconAltText() const override;
   const base::Feature* GetScreenReaderPromptPromoFeature() const override;
   const char* GetScreenReaderPromptPromoEventName() const override;
+  std::string GetAppId() const override;
 
  private:
   // The browser window this instance is responsible for.
diff --git a/chrome/browser/ui/views/user_education/browser_feature_promo_controller_unittest.cc b/chrome/browser/ui/views/user_education/browser_feature_promo_controller_unittest.cc
index 92aa2e3..e4ec601 100644
--- a/chrome/browser/ui/views/user_education/browser_feature_promo_controller_unittest.cc
+++ b/chrome/browser/ui/views/user_education/browser_feature_promo_controller_unittest.cc
@@ -34,8 +34,8 @@
 #include "components/user_education/common/feature_promo_controller.h"
 #include "components/user_education/common/feature_promo_handle.h"
 #include "components/user_education/common/feature_promo_registry.h"
-#include "components/user_education/common/feature_promo_snooze_service.h"
 #include "components/user_education/common/feature_promo_specification.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
 #include "components/user_education/common/help_bubble_factory_registry.h"
 #include "components/user_education/common/help_bubble_params.h"
 #include "components/user_education/common/tutorial.h"
@@ -95,9 +95,9 @@
 using user_education::FeaturePromoController;
 using user_education::FeaturePromoHandle;
 using user_education::FeaturePromoRegistry;
-using user_education::FeaturePromoSnoozeService;
 using user_education::FeaturePromoSpecification;
 using user_education::FeaturePromoStatus;
+using user_education::FeaturePromoStorageService;
 using user_education::HelpBubble;
 using user_education::HelpBubbleArrow;
 using user_education::HelpBubbleFactoryRegistry;
@@ -188,8 +188,8 @@
   }
 
  protected:
-  FeaturePromoSnoozeService* snooze_service() {
-    return controller_->snooze_service();
+  FeaturePromoStorageService* storage_service() {
+    return controller_->storage_service();
   }
 
   FeaturePromoRegistry* registry() { return controller_->registry(); }
@@ -611,13 +611,21 @@
 }
 
 TEST_F(BrowserFeaturePromoControllerTest, SnoozeServiceBlocksPromo) {
-  EXPECT_CALL(*mock_tracker_, ShouldTriggerHelpUI(Ref(kTestIPHFeature)))
+  EXPECT_CALL(*mock_tracker_, ShouldTriggerHelpUI(Ref(kTutorialIPHFeature)))
       .Times(0);
-  snooze_service()->OnUserDismiss(kTestIPHFeature);
-  EXPECT_FALSE(controller_->MaybeShowPromo(kTestIPHFeature));
-  EXPECT_FALSE(controller_->IsPromoActive(kTestIPHFeature));
+  // Simulate a snooze by writing data directly.
+  user_education::FeaturePromoStorageService::PromoData data;
+  data.show_count = 1;
+  data.snooze_count = 1;
+  data.last_show_time = base::Time::Now();
+  data.last_snooze_time = base::Time::Now();
+  data.last_snooze_duration = base::Days(7);
+  storage_service()->SavePromoData(kTutorialIPHFeature, data);
+
+  EXPECT_FALSE(controller_->MaybeShowPromo(kTutorialIPHFeature));
+  EXPECT_FALSE(controller_->IsPromoActive(kTutorialIPHFeature));
   EXPECT_FALSE(GetPromoBubble());
-  snooze_service()->Reset(kTestIPHFeature);
+  storage_service()->Reset(kTutorialIPHFeature);
 }
 
 TEST_F(BrowserFeaturePromoControllerTest, PromoEndsWhenRequested) {
@@ -733,6 +741,24 @@
   EXPECT_CALL(*mock_tracker_, Dismissed(Ref(kTestIPHFeature))).Times(1);
 }
 
+TEST_F(BrowserFeaturePromoControllerTest, ContinuedPromoDismissesOnForceEnd) {
+  EXPECT_CALL(*mock_tracker_, ShouldTriggerHelpUI(Ref(kTestIPHFeature)))
+      .WillOnce(Return(true));
+  EXPECT_CALL(*mock_tracker_, Dismissed).Times(0);
+  ASSERT_TRUE(controller_->MaybeShowPromo(kTestIPHFeature));
+
+  FeaturePromoHandle promo_handle =
+      controller_->CloseBubbleAndContinuePromo(kTestIPHFeature);
+
+  EXPECT_CALL(*mock_tracker_, Dismissed(Ref(kTestIPHFeature))).Times(1);
+  controller_->EndPromo(kTestIPHFeature,
+                        user_education::FeaturePromoCloseReason::kAbortPromo);
+  EXPECT_FALSE(controller_->IsPromoActive(kTestIPHFeature,
+                                          FeaturePromoStatus::kContinued));
+  EXPECT_CALL(*mock_tracker_, Dismissed).Times(0);
+  promo_handle.Release();
+}
+
 TEST_F(BrowserFeaturePromoControllerTest, PromoHandleDismissesPromoOnRelease) {
   EXPECT_CALL(*mock_tracker_, ShouldTriggerHelpUI(Ref(kTestIPHFeature)))
       .WillOnce(Return(true));
@@ -855,27 +881,6 @@
       GetAnchorView()->GetProperty(user_education::kHasInProductHelpPromoKey));
 }
 
-TEST_F(BrowserFeaturePromoControllerTest, TestCanBlockPromos) {
-  EXPECT_CALL(*mock_tracker_, ShouldTriggerHelpUI(Ref(kTestIPHFeature)))
-      .Times(0);
-
-  auto lock = controller_->BlockPromosForTesting();
-  EXPECT_FALSE(controller_->MaybeShowPromo(kTestIPHFeature));
-  EXPECT_FALSE(controller_->IsPromoActive(kTestIPHFeature));
-  EXPECT_FALSE(GetPromoBubble());
-}
-
-TEST_F(BrowserFeaturePromoControllerTest, TestCanStopCurrentPromo) {
-  EXPECT_CALL(*mock_tracker_, ShouldTriggerHelpUI(Ref(kTestIPHFeature)))
-      .WillOnce(Return(true));
-
-  EXPECT_TRUE(controller_->MaybeShowPromo(kTestIPHFeature));
-
-  auto lock = controller_->BlockPromosForTesting();
-  EXPECT_FALSE(controller_->IsPromoActive(kTestIPHFeature));
-  EXPECT_FALSE(GetPromoBubble());
-}
-
 TEST_F(BrowserFeaturePromoControllerTest, CriticalPromoBlocksNormalPromo) {
   auto bubble =
       controller_->ShowCriticalPromo(DefaultBubbleParams(), GetAnchorElement());
diff --git a/chrome/browser/ui/views/user_education/browser_user_education_service_browsertest.cc b/chrome/browser/ui/views/user_education/browser_user_education_service_browsertest.cc
index b36760f..f422c6fd 100644
--- a/chrome/browser/ui/views/user_education/browser_user_education_service_browsertest.cc
+++ b/chrome/browser/ui/views/user_education/browser_user_education_service_browsertest.cc
@@ -43,6 +43,8 @@
   kNotConfigured,
   kWrongSessionRate,
   kWrongSessionImpact,
+  kWrongSessionImpactPerApp,
+  kWrongSessionImpactLegalNotice,
   kLegacyPromoNoScreenReader,
 };
 
@@ -145,6 +147,22 @@
             "similar IPH from running (session rate impact ALL); an IPH which "
             "is not limited should not (session rate impact NONE).";
       break;
+    case IPHFailureReason::kWrongSessionImpactPerApp:
+      os << " has unexpected per-app session rate impact: "
+         << failure.config->session_rate_impact.type
+         << ". A heavyweight IPH which runs per-app should prevent other IPH "
+            "from running (session rate impact ALL); it may or may not be "
+            "limited by other IPH.";
+      break;
+    case IPHFailureReason::kWrongSessionImpactLegalNotice:
+      os << " has unexpected per-app session rate and/or session rate impact: "
+         << failure.config->session_rate.type << ", "
+         << failure.config->session_rate.value << ", "
+         << failure.config->session_rate_impact.type
+         << ". A heavyweight legal notice should never be prevented from "
+            "running (session rate ANY) but should prevent other IPH from "
+            "running (session rate impact ALL).";
+      break;
     case IPHFailureReason::kLegacyPromoNoScreenReader:
       os << " is a legacy promo with inadequate screen reader support. Use a "
             "toast promo instead.";
@@ -350,15 +368,39 @@
       case user_education::FeaturePromoSpecification::PromoType::kTutorial:
       case user_education::FeaturePromoSpecification::PromoType::kCustomAction:
       case user_education::FeaturePromoSpecification::PromoType::kSnooze:
-        // Standard promos should be session-limited and should limit other IPH.
-        if (!is_session_limited) {
-          MaybeAddFailure(failures, exceptions, feature,
-                          IPHFailureReason::kWrongSessionRate, feature_config);
-        }
-        if (!limits_other_iph) {
-          MaybeAddFailure(failures, exceptions, feature,
-                          IPHFailureReason::kWrongSessionImpact,
-                          feature_config);
+        switch (spec.promo_subtype()) {
+          case user_education::FeaturePromoSpecification::PromoSubtype::kNormal:
+            // Standard promos should be session-limited and should limit other
+            // IPH.
+            if (!is_session_limited) {
+              MaybeAddFailure(failures, exceptions, feature,
+                              IPHFailureReason::kWrongSessionRate,
+                              feature_config);
+            }
+            if (!limits_other_iph) {
+              MaybeAddFailure(failures, exceptions, feature,
+                              IPHFailureReason::kWrongSessionImpact,
+                              feature_config);
+            }
+            break;
+          case user_education::FeaturePromoSpecification::PromoSubtype::kPerApp:
+            // These can be session limited or not, but they should preclude
+            // other IPH.
+            if (!limits_other_iph) {
+              MaybeAddFailure(failures, exceptions, feature,
+                              IPHFailureReason::kWrongSessionImpactPerApp,
+                              feature_config);
+            }
+            break;
+          case user_education::FeaturePromoSpecification::PromoSubtype::
+              kLegalNotice:
+            // These should not be session limited, and should limit other IPH.
+            if (is_session_limited || !limits_other_iph) {
+              MaybeAddFailure(failures, exceptions, feature,
+                              IPHFailureReason::kWrongSessionImpactPerApp,
+                              feature_config);
+            }
+            break;
         }
         break;
       case user_education::FeaturePromoSpecification::PromoType::kLegacy:
diff --git a/chrome/browser/ui/views/user_education/feature_promo_lifecycle_interactive_uitest.cc b/chrome/browser/ui/views/user_education/feature_promo_lifecycle_interactive_uitest.cc
new file mode 100644
index 0000000..3c4515f1
--- /dev/null
+++ b/chrome/browser/ui/views/user_education/feature_promo_lifecycle_interactive_uitest.cc
@@ -0,0 +1,563 @@
+// Copyright 2020 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include <memory>
+#include <utility>
+
+#include "base/containers/contains.h"
+#include "base/feature_list.h"
+#include "base/test/bind.h"
+#include "base/time/time.h"
+#include "chrome/browser/feature_engagement/tracker_factory.h"
+#include "chrome/browser/profiles/profile.h"
+#include "chrome/browser/ui/browser_commands.h"
+#include "chrome/browser/ui/browser_element_identifiers.h"
+#include "chrome/browser/ui/views/frame/browser_view.h"
+#include "chrome/browser/ui/views/user_education/browser_feature_promo_controller.h"
+#include "chrome/browser/ui/web_applications/app_browser_controller.h"
+#include "chrome/browser/ui/web_applications/web_app_controller_browsertest.h"
+#include "chrome/browser/web_applications/web_app_id.h"
+#include "chrome/grit/generated_resources.h"
+#include "chrome/test/interaction/interactive_browser_test.h"
+#include "components/feature_engagement/test/mock_tracker.h"
+#include "components/feature_engagement/test/scoped_iph_feature_list.h"
+#include "components/keyed_service/content/browser_context_dependency_manager.h"
+#include "components/keyed_service/content/browser_context_keyed_service_factory.h"
+#include "components/user_education/common/feature_promo_controller.h"
+#include "components/user_education/common/feature_promo_specification.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
+#include "components/user_education/views/help_bubble_factory_views.h"
+#include "components/user_education/views/help_bubble_view.h"
+#include "content/public/test/browser_test.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/abseil-cpp/absl/types/optional.h"
+#include "ui/views/view.h"
+#include "ui/views/widget/widget.h"
+
+using ::testing::_;
+using ::testing::AnyNumber;
+using ::testing::NiceMock;
+using ::testing::Ref;
+using ::testing::Return;
+
+namespace {
+BASE_FEATURE(kFeaturePromoLifecycleTestPromo,
+             "FeaturePromoLifecycleTestPromo",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+BASE_FEATURE(kFeaturePromoLifecycleTestPromo2,
+             "FeaturePromoLifecycleTestPromo2",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+BASE_FEATURE(kFeaturePromoLifecycleTestPromo3,
+             "FeaturePromoLifecycleTestPromo3",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+}  // namespace
+
+using TestBase = InteractiveBrowserTestT<web_app::WebAppControllerBrowserTest>;
+using CloseReason = user_education::FeaturePromoStorageService::CloseReason;
+
+class FeaturePromoLifecycleUiTest : public TestBase {
+ public:
+  FeaturePromoLifecycleUiTest() {
+    subscription_ = BrowserContextDependencyManager::GetInstance()
+                        ->RegisterCreateServicesCallbackForTesting(
+                            base::BindRepeating(RegisterMockTracker));
+    scoped_feature_list_.InitAndEnableFeatures(
+        {kFeaturePromoLifecycleTestPromo});
+    disable_active_checks_ = user_education::FeaturePromoControllerCommon::
+        BlockActiveWindowCheckForTesting();
+  }
+  ~FeaturePromoLifecycleUiTest() override = default;
+
+  void SetUpOnMainThread() override {
+    TestBase::SetUpOnMainThread();
+    for (auto& promo : CreatePromos()) {
+      GetPromoController(browser())->registry()->RegisterFeature(
+          std::move(promo));
+    }
+  }
+
+ protected:
+  using PromoData = user_education::FeaturePromoStorageService::PromoData;
+
+  using SpecList = std::vector<user_education::FeaturePromoSpecification>;
+  virtual SpecList CreatePromos() {
+    SpecList promos;
+    promos.emplace_back(
+        user_education::FeaturePromoSpecification::CreateForSnoozePromo(
+            kFeaturePromoLifecycleTestPromo, kToolbarAppMenuButtonElementId,
+            IDS_TAB_GROUPS_NEW_GROUP_PROMO));
+    return promos;
+  }
+
+  auto InBrowser(base::OnceCallback<void(Browser*)> callback) {
+    return WithView(kBrowserViewElementId,
+                    base::BindOnce(
+                        [](base::OnceCallback<void(Browser*)> callback,
+                           BrowserView* browser_view) {
+                          std::move(callback).Run(browser_view->browser());
+                        },
+                        std::move(callback)));
+  }
+
+  auto CheckBrowser(base::OnceCallback<bool(Browser*)> callback) {
+    return CheckView(
+        kBrowserViewElementId,
+        base::BindOnce(
+            [](base::OnceCallback<bool(Browser*)> callback,
+               BrowserView* browser_view) {
+              return std::move(callback).Run(browser_view->browser());
+            },
+            std::move(callback)));
+  }
+
+  auto CheckSnoozePrefs(bool is_dismissed, int show_count, int snooze_count) {
+    return CheckBrowser(base::BindLambdaForTesting(
+        [this, is_dismissed, show_count, snooze_count](Browser* browser) {
+          auto data = GetStorageService(browser)->ReadPromoData(
+              kFeaturePromoLifecycleTestPromo);
+
+          if (!data.has_value()) {
+            return false;
+          }
+
+          EXPECT_EQ(data->is_dismissed, is_dismissed);
+          EXPECT_EQ(data->show_count, show_count);
+          EXPECT_EQ(data->snooze_count, snooze_count);
+
+          // last_show_time is only meaningful if a show has occurred.
+          if (data->show_count > 0) {
+            EXPECT_GE(data->last_show_time, last_show_time_.first);
+            EXPECT_LE(data->last_show_time, last_show_time_.second);
+          }
+
+          // last_snooze_time is only meaningful if a snooze has occurred.
+          if (data->snooze_count > 0) {
+            EXPECT_GE(data->last_snooze_time, last_snooze_time_.first);
+            EXPECT_LE(data->last_snooze_time, last_snooze_time_.second);
+          }
+
+          return !testing::Test::HasNonfatalFailure();
+        }));
+  }
+
+  auto SetSnoozePrefs(const PromoData& data) {
+    return InBrowser(base::BindLambdaForTesting([data](Browser* browser) {
+      GetStorageService(browser)->SavePromoData(kFeaturePromoLifecycleTestPromo,
+                                                data);
+    }));
+  }
+
+  // Tries to show tab groups IPH by meeting the trigger conditions. If
+  // |should_show| is true it checks that it was shown. If false, it
+  // checks that it was not shown.
+  auto AttemptIPH(
+      bool should_show,
+      const base::Feature* feature = &kFeaturePromoLifecycleTestPromo) {
+    return InBrowser(base::BindLambdaForTesting(
+        [this, should_show, feature](Browser* browser) {
+          auto* const tracker = GetTracker(browser);
+          if (should_show) {
+            last_show_time_.first = base::Time::Now();
+            EXPECT_CALL(*tracker, ShouldTriggerHelpUI(Ref(*feature)))
+                .WillOnce(Return(true));
+          } else {
+            EXPECT_CALL(*tracker, ShouldTriggerHelpUI(Ref(*feature))).Times(0);
+          }
+
+          ASSERT_EQ(should_show,
+                    GetPromoController(browser)->MaybeShowPromo(*feature));
+          ASSERT_EQ(should_show,
+                    GetPromoController(browser)->IsPromoActive(*feature));
+
+          // If shown, Tracker::Dismissed should be called eventually.
+          if (should_show) {
+            EXPECT_CALL(*tracker, Dismissed(Ref(*feature)));
+            last_show_time_.second = base::Time::Now();
+          }
+        }));
+  }
+
+  auto SnoozeIPH() {
+    return Steps(
+        Do(base::BindLambdaForTesting(
+            [this]() { last_snooze_time_.first = base::Time::Now(); })),
+        PressButton(
+            user_education::HelpBubbleView::kFirstNonDefaultButtonIdForTesting),
+        WaitForHide(
+            user_education::HelpBubbleView::kHelpBubbleElementIdForTesting),
+        Do(base::BindLambdaForTesting(
+            [this]() { last_snooze_time_.second = base::Time::Now(); })));
+  }
+
+  auto DismissIPH() {
+    return Steps(
+        PressButton(user_education::HelpBubbleView::kCloseButtonIdForTesting),
+        WaitForHide(
+            user_education::HelpBubbleView::kHelpBubbleElementIdForTesting),
+        FlushEvents(), CheckBrowser(base::BindOnce([](Browser* browser) {
+          auto* const promo = GetPromoController(browser)->current_promo_.get();
+          return !promo || (!promo->is_promo_active() && !promo->help_bubble());
+        })));
+  }
+
+  auto AbortIPH(
+      const base::Feature* feature = &kFeaturePromoLifecycleTestPromo) {
+    return InBrowser(base::BindLambdaForTesting([feature](Browser* browser) {
+      GetPromoController(browser)->EndPromo(
+          *feature, user_education::FeaturePromoCloseReason::kAbortPromo);
+    }));
+  }
+
+  auto CheckDismissed(
+      bool dismissed,
+      const base::Feature* feature = &kFeaturePromoLifecycleTestPromo) {
+    return CheckBrowser(
+        base::BindLambdaForTesting([dismissed, feature](Browser* browser) {
+          return GetPromoController(browser)->HasPromoBeenDismissed(*feature) ==
+                 dismissed;
+        }));
+  }
+
+  auto CheckDismissedWithReason(
+      CloseReason close_reason,
+      const base::Feature* feature = &kFeaturePromoLifecycleTestPromo) {
+    return CheckBrowser(
+        base::BindLambdaForTesting([close_reason, feature](Browser* browser) {
+          CloseReason actual_reason;
+          return GetPromoController(browser)->HasPromoBeenDismissed(
+                     *feature, &actual_reason) &&
+                 actual_reason == close_reason;
+        }));
+  }
+
+  static BrowserFeaturePromoController* GetPromoController(Browser* browser) {
+    return static_cast<BrowserFeaturePromoController*>(
+        browser->window()->GetFeaturePromoController());
+  }
+
+  static user_education::FeaturePromoStorageService* GetStorageService(
+      Browser* browser) {
+    return GetPromoController(browser)->storage_service();
+  }
+
+  static NiceMock<feature_engagement::test::MockTracker>* GetTracker(
+      Browser* browser) {
+    return static_cast<NiceMock<feature_engagement::test::MockTracker>*>(
+        feature_engagement::TrackerFactory::GetForBrowserContext(
+            browser->profile()));
+  }
+
+ private:
+  static void RegisterMockTracker(content::BrowserContext* context) {
+    feature_engagement::TrackerFactory::GetInstance()->SetTestingFactory(
+        context, base::BindRepeating(CreateMockTracker));
+  }
+
+  static std::unique_ptr<KeyedService> CreateMockTracker(
+      content::BrowserContext* context) {
+    auto mock_tracker =
+        std::make_unique<NiceMock<feature_engagement::test::MockTracker>>();
+
+    // Allow any other IPH to call, but don't ever show them.
+    EXPECT_CALL(*mock_tracker, ShouldTriggerHelpUI(_))
+        .Times(AnyNumber())
+        .WillRepeatedly(Return(false));
+
+    return mock_tracker;
+  }
+
+  std::pair<base::Time, base::Time> last_show_time_;
+  std::pair<base::Time, base::Time> last_snooze_time_;
+
+  feature_engagement::test::ScopedIphFeatureList scoped_feature_list_;
+  base::CallbackListSubscription subscription_;
+  user_education::FeaturePromoControllerCommon::TestLock disable_active_checks_;
+};
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, DismissDoesNotSnooze) {
+  RunTestSequence(AttemptIPH(true), DismissIPH(),
+                  CheckSnoozePrefs(/* is_dismiss */ true,
+                                   /* show_count */ 1,
+                                   /* snooze_count */ 0));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, SnoozeSetsCorrectTime) {
+  RunTestSequence(AttemptIPH(true), SnoozeIPH(),
+                  CheckSnoozePrefs(/* is_dismiss */ false,
+                                   /* show_count */ 1,
+                                   /* snooze_count */ 1));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, HasPromoBeenDismissed) {
+  RunTestSequence(CheckDismissed(false), AttemptIPH(true), DismissIPH(),
+                  CheckDismissed(true));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest,
+                       HasPromoBeenDismissedWithReason) {
+  RunTestSequence(AttemptIPH(true), DismissIPH(),
+                  CheckDismissed(CloseReason::kCancel));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, CanReSnooze) {
+  // Simulate the user snoozing the IPH.
+  PromoData data;
+  data.is_dismissed = false;
+  data.show_count = 1;
+  data.snooze_count = 1;
+  data.last_snooze_duration = base::Hours(26);
+  data.last_snooze_time = base::Time::Now() - data.last_snooze_duration;
+  data.last_show_time = data.last_snooze_time - base::Seconds(1);
+
+  RunTestSequence(SetSnoozePrefs(data), AttemptIPH(true), SnoozeIPH(),
+                  CheckSnoozePrefs(/* is_dismiss */ false,
+                                   /* show_count */ 2,
+                                   /* snooze_count */ 2));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, DoesNotShowIfDismissed) {
+  PromoData data;
+  data.is_dismissed = true;
+  data.show_count = 1;
+  data.snooze_count = 0;
+
+  RunTestSequence(SetSnoozePrefs(data), AttemptIPH(false));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest,
+                       DoesNotShowBeforeSnoozeDuration) {
+  PromoData data;
+  data.is_dismissed = false;
+  data.show_count = 1;
+  data.snooze_count = 1;
+  data.last_snooze_duration = base::Hours(26);
+  data.last_snooze_time = base::Time::Now();
+  data.last_show_time = data.last_snooze_time - base::Seconds(1);
+
+  RunTestSequence(SetSnoozePrefs(data), AttemptIPH(false));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, AbortPromoSetsPrefs) {
+  RunTestSequence(
+      AttemptIPH(true), AbortIPH(),
+      WaitForHide(
+          user_education::HelpBubbleView::kHelpBubbleElementIdForTesting),
+      CheckSnoozePrefs(/* is_dismiss */ false,
+                       /* show_count */ 1,
+                       /* snooze_count */ 0));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, EndPromoSetsPrefs) {
+  RunTestSequence(
+      AttemptIPH(true), InBrowser(base::BindOnce([](Browser* browser) {
+        GetPromoController(browser)->EndPromo(
+            kFeaturePromoLifecycleTestPromo,
+            user_education::FeaturePromoCloseReason::kFeatureEngaged);
+      })),
+      WaitForHide(
+          user_education::HelpBubbleView::kHelpBubbleElementIdForTesting),
+      CheckSnoozePrefs(/* is_dismiss */ true,
+                       /* show_count */ 1,
+                       /* snooze_count */ 0));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, WidgetCloseSetsPrefs) {
+  RunTestSequence(
+      AttemptIPH(true),
+      WithView(user_education::HelpBubbleView::kHelpBubbleElementIdForTesting,
+               base::BindOnce([](user_education::HelpBubbleView* bubble) {
+                 bubble->GetWidget()->CloseWithReason(
+                     views::Widget::ClosedReason::kEscKeyPressed);
+               })),
+      WaitForHide(
+          user_education::HelpBubbleView::kHelpBubbleElementIdForTesting),
+      CheckSnoozePrefs(/* is_dismiss */ false,
+                       /* show_count */ 1,
+                       /* snooze_count */ 0));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, AnchorHideSetsPrefs) {
+  RunTestSequence(
+      AttemptIPH(true),
+      WithView(user_education::HelpBubbleView::kHelpBubbleElementIdForTesting,
+               base::BindOnce([](user_education::HelpBubbleView* bubble) {
+                 // This should yank the bubble out from under us.
+                 bubble->GetAnchorView()->SetVisible(false);
+               })),
+      WaitForHide(
+          user_education::HelpBubbleView::kHelpBubbleElementIdForTesting),
+      CheckSnoozePrefs(/* is_dismiss */ false,
+                       /* show_count */ 1,
+                       /* snooze_count */ 0));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleUiTest, WorkWithoutNonClickerData) {
+  PromoData data;
+  data.is_dismissed = false;
+  data.snooze_count = 1;
+  data.last_snooze_duration = base::Hours(26);
+  data.last_snooze_time = base::Time::Now() - data.last_snooze_duration;
+
+  // Non-clicker policy shipped pref entries that don't exist before.
+  // Make sure empty entries are properly handled.
+  RunTestSequence(SetSnoozePrefs(data), AttemptIPH(true));
+}
+
+class FeaturePromoLifecycleAppUiTest : public FeaturePromoLifecycleUiTest {
+ public:
+  FeaturePromoLifecycleAppUiTest() = default;
+  ~FeaturePromoLifecycleAppUiTest() override = default;
+
+  static constexpr char kApp1Url[] = "http://example.org/";
+  static constexpr char kApp2Url[] = "http://foo.com/";
+
+  void SetUpOnMainThread() override {
+    FeaturePromoLifecycleUiTest::SetUpOnMainThread();
+    app1_id_ = InstallPWA(GURL(kApp1Url));
+    app2_id_ = InstallPWA(GURL(kApp2Url));
+  }
+
+  auto CheckShownForApp() {
+    return CheckBrowser(base::BindOnce([](Browser* browser) {
+      const auto data = GetStorageService(browser)->ReadPromoData(
+          kFeaturePromoLifecycleTestPromo);
+      return base::Contains(data->shown_for_apps,
+                            browser->app_controller()->app_id());
+    }));
+  }
+
+ protected:
+  web_app::AppId app1_id_;
+  web_app::AppId app2_id_;
+
+ private:
+  SpecList CreatePromos() override {
+    SpecList promos;
+    promos.emplace_back(std::move(
+        user_education::FeaturePromoSpecification::CreateForLegacyPromo(
+            &kFeaturePromoLifecycleTestPromo, kToolbarAppMenuButtonElementId,
+            IDS_TAB_GROUPS_NEW_GROUP_PROMO)
+            .SetPromoSubtype(user_education::FeaturePromoSpecification::
+                                 PromoSubtype::kPerApp)));
+    return promos;
+  }
+};
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleAppUiTest, ShowForApp) {
+  Browser* const app_browser = LaunchWebAppBrowser(app1_id_);
+  RunTestSequenceInContext(app_browser->window()->GetElementContext(),
+                           AttemptIPH(true), DismissIPH(), CheckShownForApp());
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleAppUiTest, ShowForAppThenBlocked) {
+  Browser* const app_browser = LaunchWebAppBrowser(app1_id_);
+  RunTestSequenceInContext(app_browser->window()->GetElementContext(),
+                           AttemptIPH(true), DismissIPH(), FlushEvents(),
+                           AttemptIPH(false));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleAppUiTest, HasPromoBeenDismissed) {
+  Browser* const app_browser = LaunchWebAppBrowser(app1_id_);
+  RunTestSequenceInContext(app_browser->window()->GetElementContext(),
+                           CheckDismissed(false), AttemptIPH(true),
+                           DismissIPH(), CheckDismissed(true));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleAppUiTest, ShowForTwoApps) {
+  Browser* const app_browser = LaunchWebAppBrowser(app1_id_);
+  Browser* const app_browser2 = LaunchWebAppBrowser(app2_id_);
+  RunTestSequenceInContext(
+      app_browser->window()->GetElementContext(), AttemptIPH(true),
+      DismissIPH(), FlushEvents(),
+      InContext(app_browser2->window()->GetElementContext(),
+                Steps(AttemptIPH(true), DismissIPH(), CheckShownForApp())));
+}
+
+class FeaturePromoLifecycleCriticaUiTest : public FeaturePromoLifecycleUiTest {
+ public:
+  FeaturePromoLifecycleCriticaUiTest() = default;
+  ~FeaturePromoLifecycleCriticaUiTest() override = default;
+
+  auto CheckDismissed(
+      bool dismissed,
+      const base::Feature* feature = &kFeaturePromoLifecycleTestPromo) {
+    return CheckBrowser(
+        base::BindLambdaForTesting([dismissed, feature](Browser* browser) {
+          const auto data = GetStorageService(browser)->ReadPromoData(*feature);
+          return (data && data->is_dismissed) == dismissed;
+        }));
+  }
+
+ private:
+  SpecList CreatePromos() override {
+    SpecList result;
+    result.emplace_back(
+        user_education::FeaturePromoSpecification::CreateForLegacyPromo(
+            &kFeaturePromoLifecycleTestPromo, kToolbarAppMenuButtonElementId,
+            IDS_TAB_GROUPS_NEW_GROUP_PROMO));
+    result.back().set_promo_subtype_for_testing(
+        user_education::FeaturePromoSpecification::PromoSubtype::kLegalNotice);
+    result.emplace_back(
+        user_education::FeaturePromoSpecification::CreateForLegacyPromo(
+            &kFeaturePromoLifecycleTestPromo2, kToolbarAppMenuButtonElementId,
+            IDS_TAB_GROUPS_NAMED_GROUP_TOOLTIP));
+    result.back().set_promo_subtype_for_testing(
+        user_education::FeaturePromoSpecification::PromoSubtype::kLegalNotice);
+    result.emplace_back(
+        user_education::FeaturePromoSpecification::CreateForLegacyPromo(
+            &kFeaturePromoLifecycleTestPromo3, kToolbarAppMenuButtonElementId,
+            IDS_TAB_GROUPS_UNNAMED_GROUP_TOOLTIP));
+    return result;
+  }
+};
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleCriticaUiTest, ShowCriticalPromo) {
+  RunTestSequence(CheckDismissed(false), AttemptIPH(true), DismissIPH(),
+                  CheckDismissed(true));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleCriticaUiTest,
+                       CannotRepeatDismissedPromo) {
+  RunTestSequence(AttemptIPH(true), DismissIPH(), FlushEvents(),
+                  AttemptIPH(false));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleCriticaUiTest, ReshowAfterAbort) {
+  RunTestSequence(AttemptIPH(true), AbortIPH(), CheckDismissed(false),
+                  AttemptIPH(true), DismissIPH(), CheckDismissed(true));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleCriticaUiTest,
+                       HasPromoBeenDismissed) {
+  RunTestSequence(CheckDismissed(false), AttemptIPH(true), DismissIPH(),
+                  CheckDismissed(true));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleCriticaUiTest,
+                       ShowSecondAfterDismiss) {
+  RunTestSequence(
+      AttemptIPH(true, &kFeaturePromoLifecycleTestPromo), DismissIPH(),
+      CheckDismissed(true, &kFeaturePromoLifecycleTestPromo),
+      AttemptIPH(true, &kFeaturePromoLifecycleTestPromo2), DismissIPH(),
+      CheckDismissed(true, &kFeaturePromoLifecycleTestPromo2));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleCriticaUiTest,
+                       CriticalBlocksCritical) {
+  RunTestSequence(AttemptIPH(true, &kFeaturePromoLifecycleTestPromo),
+                  AttemptIPH(false, &kFeaturePromoLifecycleTestPromo2),
+                  DismissIPH(),
+                  CheckDismissed(true, &kFeaturePromoLifecycleTestPromo),
+                  CheckDismissed(false, &kFeaturePromoLifecycleTestPromo2));
+}
+
+IN_PROC_BROWSER_TEST_F(FeaturePromoLifecycleCriticaUiTest,
+                       CriticalCancelsNormal) {
+  RunTestSequence(AttemptIPH(true, &kFeaturePromoLifecycleTestPromo3),
+                  AttemptIPH(true, &kFeaturePromoLifecycleTestPromo),
+                  DismissIPH(),
+                  CheckDismissed(true, &kFeaturePromoLifecycleTestPromo),
+                  CheckDismissed(false, &kFeaturePromoLifecycleTestPromo3));
+}
diff --git a/chrome/browser/user_education/user_education_configuration_provider.cc b/chrome/browser/user_education/user_education_configuration_provider.cc
index 03e1118..95c5a338 100644
--- a/chrome/browser/user_education/user_education_configuration_provider.cc
+++ b/chrome/browser/user_education/user_education_configuration_provider.cc
@@ -64,6 +64,11 @@
   }
 
   const auto* const promo_spec = registry_.GetParamsForFeature(feature);
+  const bool is_unlimited =
+      promo_spec->promo_subtype() ==
+          user_education::FeaturePromoSpecification::PromoSubtype::kPerApp ||
+      promo_spec->promo_subtype() ==
+          user_education::FeaturePromoSpecification::PromoSubtype::kLegalNotice;
 
   switch (promo_spec->promo_type()) {
     case user_education::FeaturePromoSpecification::PromoType::kToast:
@@ -78,12 +83,19 @@
     case user_education::FeaturePromoSpecification::PromoType::kSnooze:
     case user_education::FeaturePromoSpecification::PromoType::kCustomAction:
     case user_education::FeaturePromoSpecification::PromoType::kTutorial:
-      // Heavyweight IPH can only show once per session.
-      config.session_rate.type = feature_engagement::EQUAL;
-      config.session_rate.value = 0;
-      config.session_rate_impact.type =
-          feature_engagement::SessionRateImpact::Type::ALL;
-      config.session_rate_impact.affected_features.reset();
+      if (is_unlimited) {
+        config.session_rate.type = feature_engagement::ANY;
+        config.session_rate_impact.type =
+            feature_engagement::SessionRateImpact::Type::ALL;
+        config.session_rate_impact.affected_features.reset();
+      } else {
+        // Heavyweight IPH can only show once per session.
+        config.session_rate.type = feature_engagement::EQUAL;
+        config.session_rate.value = 0;
+        config.session_rate_impact.type =
+            feature_engagement::SessionRateImpact::Type::ALL;
+        config.session_rate_impact.affected_features.reset();
+      }
       break;
 
     case user_education::FeaturePromoSpecification::PromoType::kLegacy:
@@ -103,8 +115,13 @@
   if (config.trigger.name.empty()) {
     config.trigger.name = GetDefaultTriggerName(feature);
   }
-  config.trigger.comparator.type = feature_engagement::LESS_THAN;
-  config.trigger.comparator.value = 3;
+  if (is_unlimited) {
+    config.trigger.comparator.type = feature_engagement::ANY;
+    config.trigger.comparator.value = 0;
+  } else {
+    config.trigger.comparator.type = feature_engagement::LESS_THAN;
+    config.trigger.comparator.value = 3;
+  }
   config.trigger.storage = feature_engagement::kMaxStoragePeriod;
   config.trigger.window = feature_engagement::kMaxStoragePeriod;
 
diff --git a/chrome/browser/user_education/user_education_configuration_provider.h b/chrome/browser/user_education/user_education_configuration_provider.h
index 132dde5..effa0e0 100644
--- a/chrome/browser/user_education/user_education_configuration_provider.h
+++ b/chrome/browser/user_education/user_education_configuration_provider.h
@@ -10,6 +10,7 @@
 #include "components/feature_engagement/public/feature_list.h"
 #include "components/feature_engagement/public/group_list.h"
 #include "components/user_education/common/feature_promo_registry.h"
+#include "third_party/abseil-cpp/absl/types/optional.h"
 
 // Provides feature engagement configuration based on IPH registered in the
 // browser.
diff --git a/chrome/browser/user_education/user_education_configuration_provider_unittest.cc b/chrome/browser/user_education/user_education_configuration_provider_unittest.cc
index b9eaec3..a438f3e 100644
--- a/chrome/browser/user_education/user_education_configuration_provider_unittest.cc
+++ b/chrome/browser/user_education/user_education_configuration_provider_unittest.cc
@@ -9,6 +9,7 @@
 #include <vector>
 
 #include "base/feature_list.h"
+#include "base/functional/callback_helpers.h"
 #include "base/test/scoped_feature_list.h"
 #include "build/build_config.h"
 #include "chrome/app/chrome_command_ids.h"
@@ -17,6 +18,7 @@
 #include "components/feature_engagement/public/group_list.h"
 #include "components/feature_engagement/public/tracker.h"
 #include "components/strings/grit/components_strings.h"
+#include "components/user_education/common/feature_promo_handle.h"
 #include "components/user_education/common/feature_promo_registry.h"
 #include "components/user_education/common/feature_promo_specification.h"
 #include "components/user_education/common/user_education_features.h"
@@ -30,14 +32,25 @@
 constexpr char kToastUsed[] = "ToastIphFeature_used";
 constexpr char kSnoozeTrigger[] = "SnoozeIphFeature_trigger";
 constexpr char kSnoozeUsed[] = "SnoozeIphFeature_used";
+constexpr char kPerAppTrigger[] = "PerAppIphFeature_trigger";
+constexpr char kPerAppUsed[] = "PerAppIphFeature_used";
+constexpr char kLegalNoticeTrigger[] = "LegalNoticeIphFeature_trigger";
+constexpr char kLegalNoticeUsed[] = "LegalNoticeIphFeature_used";
 BASE_FEATURE(kToastIphFeature,
              "IPH_ToastIphFeature",
              base::FEATURE_ENABLED_BY_DEFAULT);
 BASE_FEATURE(kSnoozeIphFeature,
              "IPH_SnoozeIphFeature",
              base::FEATURE_ENABLED_BY_DEFAULT);
+BASE_FEATURE(kPerAppIphFeature,
+             "IPH_PerAppIphFeature",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+BASE_FEATURE(kLegalNoticeIphFeature,
+             "IPH_LegalNoticeIphFeature",
+             base::FEATURE_ENABLED_BY_DEFAULT);
 const std::initializer_list<const base::Feature*> kKnownFeatures{
-    &kToastIphFeature, &kSnoozeIphFeature};
+    &kToastIphFeature, &kSnoozeIphFeature, &kPerAppIphFeature,
+    &kLegalNoticeIphFeature};
 const std::initializer_list<const base::Feature*> kKnownGroups{};
 
 DEFINE_LOCAL_ELEMENT_IDENTIFIER_VALUE(kTestElementId);
@@ -48,15 +61,30 @@
 // SetEnableV2 (if necessary).
 std::unique_ptr<UserEducationConfigurationProvider> CreateProvider() {
   user_education::FeaturePromoRegistry registry;
+
   registry.RegisterFeature(
       user_education::FeaturePromoSpecification::CreateForToastPromo(
           kToastIphFeature, kTestElementId, IDS_OK, IDS_CANCEL,
           user_education::FeaturePromoSpecification::AcceleratorInfo(
               IDC_HOME)));
+
   registry.RegisterFeature(
       user_education::FeaturePromoSpecification::CreateForSnoozePromo(
           kSnoozeIphFeature, kTestElementId, IDS_CLOSE));
 
+  auto spec = user_education::FeaturePromoSpecification::CreateForCustomAction(
+      kPerAppIphFeature, kTestElementId, IDS_CANCEL, IDS_OK, base::DoNothing());
+  spec.set_promo_subtype_for_testing(
+      user_education::FeaturePromoSpecification::PromoSubtype::kPerApp);
+  registry.RegisterFeature(std::move(spec));
+
+  spec = user_education::FeaturePromoSpecification::CreateForCustomAction(
+      kLegalNoticeIphFeature, kTestElementId, IDS_CLEAR, IDS_CLOSE,
+      base::DoNothing());
+  spec.set_promo_subtype_for_testing(
+      user_education::FeaturePromoSpecification::PromoSubtype::kLegalNotice);
+  registry.RegisterFeature(std::move(spec));
+
   return std::make_unique<UserEducationConfigurationProvider>(
       std::move(registry));
 }
@@ -87,6 +115,12 @@
         feature_engagement::kMaxStoragePeriod);
   }
 
+  auto GetAnyTrigger(const char* name) {
+    return feature_engagement::EventConfig(
+        name, kAny, feature_engagement::kMaxStoragePeriod,
+        feature_engagement::kMaxStoragePeriod);
+  }
+
   auto GetDefaultUsed(const char* name) {
     return feature_engagement::EventConfig(
         name, kEqualsZero, feature_engagement::kMaxStoragePeriod,
@@ -172,6 +206,69 @@
   EXPECT_TRUE(config.groups.empty());
 }
 
+TEST_F(UserEducationConfigurationProviderTest, ProvidesPerAppConfiguration) {
+  feature_engagement::FeatureConfig config;
+
+  EXPECT_TRUE(CreateProvider()->MaybeProvideFeatureConfiguration(
+      kPerAppIphFeature, config, kKnownFeatures, kKnownGroups));
+
+  EXPECT_TRUE(config.valid);
+
+  EXPECT_EQ(GetDefaultUsed(kPerAppUsed), config.used);
+
+  EXPECT_EQ(GetAnyTrigger(kPerAppTrigger), config.trigger);
+
+  EXPECT_TRUE(config.event_configs.empty());
+
+  EXPECT_EQ(kAny, config.session_rate);
+
+  EXPECT_EQ(kSessionRateImpactAll, config.session_rate_impact);
+
+  EXPECT_EQ(feature_engagement::BlockedBy(), config.blocked_by);
+
+  EXPECT_EQ(feature_engagement::Blocking(), config.blocking);
+
+  EXPECT_EQ(kAny, config.availability);
+
+  EXPECT_FALSE(config.tracking_only);
+
+  EXPECT_EQ(feature_engagement::SnoozeParams(), config.snooze_params);
+
+  EXPECT_TRUE(config.groups.empty());
+}
+
+TEST_F(UserEducationConfigurationProviderTest,
+       ProvidesLegalNoticeConfiguration) {
+  feature_engagement::FeatureConfig config;
+
+  EXPECT_TRUE(CreateProvider()->MaybeProvideFeatureConfiguration(
+      kLegalNoticeIphFeature, config, kKnownFeatures, kKnownGroups));
+
+  EXPECT_TRUE(config.valid);
+
+  EXPECT_EQ(GetDefaultUsed(kLegalNoticeUsed), config.used);
+
+  EXPECT_EQ(GetAnyTrigger(kLegalNoticeTrigger), config.trigger);
+
+  EXPECT_TRUE(config.event_configs.empty());
+
+  EXPECT_EQ(kAny, config.session_rate);
+
+  EXPECT_EQ(kSessionRateImpactAll, config.session_rate_impact);
+
+  EXPECT_EQ(feature_engagement::BlockedBy(), config.blocked_by);
+
+  EXPECT_EQ(feature_engagement::Blocking(), config.blocking);
+
+  EXPECT_EQ(kAny, config.availability);
+
+  EXPECT_FALSE(config.tracking_only);
+
+  EXPECT_EQ(feature_engagement::SnoozeParams(), config.snooze_params);
+
+  EXPECT_TRUE(config.groups.empty());
+}
+
 TEST_F(UserEducationConfigurationProviderTest, HandlesEventConfigs) {
   feature_engagement::EventConfig event("other_event", kEqualsZero, 100, 100);
 
diff --git a/chrome/test/BUILD.gn b/chrome/test/BUILD.gn
index e774170..8369deb 100644
--- a/chrome/test/BUILD.gn
+++ b/chrome/test/BUILD.gn
@@ -9432,6 +9432,7 @@
       "../browser/signin/signin_ui_util_unittest.cc",
       "../browser/signin/signin_util_unittest.cc",
       "../browser/ui/profiles/profile_customization_synced_theme_waiter_unittest.cc",
+      "../browser/ui/user_education/browser_feature_promo_storage_service_unittest.cc",
       "../browser/ui/views/download/bubble/download_bubble_contents_view_unittest.cc",
       "../browser/ui/views/download/bubble/download_bubble_row_list_view_unittest.cc",
       "../browser/ui/views/download/bubble/download_bubble_row_view_unittest.cc",
@@ -10591,7 +10592,7 @@
         "../browser/ui/views/translate/translate_icon_view_interactive_uitest.cc",
         "../browser/ui/views/user_education/browser_feature_promo_controller_interactive_uitest.cc",
         "../browser/ui/views/user_education/feature_promo_dialog_interactive_uitest.cc",
-        "../browser/ui/views/user_education/feature_promo_snooze_interactive_uitest.cc",
+        "../browser/ui/views/user_education/feature_promo_lifecycle_interactive_uitest.cc",
         "../browser/ui/views/user_education/help_bubble_factory_registry_interactive_uitest.cc",
         "../browser/ui/views/user_education/help_bubble_factory_views_interactive_uitest.cc",
         "../browser/ui/views/user_education/help_bubble_factory_webui_interactive_uitest.cc",
diff --git a/components/user_education/DEPS b/components/user_education/DEPS
index a94f8e6..1101d8f 100644
--- a/components/user_education/DEPS
+++ b/components/user_education/DEPS
@@ -1,6 +1,7 @@
 include_rules = [
   "+base",
   "+components/feature_engagement/public",
+  "+components/feature_engagement/test",
   "+components/strings",
   "+components/vector_icons",
   "+ui/accessibility",
diff --git a/components/user_education/common/BUILD.gn b/components/user_education/common/BUILD.gn
index 1aab24d..bdda0eb5 100644
--- a/components/user_education/common/BUILD.gn
+++ b/components/user_education/common/BUILD.gn
@@ -11,12 +11,14 @@
     "feature_promo_controller.h",
     "feature_promo_handle.cc",
     "feature_promo_handle.h",
+    "feature_promo_lifecycle.cc",
+    "feature_promo_lifecycle.h",
     "feature_promo_registry.cc",
     "feature_promo_registry.h",
-    "feature_promo_snooze_service.cc",
-    "feature_promo_snooze_service.h",
     "feature_promo_specification.cc",
     "feature_promo_specification.h",
+    "feature_promo_storage_service.cc",
+    "feature_promo_storage_service.h",
     "help_bubble.cc",
     "help_bubble.h",
     "help_bubble_factory.h",
@@ -61,7 +63,8 @@
 
   sources = [
     "feature_promo_controller_unittest.cc",
-    "feature_promo_snooze_service_unittest.cc",
+    "feature_promo_lifecycle_unittest.cc",
+    "feature_promo_storage_service_unittest.cc",
     "help_bubble_factory_registry_unittest.cc",
     "product_messaging_controller_unittest.cc",
     "tutorial_unittest.cc",
@@ -72,6 +75,7 @@
     "//base",
     "//base/test:test_support",
     "//components/feature_engagement/public",
+    "//components/feature_engagement/test:test_support",
     "//components/strings",
     "//components/user_education/test",
     "//components/variations",
diff --git a/components/user_education/common/feature_promo_controller.cc b/components/user_education/common/feature_promo_controller.cc
index ec24db3..5340ad46 100644
--- a/components/user_education/common/feature_promo_controller.cc
+++ b/components/user_education/common/feature_promo_controller.cc
@@ -3,7 +3,7 @@
 // found in the LICENSE file.
 
 #include "components/user_education/common/feature_promo_controller.h"
-#include <initializer_list>
+
 #include <string>
 
 #include "base/auto_reset.h"
@@ -12,15 +12,12 @@
 #include "base/feature_list.h"
 #include "base/functional/bind.h"
 #include "base/functional/callback_forward.h"
-#include "base/metrics/histogram_functions.h"
-#include "base/metrics/user_metrics.h"
-#include "base/notreached.h"
-#include "components/feature_engagement/public/event_constants.h"
 #include "components/feature_engagement/public/feature_constants.h"
 #include "components/strings/grit/components_strings.h"
+#include "components/user_education/common/feature_promo_lifecycle.h"
 #include "components/user_education/common/feature_promo_registry.h"
-#include "components/user_education/common/feature_promo_snooze_service.h"
 #include "components/user_education/common/feature_promo_specification.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
 #include "components/user_education/common/help_bubble_factory_registry.h"
 #include "components/user_education/common/help_bubble_params.h"
 #include "components/user_education/common/tutorial.h"
@@ -32,35 +29,6 @@
 
 namespace user_education {
 
-namespace internal {
-// These values are persisted to logs. Entries should not be renumbered and
-// numeric values should never be reused.
-//
-// Most of these values are internal to the FeaturePromoController. Only
-// a few are made public to users (through FeaturePromoCloseReason) that
-// might call EndPromo in order to provide context for emitting metrics.
-enum class FeaturePromoCloseReasonInternal {
-  // Actions within the FeaturePromo.
-  kDismiss = 0,  // Promo dismissed by user.
-  kSnooze = 1,   // Promo snoozed by user.
-  kAction = 2,   // Custom action taken by user.
-  kCancel = 3,   // Promo was cancelled.
-
-  // Actions outside the FeaturePromo.
-  kTimeout = 4,         // Promo timed out.
-  kAbortPromo = 5,      // Promo aborted by indirect user action.
-  kFeatureEngaged = 6,  // Promo closed by indirect user engagement.
-
-  // Controller system actions.
-  kOverrideForUIRegionConflict = 7,  // Promo aborted to avoid overlap.
-  kOverrideForDemo = 8,              // Promo aborted by the demo system.
-  kOverrideForTesting = 9,           // Promo aborted for tests.
-  kOverrideForPrecedence = 10,       // Promo aborted for higher priority Promo.
-
-  kMaxValue = kOverrideForPrecedence,
-};
-}  // namespace internal
-
 FeaturePromoController::FeaturePromoController() = default;
 FeaturePromoController::~FeaturePromoController() = default;
 
@@ -68,16 +36,16 @@
     feature_engagement::Tracker* feature_engagement_tracker,
     FeaturePromoRegistry* registry,
     HelpBubbleFactoryRegistry* help_bubble_registry,
-    FeaturePromoSnoozeService* snooze_service,
+    FeaturePromoStorageService* storage_service,
     TutorialService* tutorial_service)
     : registry_(registry),
       feature_engagement_tracker_(feature_engagement_tracker),
       bubble_factory_registry_(help_bubble_registry),
-      snooze_service_(snooze_service),
+      storage_service_(storage_service),
       tutorial_service_(tutorial_service) {
   DCHECK(feature_engagement_tracker_);
   DCHECK(bubble_factory_registry_);
-  DCHECK(snooze_service_);
+  DCHECK(storage_service_);
 }
 
 FeaturePromoControllerCommon::~FeaturePromoControllerCommon() {
@@ -97,11 +65,9 @@
 
   const FeaturePromoSpecification* spec =
       registry()->GetParamsForFeature(iph_feature);
-  if (!spec)
+  if (!spec) {
     return false;
-
-  DCHECK_EQ(&iph_feature, spec->feature());
-  DCHECK(spec->anchor_element_id());
+  }
 
   // Fetch the anchor element. For now, assume all elements are Views.
   ui::TrackedElement* const anchor_element =
@@ -111,8 +77,8 @@
     return false;
 
   return MaybeShowPromoFromSpecification(
-      *spec, anchor_element, std::move(close_callback), std::move(body_params),
-      std::move(title_params));
+      *spec, /* for_demo =*/false, anchor_element, std::move(close_callback),
+      std::move(body_params), std::move(title_params));
 }
 
 bool FeaturePromoControllerCommon::MaybeShowStartupPromo(
@@ -122,8 +88,9 @@
     FeaturePromoSpecification::FormatParameters body_params,
     FeaturePromoSpecification::FormatParameters title_params) {
   // If the promo is currently running, fail.
-  if (current_iph_feature_ == &iph_feature)
+  if (GetCurrentPromoFeature() == &iph_feature) {
     return false;
+  }
 
   // If the promo is already queued, fail.
   if (base::Contains(startup_promos_, &iph_feature))
@@ -148,39 +115,49 @@
     BubbleCloseCallback close_callback,
     FeaturePromoSpecification::FormatParameters body_params,
     FeaturePromoSpecification::FormatParameters title_params) {
-  if (current_iph_feature_ && promo_bubble_) {
-    EndPromo(*current_iph_feature_,
-             static_cast<int>(
-                 internal::FeaturePromoCloseReasonInternal::kOverrideForDemo));
-  }
-  iph_feature_bypassing_tracker_ = iph_feature;
-
-  bool showed_promo =
-      MaybeShowPromo(*iph_feature, std::move(close_callback),
-                     std::move(body_params), std::move(title_params));
-
-  if (!showed_promo && iph_feature_bypassing_tracker_) {
-    iph_feature_bypassing_tracker_ = nullptr;
+  if (current_promo_) {
+    EndPromo(*GetCurrentPromoFeature(), CloseReason::kOverrideForDemo);
   }
 
-  return showed_promo;
+  const FeaturePromoSpecification* spec =
+      registry()->GetParamsForFeature(*iph_feature);
+  if (!spec) {
+    return false;
+  }
+
+  // Fetch the anchor element. For now, assume all elements are Views.
+  ui::TrackedElement* const anchor_element =
+      spec->GetAnchorElement(GetAnchorContext());
+
+  if (!anchor_element) {
+    return false;
+  }
+
+  return MaybeShowPromoFromSpecification(
+      *spec, /* for_demo =*/true, anchor_element, std::move(close_callback),
+      std::move(body_params), std::move(title_params));
 }
 
 bool FeaturePromoControllerCommon::MaybeShowPromoFromSpecification(
     const FeaturePromoSpecification& spec,
+    bool for_demo,
     ui::TrackedElement* anchor_element,
     BubbleCloseCallback close_callback,
     FeaturePromoSpecification::FormatParameters body_params,
     FeaturePromoSpecification::FormatParameters title_params) {
   CHECK(anchor_element);
 
-  if (promos_blocked_for_testing_) {
-    return false;
-  }
+  const bool is_high_priority =
+      spec.promo_subtype() ==
+      FeaturePromoSpecification::PromoSubtype::kLegalNotice;
+  const bool high_priority_already_showing =
+      critical_promo_bubble_ ||
+      (current_promo_ &&
+       current_promo_->promo_subtype() ==
+           FeaturePromoSpecification::PromoSubtype::kLegalNotice);
 
-  // A normal promo cannot show if a critical promo is displayed. These
-  // are not registered with |tracker_| so check here.
-  if (critical_promo_bubble_) {
+  // A normal promo cannot show if a high priority promo is displayed.
+  if (high_priority_already_showing) {
     return false;
   }
 
@@ -189,8 +166,20 @@
     return false;
   }
 
-  // Can't show a standard promo if another help bubble is visible.
-  if (bubble_factory_registry_->is_any_bubble_showing()) {
+  if (is_high_priority) {
+    // For high priority promos, close any other promos or help bubbles in this
+    // context.
+    if (const auto* current = GetCurrentPromoFeature()) {
+      EndPromo(*current, CloseReason::kOverrideForPrecedence);
+    }
+    if (auto* help_bubble = bubble_factory_registry_->GetHelpBubble(
+            anchor_element->context())) {
+      help_bubble->Close();
+    }
+  } else if (current_promo_ ||
+             bubble_factory_registry_->is_any_bubble_showing()) {
+    // Can't show a standard promo if another IPH is running or another help
+    // bubble is visible.
     return false;
   }
 
@@ -198,11 +187,12 @@
   // trigger the bubble if possible. Put any checks that should be bypassed in
   // demo mode in this block.
   const base::Feature* const feature = spec.feature();
-  const bool feature_is_bypassing_tracker =
-      feature == iph_feature_bypassing_tracker_;
-  const bool is_demo_mode =
+  const bool in_demo_mode =
       base::FeatureList::IsEnabled(feature_engagement::kIPHDemoMode);
-  if (!is_demo_mode && !feature_is_bypassing_tracker) {
+  auto lifecycle = std::make_unique<FeaturePromoLifecycle>(
+      storage_service_, GetAppId(), feature, spec.promo_type(),
+      spec.promo_subtype());
+  if (!for_demo && !in_demo_mode) {
     // When not bypassing the normal gating systems, don't try to show promos
     // for disabled features. This prevents us from calling into the Feature
     // Engagement tracker more times than necessary, emitting unnecessary
@@ -210,34 +200,34 @@
     if (!base::FeatureList::IsEnabled(*feature)) {
       return false;
     }
-    // Don't try to show promos on snooze cooldown or that have hit maximum
-    // snooze count.
-    if (snooze_service_->IsBlocked(*feature)) {
+
+    if (!lifecycle->CanShow()) {
       return false;
     }
   }
 
   // TODO(crbug.com/1258216): Currently this must be called before
   // ShouldTriggerHelpUI() below. See bug for details.
-  const bool screen_reader_available = CheckScreenReaderPromptAvailable();
+  const bool screen_reader_available =
+      CheckScreenReaderPromptAvailable(for_demo || in_demo_mode);
 
-  if (!feature_is_bypassing_tracker &&
+  if (!for_demo &&
       !feature_engagement_tracker_->ShouldTriggerHelpUI(*feature)) {
     return false;
   }
 
   // If the tracker says we should trigger, but we have a promo
   // currently showing, there is a bug somewhere in here.
-  DCHECK(!current_iph_feature_);
-  current_iph_feature_ = feature;
+  DCHECK(!current_promo_);
+  current_promo_ = std::move(lifecycle);
 
   // Try to show the bubble and bail out if we cannot.
-  promo_bubble_ = ShowPromoBubbleImpl(
+  auto bubble = ShowPromoBubbleImpl(
       spec, anchor_element, std::move(body_params), std::move(title_params),
       screen_reader_available, /* is_critical_promo =*/false);
-  if (!promo_bubble_) {
-    current_iph_feature_ = nullptr;
-    if (!feature_is_bypassing_tracker) {
+  if (!bubble) {
+    current_promo_.reset();
+    if (!for_demo) {
       feature_engagement_tracker_->Dismissed(*feature);
     }
     return false;
@@ -245,8 +235,11 @@
 
   bubble_closed_callback_ = std::move(close_callback);
 
-  if (!feature_is_bypassing_tracker) {
-    snooze_service_->OnPromoShown(*feature);
+  if (for_demo) {
+    current_promo_->OnPromoShownForDemo(std::move(bubble));
+  } else {
+    current_promo_->OnPromoShown(std::move(bubble),
+                                 feature_engagement_tracker_);
   }
 
   return true;
@@ -257,20 +250,15 @@
     ui::TrackedElement* anchor_element,
     FeaturePromoSpecification::FormatParameters body_params,
     FeaturePromoSpecification::FormatParameters title_params) {
-  if (promos_blocked_for_testing_)
-    return nullptr;
-
   // Don't preempt an existing critical promo.
   if (critical_promo_bubble_)
     return nullptr;
 
   // If a normal bubble is showing, close it. Won't affect a promo continued
   // after its bubble has closed.
-  if (current_iph_feature_)
-    EndPromo(
-        *current_iph_feature_,
-        static_cast<int>(
-            internal::FeaturePromoCloseReasonInternal::kOverrideForPrecedence));
+  if (const auto* current = GetCurrentPromoFeature()) {
+    EndPromo(*current, CloseReason::kOverrideForPrecedence);
+  }
 
   // Snooze and tutorial are not supported for critical promos.
   DCHECK_NE(FeaturePromoSpecification::PromoType::kSnooze, spec.promo_type());
@@ -279,7 +267,8 @@
   // Create the bubble.
   auto bubble = ShowPromoBubbleImpl(
       spec, anchor_element, std::move(body_params), std::move(title_params),
-      CheckScreenReaderPromptAvailable(), /* is_critical_promo =*/true);
+      CheckScreenReaderPromptAvailable(/* for_demo =*/false),
+      /* is_critical_promo =*/true);
   critical_promo_bubble_ = bubble.get();
   return bubble;
 }
@@ -288,13 +277,41 @@
     const base::Feature& iph_feature) const {
   if (base::Contains(startup_promos_, &iph_feature))
     return FeaturePromoStatus::kQueuedForStartup;
-  if (current_iph_feature_ != &iph_feature)
+  if (GetCurrentPromoFeature() != &iph_feature) {
     return FeaturePromoStatus::kNotRunning;
-  return (promo_bubble_ && promo_bubble_->is_open())
+  }
+  return current_promo_->is_bubble_visible()
              ? FeaturePromoStatus::kBubbleShowing
              : FeaturePromoStatus::kContinued;
 }
 
+bool FeaturePromoControllerCommon::HasPromoBeenDismissed(
+    const base::Feature& iph_feature,
+    CloseReason* last_close_reason) const {
+  const FeaturePromoSpecification* spec =
+      registry()->GetParamsForFeature(iph_feature);
+  if (!spec) {
+    return false;
+  }
+
+  const auto data = storage_service()->ReadPromoData(iph_feature);
+  if (!data) {
+    return false;
+  }
+
+  if (last_close_reason) {
+    *last_close_reason = data->last_dismissed_by;
+  }
+
+  switch (spec->promo_subtype()) {
+    case user_education::FeaturePromoSpecification::PromoSubtype::kNormal:
+    case user_education::FeaturePromoSpecification::PromoSubtype::kLegalNotice:
+      return data->is_dismissed;
+    case user_education::FeaturePromoSpecification::PromoSubtype::kPerApp:
+      return base::Contains(data->shown_for_apps, GetAppId());
+  }
+}
+
 bool FeaturePromoControllerCommon::EndPromo(
     const base::Feature& iph_feature,
     FeaturePromoCloseReason close_reason) {
@@ -302,13 +319,13 @@
   // FeaturePromoCloseReasonInternal and call private method.
   auto close_reason_internal =
       close_reason == FeaturePromoCloseReason::kFeatureEngaged
-          ? internal::FeaturePromoCloseReasonInternal::kFeatureEngaged
-          : internal::FeaturePromoCloseReasonInternal::kAbortPromo;
-  return EndPromo(iph_feature, static_cast<int>(close_reason_internal));
+          ? CloseReason::kFeatureEngaged
+          : CloseReason::kAbortPromo;
+  return EndPromo(iph_feature, close_reason_internal);
 }
 
 bool FeaturePromoControllerCommon::EndPromo(const base::Feature& iph_feature,
-                                            int close_reason) {
+                                            CloseReason close_reason) {
   const auto it = startup_promos_.find(&iph_feature);
   if (it != startup_promos_.end()) {
     std::move(it->second).Run(iph_feature, false);
@@ -316,97 +333,41 @@
     return true;
   }
 
-  if (current_iph_feature_ != &iph_feature)
+  if (GetCurrentPromoFeature() != &iph_feature) {
     return false;
+  }
 
-  const bool was_open = promo_bubble_ && promo_bubble_->is_open();
-  if (promo_bubble_) {
-    promo_bubble_->Close();
-    RecordPromoAction(iph_feature, close_reason);
-  }
-  if (!continuing_after_bubble_closed_ &&
-      iph_feature_bypassing_tracker_ == &iph_feature) {
-    iph_feature_bypassing_tracker_ = nullptr;
-  }
+  const bool was_open = current_promo_->is_bubble_visible();
+  current_promo_->OnPromoEnded(close_reason);
+  current_promo_.reset();
   return was_open;
 }
 
-void FeaturePromoControllerCommon::RecordPromoAction(
-    const base::Feature& iph_feature,
-    int action) {
-  std::string action_name = "UserEducation.MessageAction.";
-
-  switch (static_cast<internal::FeaturePromoCloseReasonInternal>(action)) {
-    case internal::FeaturePromoCloseReasonInternal::kDismiss:
-      action_name.append("Dismiss");
-      break;
-    case internal::FeaturePromoCloseReasonInternal::kSnooze:
-      action_name.append("Snooze");
-      break;
-    case internal::FeaturePromoCloseReasonInternal::kAction:
-      action_name.append("Action");
-      break;
-    case internal::FeaturePromoCloseReasonInternal::kCancel:
-      action_name.append("Cancel");
-      break;
-    case internal::FeaturePromoCloseReasonInternal::kTimeout:
-      action_name.append("Timeout");
-      break;
-    case internal::FeaturePromoCloseReasonInternal::kAbortPromo:
-      action_name.append("Abort");
-      break;
-    case internal::FeaturePromoCloseReasonInternal::kFeatureEngaged:
-      action_name.append("FeatureEngaged");
-      break;
-    case internal::FeaturePromoCloseReasonInternal::
-        kOverrideForUIRegionConflict:
-      action_name.append("OverrideForUIRegionConflict");
-      break;
-    case internal::FeaturePromoCloseReasonInternal::kOverrideForPrecedence:
-      action_name.append("OverrideForPrecedence");
-      break;
-    case internal::FeaturePromoCloseReasonInternal::kOverrideForDemo:
-      // Not used for metrics.
-      return;
-    case internal::FeaturePromoCloseReasonInternal::kOverrideForTesting:
-      // Not used for metrics.
-      return;
-  }
-  action_name.append(".");
-  action_name.append(iph_feature.name);
-  // Record the user action.
-  base::RecordComputedAction(action_name);
-
-  // Record the histogram.
-  std::string histogram_name =
-      std::string("UserEducation.MessageAction.").append(iph_feature.name);
-  base::UmaHistogramEnumeration(
-      histogram_name,
-      static_cast<internal::FeaturePromoCloseReasonInternal>(action));
-}
-
 bool FeaturePromoControllerCommon::DismissNonCriticalBubbleInRegion(
     const gfx::Rect& screen_bounds) {
-  if (promo_bubble_ && promo_bubble_->is_open() &&
-      promo_bubble_->GetBoundsInScreen().Intersects(screen_bounds)) {
-    const bool result =
-        EndPromo(*current_iph_feature_,
-                 static_cast<int>(internal::FeaturePromoCloseReasonInternal::
-                                      kOverrideForUIRegionConflict));
-    DCHECK(result);
-    return result;
+  const auto* const bubble = promo_bubble();
+  if (!bubble || !bubble->is_open() ||
+      !bubble->GetBoundsInScreen().Intersects(screen_bounds)) {
+    return false;
   }
-  return false;
+  const bool result = EndPromo(*current_promo_->iph_feature(),
+                               CloseReason::kOverrideForUIRegionConflict);
+  DCHECK(result);
+  return result;
 }
 
 FeaturePromoHandle FeaturePromoControllerCommon::CloseBubbleAndContinuePromo(
     const base::Feature& iph_feature) {
-  DCHECK_EQ(current_iph_feature_, &iph_feature);
-  continuing_after_bubble_closed_ = true;
-  const bool result = EndPromo(
-      iph_feature,
-      static_cast<int>(internal::FeaturePromoCloseReasonInternal::kAction));
-  DCHECK(result);
+  return CloseBubbleAndContinuePromoWithReason(iph_feature,
+                                               CloseReason::kFeatureEngaged);
+}
+
+FeaturePromoHandle
+FeaturePromoControllerCommon::CloseBubbleAndContinuePromoWithReason(
+    const base::Feature& iph_feature,
+    CloseReason close_reason) {
+  DCHECK_EQ(GetCurrentPromoFeature(), &iph_feature);
+  current_promo_->OnPromoEnded(close_reason, /*continue_promo=*/true);
   return FeaturePromoHandle(GetAsWeakPtr(), &iph_feature);
 }
 
@@ -415,18 +376,8 @@
   return weak_ptr_factory_.GetWeakPtr();
 }
 
-FeaturePromoControllerCommon::TestLock
-FeaturePromoControllerCommon::BlockPromosForTesting() {
-  if (current_iph_feature_)
-    EndPromo(
-        *current_iph_feature_,
-        static_cast<int>(
-            internal::FeaturePromoCloseReasonInternal::kOverrideForTesting));
-  return std::make_unique<base::AutoReset<bool>>(&promos_blocked_for_testing_,
-                                                 true);
-}
-
-bool FeaturePromoControllerCommon::CheckScreenReaderPromptAvailable() const {
+bool FeaturePromoControllerCommon::CheckScreenReaderPromptAvailable(
+    bool for_demo) const {
   if (!ui::AXPlatformNode::GetAccessibilityMode().has_mode(
           ui::AXMode::kScreenReader)) {
     return false;
@@ -436,15 +387,16 @@
   // without querying the FE backend, since the backend will return false for
   // all promos other than the one that's being demoed. If we didn't have this
   // code the screen reader prompt would never play.
-  if (base::FeatureList::IsEnabled(feature_engagement::kIPHDemoMode) ||
-      iph_feature_bypassing_tracker_)
+  if (for_demo) {
     return true;
+  }
 
   const base::Feature* const prompt_feature =
       GetScreenReaderPromptPromoFeature();
   if (!prompt_feature ||
-      !feature_engagement_tracker_->ShouldTriggerHelpUI(*prompt_feature))
+      !feature_engagement_tracker_->ShouldTriggerHelpUI(*prompt_feature)) {
     return false;
+  }
 
   // TODO(crbug.com/1258216): Once we have our answer, immediately dismiss
   // so that this doesn't interfere with actually showing the bubble. This
@@ -520,18 +472,22 @@
   if (spec.feature()) {
     create_params.dismiss_callback = base::BindOnce(
         &FeaturePromoControllerCommon::OnHelpBubbleDismissed,
-        weak_ptr_factory_.GetWeakPtr(), base::Unretained(spec.feature()));
+        weak_ptr_factory_.GetWeakPtr(), base::Unretained(spec.feature()),
+        /* via_action_button =*/false);
   }
 
+  const bool can_snooze =
+      spec.promo_subtype() == FeaturePromoSpecification::PromoSubtype::kNormal;
   switch (spec.promo_type()) {
     case FeaturePromoSpecification::PromoType::kSnooze:
       CHECK(spec.feature());
+      CHECK(can_snooze);
       create_params.buttons = CreateSnoozeButtons(*spec.feature());
       break;
     case FeaturePromoSpecification::PromoType::kTutorial:
       CHECK(spec.feature());
-      create_params.buttons =
-          CreateTutorialButtons(*spec.feature(), spec.tutorial_id());
+      create_params.buttons = CreateTutorialButtons(*spec.feature(), can_snooze,
+                                                    spec.tutorial_id());
       create_params.dismiss_callback = base::BindOnce(
           &FeaturePromoControllerCommon::OnTutorialHelpBubbleDismissed,
           weak_ptr_factory_.GetWeakPtr(), base::Unretained(spec.feature()),
@@ -553,7 +509,7 @@
   bool had_screen_reader_promo = false;
   if (spec.promo_type() == FeaturePromoSpecification::PromoType::kTutorial) {
     create_params.keyboard_navigation_hint = GetTutorialScreenReaderHint();
-  } else if (CheckScreenReaderPromptAvailable()) {
+  } else if (screen_reader_prompt_available) {
     create_params.keyboard_navigation_hint = GetFocusHelpBubbleScreenReaderHint(
         spec.promo_type(), anchor_element, is_critical_promo);
     had_screen_reader_promo = !create_params.keyboard_navigation_hint.empty();
@@ -582,14 +538,9 @@
 
 void FeaturePromoControllerCommon::FinishContinuedPromo(
     const base::Feature& iph_feature) {
-  DCHECK(continuing_after_bubble_closed_);
-  if (iph_feature_bypassing_tracker_ != &iph_feature)
-    feature_engagement_tracker_->Dismissed(iph_feature);
-  else
-    iph_feature_bypassing_tracker_ = nullptr;
-  if (current_iph_feature_ == &iph_feature) {
-    current_iph_feature_ = nullptr;
-    continuing_after_bubble_closed_ = false;
+  if (GetCurrentPromoFeature() == &iph_feature) {
+    current_promo_->OnContinuedPromoEnded(/*completed_successfully=*/true);
+    current_promo_.reset();
   }
 }
 
@@ -600,58 +551,56 @@
   // it.
   if (bubble == critical_promo_bubble_) {
     critical_promo_bubble_ = nullptr;
-  } else if (bubble == promo_bubble_.get()) {
-    if (!continuing_after_bubble_closed_) {
-      if (iph_feature_bypassing_tracker_.get() != current_iph_feature_)
-        feature_engagement_tracker_->Dismissed(*current_iph_feature_);
-      else
-        iph_feature_bypassing_tracker_ = nullptr;
-      current_iph_feature_ = nullptr;
+  } else if (bubble == promo_bubble()) {
+    if (current_promo_->OnPromoBubbleClosed()) {
+      current_promo_.reset();
     }
-    promo_bubble_.reset();
-  } else {
-    NOTREACHED();
   }
 
-  if (bubble_closed_callback_)
+  if (bubble_closed_callback_) {
     std::move(bubble_closed_callback_).Run();
+  }
+}
+
+void FeaturePromoControllerCommon::OnHelpBubbleTimedOut(
+    const base::Feature* feature) {
+  if (feature == GetCurrentPromoFeature()) {
+    current_promo_->OnPromoEnded(CloseReason::kTimeout);
+    current_promo_.reset();
+  }
 }
 
 void FeaturePromoControllerCommon::OnHelpBubbleSnoozed(
     const base::Feature* feature) {
-  if (iph_feature_bypassing_tracker_ != feature) {
-    RecordPromoAction(
-        *feature,
-        static_cast<int>(internal::FeaturePromoCloseReasonInternal::kSnooze));
-    snooze_service_->OnUserSnooze(*feature);
+  if (feature == GetCurrentPromoFeature()) {
+    current_promo_->OnPromoEnded(CloseReason::kSnooze);
+    current_promo_.reset();
+  }
+}
+
+void FeaturePromoControllerCommon::OnHelpBubbleDismissed(
+    const base::Feature* feature,
+    bool via_action_button) {
+  if (feature == GetCurrentPromoFeature()) {
+    current_promo_->OnPromoEnded(via_action_button ? CloseReason::kDismiss
+                                                   : CloseReason::kCancel);
+    current_promo_.reset();
   }
 }
 
 void FeaturePromoControllerCommon::OnHelpBubbleTimeout(
     const base::Feature* feature) {
-  if (iph_feature_bypassing_tracker_ != feature) {
-    RecordPromoAction(
-        *feature,
-        static_cast<int>(internal::FeaturePromoCloseReasonInternal::kTimeout));
-  }
-}
-
-void FeaturePromoControllerCommon::OnHelpBubbleDismissed(
-    const base::Feature* feature) {
-  if (iph_feature_bypassing_tracker_ != feature) {
-    RecordPromoAction(
-        *feature,
-        static_cast<int>(internal::FeaturePromoCloseReasonInternal::kDismiss));
-    if (snooze_service_) {
-      snooze_service_->OnUserDismiss(*feature);
-    }
+  if (feature == GetCurrentPromoFeature()) {
+    current_promo_->OnPromoEnded(CloseReason::kTimeout);
+    current_promo_.reset();
   }
 }
 
 void FeaturePromoControllerCommon::OnCustomAction(
     const base::Feature* feature,
     FeaturePromoSpecification::CustomActionCallback callback) {
-  callback.Run(GetAnchorContext(), CloseBubbleAndContinuePromo(*feature));
+  callback.Run(GetAnchorContext(), CloseBubbleAndContinuePromoWithReason(
+                                       *feature, CloseReason::kAction));
 }
 
 void FeaturePromoControllerCommon::OnTutorialHelpBubbleSnoozed(
@@ -664,45 +613,47 @@
 void FeaturePromoControllerCommon::OnTutorialHelpBubbleDismissed(
     const base::Feature* iph_feature,
     TutorialIdentifier tutorial_id) {
-  OnHelpBubbleDismissed(iph_feature);
+  OnHelpBubbleDismissed(iph_feature,
+                        /* via_action_button =*/true);
   tutorial_service_->LogIPHLinkClicked(tutorial_id, false);
 }
 
 void FeaturePromoControllerCommon::OnTutorialStarted(
     const base::Feature* iph_feature,
     TutorialIdentifier tutorial_id) {
-  if (!promo_bubble_ || !promo_bubble_->is_open()) {
-    NOTREACHED();
-  } else {
-    DCHECK_EQ(current_iph_feature_, iph_feature);
-    tutorial_promo_handle_ = CloseBubbleAndContinuePromo(*iph_feature);
-    DCHECK(tutorial_promo_handle_.is_valid());
-    tutorial_service_->StartTutorial(
-        tutorial_id, GetAnchorContext(),
-        base::BindOnce(&FeaturePromoControllerCommon::OnTutorialComplete,
-                       weak_ptr_factory_.GetWeakPtr(),
-                       base::Unretained(iph_feature)),
-        base::BindOnce(&FeaturePromoControllerCommon::OnTutorialAborted,
-                       weak_ptr_factory_.GetWeakPtr(),
-                       base::Unretained(iph_feature)));
-    if (tutorial_service_->IsRunningTutorial()) {
-      tutorial_service_->LogIPHLinkClicked(tutorial_id, true);
-    }
+  DCHECK_EQ(GetCurrentPromoFeature(), iph_feature);
+  tutorial_promo_handle_ =
+      CloseBubbleAndContinuePromoWithReason(*iph_feature, CloseReason::kAction);
+  DCHECK(tutorial_promo_handle_.is_valid());
+  tutorial_service_->StartTutorial(
+      tutorial_id, GetAnchorContext(),
+      base::BindOnce(&FeaturePromoControllerCommon::OnTutorialComplete,
+                     weak_ptr_factory_.GetWeakPtr(),
+                     base::Unretained(iph_feature)),
+      base::BindOnce(&FeaturePromoControllerCommon::OnTutorialAborted,
+                     weak_ptr_factory_.GetWeakPtr(),
+                     base::Unretained(iph_feature)));
+  if (tutorial_service_->IsRunningTutorial()) {
+    tutorial_service_->LogIPHLinkClicked(tutorial_id, true);
   }
 }
 
 void FeaturePromoControllerCommon::OnTutorialComplete(
     const base::Feature* iph_feature) {
   tutorial_promo_handle_.Release();
-  if (snooze_service_)
-    snooze_service_->OnUserDismiss(*iph_feature);
+  if (GetCurrentPromoFeature() == iph_feature) {
+    current_promo_->OnContinuedPromoEnded(/*completed_successfully=*/true);
+    current_promo_.reset();
+  }
 }
 
 void FeaturePromoControllerCommon::OnTutorialAborted(
     const base::Feature* iph_feature) {
   tutorial_promo_handle_.Release();
-  if (snooze_service_)
-    snooze_service_->OnUserSnooze(*iph_feature);
+  if (GetCurrentPromoFeature() == iph_feature) {
+    current_promo_->OnContinuedPromoEnded(/*completed_successfully=*/false);
+    current_promo_.reset();
+  }
 }
 
 std::vector<HelpBubbleButtonParams>
@@ -710,20 +661,21 @@
     const base::Feature& feature) {
   std::vector<HelpBubbleButtonParams> buttons;
 
-  HelpBubbleButtonParams snooze_button;
-  snooze_button.text = l10n_util::GetStringUTF16(IDS_PROMO_SNOOZE_BUTTON);
-  snooze_button.is_default = false;
-  snooze_button.callback = base::BindOnce(
+  HelpBubbleButtonParams storage_button;
+  storage_button.text = l10n_util::GetStringUTF16(IDS_PROMO_SNOOZE_BUTTON);
+  storage_button.is_default = false;
+  storage_button.callback = base::BindOnce(
       &FeaturePromoControllerCommon::OnHelpBubbleSnoozed,
       weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature));
-  buttons.push_back(std::move(snooze_button));
+  buttons.push_back(std::move(storage_button));
 
   HelpBubbleButtonParams dismiss_button;
   dismiss_button.text = l10n_util::GetStringUTF16(IDS_PROMO_DISMISS_BUTTON);
   dismiss_button.is_default = true;
-  dismiss_button.callback = base::BindOnce(
-      &FeaturePromoControllerCommon::OnHelpBubbleDismissed,
-      weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature));
+  dismiss_button.callback =
+      base::BindOnce(&FeaturePromoControllerCommon::OnHelpBubbleDismissed,
+                     weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature),
+                     /* via_action_button =*/true);
   buttons.push_back(std::move(dismiss_button));
 
   return buttons;
@@ -752,9 +704,10 @@
   dismiss_button.text =
       l10n_util::GetStringUTF16(custom_action_dismiss_string_id);
   dismiss_button.is_default = !custom_action_is_default;
-  dismiss_button.callback = base::BindOnce(
-      &FeaturePromoControllerCommon::OnHelpBubbleDismissed,
-      weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature));
+  dismiss_button.callback =
+      base::BindOnce(&FeaturePromoControllerCommon::OnHelpBubbleDismissed,
+                     weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature),
+                     /* via_action_button =*/true);
   buttons.push_back(std::move(dismiss_button));
 
   return buttons;
@@ -763,16 +716,26 @@
 std::vector<HelpBubbleButtonParams>
 FeaturePromoControllerCommon::CreateTutorialButtons(
     const base::Feature& feature,
+    bool can_snooze,
     TutorialIdentifier tutorial_id) {
   std::vector<HelpBubbleButtonParams> buttons;
 
-  HelpBubbleButtonParams snooze_button;
-  snooze_button.text = l10n_util::GetStringUTF16(IDS_PROMO_SNOOZE_BUTTON);
-  snooze_button.is_default = false;
-  snooze_button.callback = base::BindRepeating(
-      &FeaturePromoControllerCommon::OnTutorialHelpBubbleSnoozed,
-      weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature), tutorial_id);
-  buttons.push_back(std::move(snooze_button));
+  HelpBubbleButtonParams dismiss_button;
+  dismiss_button.is_default = false;
+  if (can_snooze) {
+    dismiss_button.text = l10n_util::GetStringUTF16(IDS_PROMO_SNOOZE_BUTTON);
+    dismiss_button.callback = base::BindRepeating(
+        &FeaturePromoControllerCommon::OnTutorialHelpBubbleSnoozed,
+        weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature),
+        tutorial_id);
+  } else {
+    dismiss_button.text = l10n_util::GetStringUTF16(IDS_PROMO_DISMISS_BUTTON);
+    dismiss_button.callback = base::BindRepeating(
+        &FeaturePromoControllerCommon::OnTutorialHelpBubbleDismissed,
+        weak_ptr_factory_.GetWeakPtr(), base::Unretained(&feature),
+        tutorial_id);
+  }
+  buttons.push_back(std::move(dismiss_button));
 
   HelpBubbleButtonParams tutorial_button;
   tutorial_button.text =
@@ -786,6 +749,11 @@
   return buttons;
 }
 
+const base::Feature* FeaturePromoControllerCommon::GetCurrentPromoFeature()
+    const {
+  return current_promo_ ? current_promo_->iph_feature() : nullptr;
+}
+
 // static
 bool FeaturePromoControllerCommon::active_window_check_blocked_ = false;
 
diff --git a/components/user_education/common/feature_promo_controller.h b/components/user_education/common/feature_promo_controller.h
index 9fcc8257..33f4b3f 100644
--- a/components/user_education/common/feature_promo_controller.h
+++ b/components/user_education/common/feature_promo_controller.h
@@ -20,8 +20,10 @@
 #include "base/memory/weak_ptr.h"
 #include "components/feature_engagement/public/tracker.h"
 #include "components/user_education/common/feature_promo_handle.h"
+#include "components/user_education/common/feature_promo_lifecycle.h"
 #include "components/user_education/common/feature_promo_registry.h"
 #include "components/user_education/common/feature_promo_specification.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
 #include "components/user_education/common/help_bubble.h"
 #include "components/user_education/common/help_bubble_params.h"
 #include "components/user_education/common/tutorial_identifier.h"
@@ -33,11 +35,10 @@
 
 // Declaring these in the global namespace for testing purposes.
 class BrowserFeaturePromoControllerTest;
-class FeaturePromoSnoozeInteractiveTest;
+class FeaturePromoLifecycleUiTest;
 
 namespace user_education {
 
-class FeaturePromoSnoozeService;
 class HelpBubbleFactoryRegistry;
 class TutorialService;
 
@@ -141,6 +142,15 @@
   virtual FeaturePromoStatus GetPromoStatus(
       const base::Feature& iph_feature) const = 0;
 
+  // Returns whether a particular promo has previously been dismissed.
+  // Useful in cases where determining if a promo should show could be
+  // expensive. If `last_close_reason` is set, and the promo has been
+  // dismissed, it wil be populated with the most recent close reason.
+  // (The value is undefined if this method returns false.)
+  virtual bool HasPromoBeenDismissed(const base::Feature& iph_feature,
+                                     FeaturePromoStorageService::CloseReason*
+                                         last_close_reason = nullptr) const = 0;
+
   // Returns whether the promo for `iph_feature` matches kBubbleShowing or any
   // of `additional_status`.
   template <typename... Args>
@@ -200,7 +210,7 @@
       feature_engagement::Tracker* feature_engagement_tracker,
       FeaturePromoRegistry* registry,
       HelpBubbleFactoryRegistry* help_bubble_registry,
-      FeaturePromoSnoozeService* snooze_service,
+      FeaturePromoStorageService* storage_service,
       TutorialService* tutorial_service);
   ~FeaturePromoControllerCommon() override;
 
@@ -224,13 +234,6 @@
   // if a bubble is closed as a result.
   bool DismissNonCriticalBubbleInRegion(const gfx::Rect& screen_bounds);
 
-  // Records user actions and histograms that discern what action was
-  // taken to close a promotion.
-  void RecordPromoAction(const base::Feature& iph_feature, int action);
-
-  // Blocks further promos and closes any existing non-critical ones.
-  [[nodiscard]] TestLock BlockPromosForTesting();
-
   // Returns the associated feature engagement tracker.
   feature_engagement::Tracker* feature_engagement_tracker() {
     return feature_engagement_tracker_;
@@ -253,6 +256,9 @@
           FeaturePromoSpecification::NoSubstitution()) override;
   FeaturePromoStatus GetPromoStatus(
       const base::Feature& iph_feature) const override;
+  bool HasPromoBeenDismissed(const base::Feature& iph_feature,
+                             FeaturePromoStorageService::CloseReason*
+                                 close_reason = nullptr) const override;
   bool MaybeShowPromoForDemoPage(
       const base::Feature* iph_feature,
       BubbleCloseCallback close_callback = base::DoNothing(),
@@ -263,7 +269,7 @@
   bool EndPromo(const base::Feature& iph_feature,
                 FeaturePromoCloseReason close_reason) override;
   FeaturePromoHandle CloseBubbleAndContinuePromo(
-      const base::Feature& iph_feature) override;
+      const base::Feature& iph_feature) final;
   base::WeakPtr<FeaturePromoController> GetAsWeakPtr() override;
 
   HelpBubbleFactoryRegistry* bubble_factory_registry() {
@@ -285,25 +291,36 @@
 
  protected:
   friend BrowserFeaturePromoControllerTest;
-  friend FeaturePromoSnoozeInteractiveTest;
+  friend FeaturePromoLifecycleUiTest;
 
   // For IPH not registered with |FeaturePromoRegistry|. Only use this
   // if it is infeasible to pre-register your IPH.
   bool MaybeShowPromoFromSpecification(
       const FeaturePromoSpecification& spec,
+      bool for_demo,
       ui::TrackedElement* anchor_element,
       BubbleCloseCallback close_callback,
       FeaturePromoSpecification::FormatParameters body_params,
       FeaturePromoSpecification::FormatParameters title_params);
 
-  FeaturePromoSnoozeService* snooze_service() { return snooze_service_; }
-  HelpBubble* promo_bubble() { return promo_bubble_.get(); }
-  const HelpBubble* promo_bubble() const { return promo_bubble_.get(); }
+  const FeaturePromoStorageService* storage_service() const {
+    return storage_service_;
+  }
+  FeaturePromoStorageService* storage_service() { return storage_service_; }
+  HelpBubble* promo_bubble() {
+    return current_promo_ ? current_promo_->help_bubble() : nullptr;
+  }
+  const HelpBubble* promo_bubble() const {
+    return current_promo_ ? current_promo_->help_bubble() : nullptr;
+  }
   HelpBubble* critical_promo_bubble() { return critical_promo_bubble_; }
   const HelpBubble* critical_promo_bubble() const {
     return critical_promo_bubble_;
   }
 
+  // Get the current app ID, if this is an app, empty otherwise.
+  virtual std::string GetAppId() const = 0;
+
   // Gets the context in which to locate the anchor view.
   virtual ui::ElementContext GetAnchorContext() const = 0;
 
@@ -344,6 +361,7 @@
       ui::TrackedElement* anchor_element,
       bool is_critical_promo) const = 0;
 
+  const FeaturePromoRegistry* registry() const { return registry_; }
   FeaturePromoRegistry* registry() { return registry_; }
 
   static bool active_window_check_blocked() {
@@ -351,8 +369,13 @@
   }
 
  private:
-  bool EndPromo(const base::Feature& iph_feature,
-                /* FeaturePromoCloseReasonInternal */ int close_reason);
+  using CloseReason = FeaturePromoStorageService::CloseReason;
+
+  bool EndPromo(const base::Feature& iph_feature, CloseReason close_reason);
+
+  FeaturePromoHandle CloseBubbleAndContinuePromoWithReason(
+      const base::Feature& iph_action,
+      CloseReason close_reason);
 
   // FeaturePromoController:
   void FinishContinuedPromo(const base::Feature& iph_feature) override;
@@ -364,7 +387,7 @@
   // ShouldTriggerHelpUI() to always return false if another promo is being
   // displayed. Once we have machinery to allow concurrency in the FE system
   // all of this logic can be rewritten.
-  bool CheckScreenReaderPromptAvailable() const;
+  bool CheckScreenReaderPromptAvailable(bool for_demo) const;
 
   // Handles firing async promos.
   void OnFeatureEngagementTrackerInitialized(
@@ -387,6 +410,9 @@
   // Callback that cleans up a help bubble when it is closed.
   void OnHelpBubbleClosed(HelpBubble* bubble);
 
+  // Callback when the help bubble times out.
+  void OnHelpBubbleTimedOut(const base::Feature* feature);
+
   // Callback for snoozed features.
   void OnHelpBubbleSnoozed(const base::Feature* feature);
 
@@ -399,7 +425,8 @@
 
   // Callback when a feature's help bubble is dismissed by any means other than
   // snoozing (including "OK" or "Got it!" buttons).
-  void OnHelpBubbleDismissed(const base::Feature* feature);
+  void OnHelpBubbleDismissed(const base::Feature* feature,
+                             bool via_action_button);
 
   // Callback when the dismiss button for IPH for tutorials is clicked.
   void OnTutorialHelpBubbleDismissed(const base::Feature* iph_feature,
@@ -426,6 +453,7 @@
   // Create appropriate buttons for a tutorial promo on the current platform.
   std::vector<HelpBubbleButtonParams> CreateTutorialButtons(
       const base::Feature& feature,
+      bool can_snooze,
       TutorialIdentifier tutorial_id);
 
   // Create appropriate buttons for a custom action promo.
@@ -436,16 +464,13 @@
       bool custom_action_is_default,
       int custom_action_dismiss_string_id);
 
+  const base::Feature* GetCurrentPromoFeature() const;
+
   // The feature promo registry to use.
   const raw_ptr<FeaturePromoRegistry> registry_;
 
-  // Non-null as long as a promo is showing. Corresponds to an IPH
-  // feature registered with |feature_engagement_tracker_|.
-  raw_ptr<const base::Feature> current_iph_feature_ = nullptr;
-  bool continuing_after_bubble_closed_ = false;
-
-  // The help bubble, if a feature promo bubble is showing.
-  std::unique_ptr<HelpBubble> promo_bubble_;
+  // Non-null as long as a promo is showing.
+  std::unique_ptr<FeaturePromoLifecycle> current_promo_;
 
   // Has a value if a critical promo is showing. If this has a value,
   // |current_iph_feature_| will usually be null. There is one edge case
@@ -463,18 +488,9 @@
 
   const raw_ptr<feature_engagement::Tracker> feature_engagement_tracker_;
   const raw_ptr<HelpBubbleFactoryRegistry> bubble_factory_registry_;
-  const raw_ptr<FeaturePromoSnoozeService> snooze_service_;
+  const raw_ptr<FeaturePromoStorageService> storage_service_;
   const raw_ptr<TutorialService> tutorial_service_;
 
-  // When set to true, promos will never be shown.
-  bool promos_blocked_for_testing_ = false;
-
-  // In the case where the user education demo page wants to bypass the feature
-  // engagement tracker, the current iph feature will be set and then checked
-  // against to verify the right feature is bypassing. this page is located at
-  // internals/user-education.
-  raw_ptr<const base::Feature> iph_feature_bypassing_tracker_ = nullptr;
-
   // Tracks pending startup promos that have not been canceled.
   std::map<const base::Feature*, StartupPromoCallback> startup_promos_;
 
diff --git a/components/user_education/common/feature_promo_controller_unittest.cc b/components/user_education/common/feature_promo_controller_unittest.cc
index 8965d4b..9b034df6 100644
--- a/components/user_education/common/feature_promo_controller_unittest.cc
+++ b/components/user_education/common/feature_promo_controller_unittest.cc
@@ -4,16 +4,8 @@
 
 #include "components/user_education/common/feature_promo_controller.h"
 
-#include <memory>
-
 #include "base/feature_list.h"
-#include "base/metrics/field_trial_param_associator.h"
-#include "base/test/scoped_feature_list.h"
-#include "base/test/task_environment.h"
-#include "base/time/time.h"
-#include "components/feature_engagement/public/feature_constants.h"
 #include "components/user_education/test/mock_feature_promo_controller.h"
-#include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 namespace user_education {
@@ -108,7 +100,7 @@
                                        FeaturePromoStatus::kQueuedForStartup));
 }
 
-TEST(FeaturePromoControllerTest, IsPromoShowing_QuuedWithDifferentVariations) {
+TEST(FeaturePromoControllerTest, IsPromoShowing_QueuedWithDifferentVariations) {
   StrictMock<test::MockFeaturePromoController> controller;
   EXPECT_CALL(controller, GetPromoStatus(Ref(kTestIPHFeature)))
       .WillRepeatedly(Return(FeaturePromoStatus::kQueuedForStartup));
diff --git a/components/user_education/common/feature_promo_lifecycle.cc b/components/user_education/common/feature_promo_lifecycle.cc
new file mode 100644
index 0000000..85a1ae4
--- /dev/null
+++ b/components/user_education/common/feature_promo_lifecycle.cc
@@ -0,0 +1,285 @@
+// Copyright 2023 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/user_education/common/feature_promo_lifecycle.h"
+
+#include "base/containers/contains.h"
+#include "base/metrics/histogram_functions.h"
+#include "base/metrics/user_metrics.h"
+#include "base/notreached.h"
+#include "base/time/time.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
+#include "components/user_education/common/help_bubble.h"
+
+namespace user_education {
+
+namespace {
+
+bool CanShowSnoozePromo(
+    const FeaturePromoStorageService::PromoData& promo_data) {
+  // This IPH has been dismissed by user permanently.
+  if (promo_data.is_dismissed) {
+    return false;
+  }
+
+  // This IPH is shown for the first time.
+  if (promo_data.show_count == 0) {
+    return true;
+  }
+
+  // Corruption: Snooze time is in the future.
+  if (promo_data.last_snooze_time > base::Time::Now()) {
+    return false;
+  }
+
+  // Use the snooze duration if this promo was snoozed the last time it was
+  // shown; otherwise use the default duration.
+  // TODO(dfried): Revisit this for 2.0.
+  const base::TimeDelta snooze_time =
+      (promo_data.last_snooze_time > promo_data.last_show_time)
+          ? promo_data.last_snooze_duration
+          : FeaturePromoLifecycle::kDefaultSnoozeDuration;
+
+  // The IPH was snoozed, so it shouldn't be shown again until the snooze
+  // duration ends.
+  return base::Time::Now() >= (promo_data.last_show_time + snooze_time);
+}
+
+class ScopedPromoData {
+ public:
+  ScopedPromoData(FeaturePromoStorageService* storage_service,
+                  const base::Feature* iph_feature)
+      : storage_service_(storage_service), iph_feature_(iph_feature) {
+    promo_data_ = storage_service_->ReadPromoData(*iph_feature)
+                      .value_or(FeaturePromoStorageService::PromoData());
+  }
+
+  ~ScopedPromoData() {
+    storage_service_->SavePromoData(*iph_feature_, promo_data_);
+  }
+
+  FeaturePromoStorageService::PromoData* operator->() { return &promo_data_; }
+
+ private:
+  FeaturePromoStorageService::PromoData promo_data_;
+  const raw_ptr<FeaturePromoStorageService> storage_service_;
+  const raw_ptr<const base::Feature> iph_feature_;
+};
+
+}  // namespace
+
+constexpr base::TimeDelta FeaturePromoLifecycle::kDefaultSnoozeDuration;
+
+FeaturePromoLifecycle::FeaturePromoLifecycle(
+    FeaturePromoStorageService* storage_service,
+    const base::StringPiece& app_id,
+    const base::Feature* iph_feature,
+    PromoType promo_type,
+    PromoSubtype promo_subtype)
+    : storage_service_(storage_service),
+      app_id_(app_id),
+      iph_feature_(iph_feature),
+      promo_type_(promo_type),
+      promo_subtype_(promo_subtype) {}
+
+FeaturePromoLifecycle::~FeaturePromoLifecycle() {
+  MaybeEndPromo();
+}
+
+bool FeaturePromoLifecycle::CanShow() const {
+  DCHECK(promo_subtype_ != PromoSubtype::kPerApp || !app_id_.empty());
+
+  const auto data = storage_service_->ReadPromoData(*iph_feature_);
+  if (!data.has_value()) {
+    return true;
+  }
+
+  switch (promo_subtype_) {
+    case PromoSubtype::kNormal:
+      switch (promo_type_) {
+        case PromoType::kLegacy:
+        case PromoType::kToast:
+        case PromoType::kCustomAction:
+          return true;
+        case PromoType::kSnooze:
+        case PromoType::kTutorial:
+          return CanShowSnoozePromo(*data);
+        case PromoType::kUnspecified:
+          NOTREACHED();
+          return false;
+      }
+      break;
+    case PromoSubtype::kPerApp:
+      return !base::Contains(data->shown_for_apps, app_id_);
+    case PromoSubtype::kLegalNotice:
+      return !data->is_dismissed;
+  }
+}
+
+void FeaturePromoLifecycle::OnPromoShown(
+    std::unique_ptr<HelpBubble> help_bubble,
+    feature_engagement::Tracker* tracker) {
+  CHECK(!was_started());
+  state_ = State::kRunning;
+  tracker_ = tracker;
+  help_bubble_ = std::move(help_bubble);
+  if (is_demo()) {
+    return;
+  }
+  ScopedPromoData data(storage_service_, iph_feature_);
+  ++data->show_count;
+  data->last_show_time = base::Time::Now();
+}
+
+void FeaturePromoLifecycle::OnPromoShownForDemo(
+    std::unique_ptr<HelpBubble> help_bubble) {
+  OnPromoShown(std::move(help_bubble), nullptr);
+}
+
+bool FeaturePromoLifecycle::OnPromoBubbleClosed() {
+  help_bubble_.reset();
+  if (state_ == State::kRunning) {
+    MaybeRecordCloseReason(CloseReason::kAbortPromo);
+    CHECK(MaybeEndPromo());
+    return true;
+  }
+  return false;
+}
+
+void FeaturePromoLifecycle::OnPromoEnded(CloseReason close_reason,
+                                         bool continue_promo) {
+  MaybeRecordCloseReason(close_reason);
+  bool write_close_data;
+  if (continue_promo) {
+    CHECK(is_bubble_visible());
+    state_ = State::kContinued;
+    help_bubble_->Close();
+    // When a snoozeable, normal promo that has a follow-up action (Tutorial,
+    // custom action), the result is not recorded until after the follow-up
+    // finishes, because e.g. an aborted tutorial counts as a snooze.
+    write_close_data = promo_subtype_ != PromoSubtype::kNormal ||
+                       close_reason != CloseReason::kAction;
+  } else {
+    CHECK(MaybeEndPromo());
+    write_close_data = true;
+  }
+
+  if (write_close_data) {
+    MaybeWriteClosePromoData(close_reason);
+  }
+}
+
+void FeaturePromoLifecycle::OnContinuedPromoEnded(bool completed_successfully) {
+  MaybeWriteClosePromoData(completed_successfully ? CloseReason::kAction
+                                                  : CloseReason::kSnooze);
+  MaybeEndPromo();
+}
+
+bool FeaturePromoLifecycle::MaybeEndPromo() {
+  if (!is_promo_active()) {
+    return false;
+  }
+  state_ = State::kClosed;
+  if (!is_demo() && !tracker_dismissed_) {
+    tracker_dismissed_ = true;
+    tracker_->Dismissed(*iph_feature_);
+  }
+  return true;
+}
+
+void FeaturePromoLifecycle::MaybeWriteClosePromoData(CloseReason close_reason) {
+  if (is_demo() || wrote_close_data_) {
+    return;
+  }
+
+  wrote_close_data_ = true;
+
+  switch (close_reason) {
+    case CloseReason::kAction:
+    case CloseReason::kCancel:
+    case CloseReason::kDismiss:
+    case CloseReason::kFeatureEngaged:
+    case CloseReason::kTimeout: {
+      ScopedPromoData data(storage_service_, iph_feature_);
+      if (!app_id_.empty()) {
+        data->shown_for_apps.insert(app_id_);
+      }
+      data->is_dismissed = true;
+      data->last_dismissed_by = close_reason;
+      break;
+    }
+
+    case CloseReason::kSnooze: {
+      ScopedPromoData data(storage_service_, iph_feature_);
+      ++data->snooze_count;
+      data->last_snooze_time = base::Time::Now();
+      data->last_snooze_duration = kDefaultSnoozeDuration;
+      break;
+    }
+
+    case CloseReason::kAbortPromo:
+    case CloseReason::kOverrideForDemo:
+    case CloseReason::kOverrideForPrecedence:
+    case CloseReason::kOverrideForTesting:
+    case CloseReason::kOverrideForUIRegionConflict:
+      // No additional action required.
+      break;
+  }
+}
+
+void FeaturePromoLifecycle::MaybeRecordCloseReason(CloseReason close_reason) {
+  if (is_demo() || state_ != State::kRunning) {
+    return;
+  }
+
+  std::string action_name = "UserEducation.MessageAction.";
+
+  switch (close_reason) {
+    case CloseReason::kDismiss:
+      action_name.append("Dismiss");
+      break;
+    case CloseReason::kSnooze:
+      action_name.append("Snooze");
+      break;
+    case CloseReason::kAction:
+      action_name.append("Action");
+      break;
+    case CloseReason::kCancel:
+      action_name.append("Cancel");
+      break;
+    case CloseReason::kTimeout:
+      action_name.append("Timeout");
+      break;
+    case CloseReason::kAbortPromo:
+      action_name.append("Abort");
+      break;
+    case CloseReason::kFeatureEngaged:
+      action_name.append("FeatureEngaged");
+      break;
+    case CloseReason::kOverrideForUIRegionConflict:
+      action_name.append("OverrideForUIRegionConflict");
+      break;
+    case CloseReason::kOverrideForPrecedence:
+      action_name.append("OverrideForPrecedence");
+      break;
+    case CloseReason::kOverrideForDemo:
+      // Not used for metrics.
+      return;
+    case CloseReason::kOverrideForTesting:
+      // Not used for metrics.
+      return;
+  }
+  action_name.append(".");
+  action_name.append(iph_feature()->name);
+  // Record the user action.
+  base::RecordComputedAction(action_name);
+
+  // Record the histogram.
+  std::string histogram_name =
+      std::string("UserEducation.MessageAction.").append(iph_feature()->name);
+  base::UmaHistogramEnumeration(histogram_name,
+                                static_cast<CloseReason>(close_reason));
+}
+
+}  // namespace user_education
diff --git a/components/user_education/common/feature_promo_lifecycle.h b/components/user_education/common/feature_promo_lifecycle.h
new file mode 100644
index 0000000..3a71c92
--- /dev/null
+++ b/components/user_education/common/feature_promo_lifecycle.h
@@ -0,0 +1,123 @@
+// Copyright 2023 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_LIFECYCLE_H_
+#define COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_LIFECYCLE_H_
+
+#include <memory>
+
+#include "base/memory/raw_ptr.h"
+#include "base/strings/string_piece_forward.h"
+#include "base/time/time.h"
+#include "components/feature_engagement/public/tracker.h"
+#include "components/user_education/common/feature_promo_specification.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
+
+namespace base {
+struct Feature;
+}
+
+namespace user_education {
+
+class HelpBubble;
+
+// Implements business logic around the lifecycle of an IPH feature promo,
+// depending on promo type and subtype. Tracks information about the IPH in
+// question through `FeaturePromoStorageService`.
+class FeaturePromoLifecycle {
+ public:
+  using CloseReason = FeaturePromoStorageService::CloseReason;
+  using PromoSubtype = FeaturePromoSpecification::PromoSubtype;
+  using PromoType = FeaturePromoSpecification::PromoType;
+
+  static constexpr base::TimeDelta kDefaultSnoozeDuration = base::Days(7);
+
+  FeaturePromoLifecycle(FeaturePromoStorageService* storage_service,
+                        const base::StringPiece& app_id,
+                        const base::Feature* iph_feature,
+                        PromoType promo_type,
+                        PromoSubtype promo_subtype);
+  ~FeaturePromoLifecycle();
+
+  FeaturePromoLifecycle(const FeaturePromoLifecycle&) = delete;
+  void operator=(const FeaturePromoLifecycle&) = delete;
+
+  // Returns whether the policy and previous usage of this IPH would allow it to
+  // be shown again; for example, a snoozeable IPH cannot show if it is
+  // currently in the snooze period.
+  bool CanShow() const;
+
+  // Notifies that the promo was shown. `tracker` will be used to release the
+  // feature when the promo ends.
+  void OnPromoShown(std::unique_ptr<HelpBubble> help_bubble,
+                    feature_engagement::Tracker* tracker);
+
+  // Notifies that the promo is being shown for a demo. No data will be stored.
+  void OnPromoShownForDemo(std::unique_ptr<HelpBubble> help_bubble);
+
+  // Notifies that the promo bubble closed. If none of the other dismissed,
+  // snoozed, continued, etc. callbacks have been called, assumes that the
+  // bubble was closed programmatically without user input.
+  //
+  // Returns whether the promo was aborted as a result.
+  bool OnPromoBubbleClosed();
+
+  // Notifies that the promo was ended for the specified `close_reason`.
+  // May result in pref data and/or histogram logging. If `continue_promo` is
+  // true, the bubble should be closed but the promo is not ended -
+  // `OnContinuedPromoEnded()` should be called when the continuation finishes.
+  void OnPromoEnded(CloseReason close_reason, bool continue_promo = false);
+
+  // For custom action, Tutorial, etc. indicates that the
+  void OnContinuedPromoEnded(bool completed_successfully);
+
+  HelpBubble* help_bubble() { return help_bubble_.get(); }
+  const HelpBubble* help_bubble() const { return help_bubble_.get(); }
+  const base::Feature* iph_feature() const { return iph_feature_; }
+  bool was_started() const { return state_ != State::kNotStarted; }
+  bool is_promo_active() const {
+    return state_ == State::kRunning || state_ == State::kContinued;
+  }
+  bool is_bubble_visible() const { return state_ == State::kRunning; }
+  bool is_demo() const { return was_started() && !tracker_; }
+  PromoType promo_type() const { return promo_type_; }
+  PromoSubtype promo_subtype() const { return promo_subtype_; }
+
+ private:
+  enum class State { kNotStarted, kRunning, kContinued, kClosed };
+
+  // Records `PromoData` about the promo closing, unless in demo mode.
+  void MaybeWriteClosePromoData(CloseReason close_reason);
+
+  // Records user actions and histograms that discern what action was taken to
+  // close a promotion. Does not record in demo mode.
+  void MaybeRecordCloseReason(CloseReason close_reason);
+
+  // If the promo is running, ends it, possibly dismissing the Tracker.
+  bool MaybeEndPromo();
+
+  // The service that stores non-transient data about the IPH.
+  const raw_ptr<FeaturePromoStorageService> storage_service_;
+
+  // The current app id, or empty for none.
+  const std::string app_id_;
+
+  // Data about the current IPH.
+  const raw_ptr<const base::Feature> iph_feature_;
+  const PromoType promo_type_;
+  const PromoSubtype promo_subtype_;
+
+  // The current state of the promo.
+  State state_ = State::kNotStarted;
+  bool wrote_close_data_ = false;
+  bool tracker_dismissed_ = false;
+
+  // The tracker, in the event the bubble needs to be dismissed.
+  raw_ptr<feature_engagement::Tracker> tracker_ = nullptr;
+  std::unique_ptr<HelpBubble> help_bubble_;
+};
+
+}  // namespace user_education
+
+#endif  // COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_LIFECYCLE_H_
diff --git a/components/user_education/common/feature_promo_lifecycle_unittest.cc b/components/user_education/common/feature_promo_lifecycle_unittest.cc
new file mode 100644
index 0000000..705e0ab
--- /dev/null
+++ b/components/user_education/common/feature_promo_lifecycle_unittest.cc
@@ -0,0 +1,452 @@
+// Copyright 2023 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/user_education/common/feature_promo_lifecycle.h"
+
+#include <sstream>
+#include <tuple>
+#include <type_traits>
+
+#include "base/callback_list.h"
+#include "base/feature_list.h"
+#include "base/test/bind.h"
+#include "base/test/task_environment.h"
+#include "components/feature_engagement/test/mock_tracker.h"
+#include "components/user_education/common/feature_promo_specification.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
+#include "components/user_education/common/help_bubble_params.h"
+#include "components/user_education/test/test_feature_promo_storage_service.h"
+#include "components/user_education/test/test_help_bubble.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "ui/base/interaction/element_identifier.h"
+#include "ui/base/interaction/element_test_util.h"
+
+namespace user_education {
+
+namespace {
+BASE_FEATURE(kTestIPHFeature,
+             "TestIPHFeature",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+BASE_FEATURE(kTestIPHFeature2,
+             "TestIPHFeature2",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+constexpr char kAppName[] = "App1";
+constexpr char kAppName2[] = "App2";
+DEFINE_LOCAL_ELEMENT_IDENTIFIER_VALUE(kTestElementId);
+const ui::ElementContext kTestElementContext{1};
+
+template <typename Arg, typename... Args>
+std::string ParamToString(
+    const testing::TestParamInfo<std::tuple<Arg, Args...>>& param) {
+  std::ostringstream oss;
+  oss << std::get<Arg>(param.param);
+  ((oss << "_" << std::get<Args>(param.param)), ...);
+  return oss.str();
+}
+
+}  // namespace
+
+using PromoType = FeaturePromoSpecification::PromoType;
+using PromoSubtype = FeaturePromoSpecification::PromoSubtype;
+using CloseReason = FeaturePromoLifecycle::CloseReason;
+
+class FeaturePromoLifecycleTest : public testing::Test {
+ public:
+  FeaturePromoLifecycleTest() = default;
+  ~FeaturePromoLifecycleTest() override = default;
+
+  void SetUp() override {
+    testing::Test::SetUp();
+    element_.Show();
+  }
+
+  void TearDown() override {
+    ASSERT_EQ(0, num_open_bubbles_);
+    testing::Test::TearDown();
+  }
+
+  PromoType promo_type() const { return promo_type_; }
+  void set_promo_type(PromoType promo_type) { promo_type_ = promo_type; }
+  PromoSubtype promo_subtype() const { return promo_subtype_; }
+  void set_promo_subtype(PromoSubtype promo_subtype) {
+    promo_subtype_ = promo_subtype;
+  }
+
+  bool is_snoozeable() const {
+    return promo_subtype() == PromoSubtype::kNormal &&
+           (promo_type() == PromoType::kSnooze ||
+            promo_type() == PromoType::kTutorial);
+  }
+
+  std::unique_ptr<FeaturePromoLifecycle> CreateLifecycle(
+      const base::Feature& feature,
+      const char* app_id = nullptr) {
+    if (!app_id) {
+      app_id = promo_subtype() == PromoSubtype::kPerApp ? kAppName : "";
+    }
+    return std::make_unique<FeaturePromoLifecycle>(
+        &storage_service_, app_id, &feature, promo_type(), promo_subtype());
+  }
+
+  std::unique_ptr<test::TestHelpBubble> CreateHelpBubble() {
+    ++num_open_bubbles_;
+    auto result =
+        std::make_unique<test::TestHelpBubble>(&element_, HelpBubbleParams());
+    help_bubble_subscriptions_.emplace_back(
+        result->AddOnCloseCallback(base::BindLambdaForTesting(
+            [this](HelpBubble*) { --num_open_bubbles_; })));
+    return result;
+  }
+
+ protected:
+  PromoType promo_type_ = PromoType::kSnooze;
+  PromoSubtype promo_subtype_ = PromoSubtype::kNormal;
+  int num_open_bubbles_ = 0;
+  ui::test::TestElement element_{kTestElementId, kTestElementContext};
+  test::TestFeaturePromoStorageService storage_service_;
+  base::test::TaskEnvironment task_environment_{
+      base::test::TaskEnvironment::TimeSource::MOCK_TIME};
+  testing::StrictMock<feature_engagement::test::MockTracker> tracker_;
+  std::vector<base::CallbackListSubscription> help_bubble_subscriptions_;
+};
+
+TEST_F(FeaturePromoLifecycleTest, BubbleClosedOnDiscard) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  EXPECT_CALL(tracker_, Dismissed(testing::Ref(kTestIPHFeature)));
+  lifecycle.reset();
+  EXPECT_EQ(0, num_open_bubbles_);
+}
+
+TEST_F(FeaturePromoLifecycleTest, BubbleClosedOnContinue) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  lifecycle->OnPromoEnded(CloseReason::kFeatureEngaged, true);
+  EXPECT_EQ(0, num_open_bubbles_);
+  auto promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_TRUE(promo_data->is_dismissed);
+  EXPECT_CALL(tracker_, Dismissed(testing::Ref(kTestIPHFeature)));
+}
+
+TEST_F(FeaturePromoLifecycleTest,
+       ClosePromoBubbleAndContinue_kNormal_TutorialSucceeds) {
+  set_promo_type(PromoType::kTutorial);
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  lifecycle->OnPromoEnded(CloseReason::kAction, true);
+  auto promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_FALSE(promo_data->is_dismissed);
+
+  EXPECT_CALL(tracker_, Dismissed(testing::Ref(kTestIPHFeature)));
+  lifecycle->OnContinuedPromoEnded(true);
+  promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_TRUE(promo_data->is_dismissed);
+  EXPECT_EQ(CloseReason::kAction, promo_data->last_dismissed_by);
+}
+
+TEST_F(FeaturePromoLifecycleTest,
+       ClosePromoBubbleAndContinue_kNormal_TutorialFails) {
+  set_promo_type(PromoType::kTutorial);
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  lifecycle->OnPromoEnded(CloseReason::kAction, true);
+  auto promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_FALSE(promo_data->is_dismissed);
+
+  EXPECT_CALL(tracker_, Dismissed(testing::Ref(kTestIPHFeature)));
+  lifecycle->OnContinuedPromoEnded(false);
+  promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_FALSE(promo_data->is_dismissed);
+  EXPECT_EQ(1, promo_data->snooze_count);
+}
+
+TEST_F(FeaturePromoLifecycleTest,
+       ClosePromoBubbleAndContinue_DismissOnDiscard) {
+  set_promo_type(PromoType::kTutorial);
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  lifecycle->OnPromoEnded(CloseReason::kAction, true);
+  EXPECT_CALL(tracker_, Dismissed(testing::Ref(kTestIPHFeature)));
+}
+
+TEST_F(FeaturePromoLifecycleTest, ClosePromoBubbleAndContinue_kLegalNotice) {
+  set_promo_type(PromoType::kTutorial);
+  set_promo_subtype(PromoSubtype::kLegalNotice);
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  lifecycle->OnPromoEnded(CloseReason::kAction, true);
+  auto promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_TRUE(promo_data->is_dismissed);
+  EXPECT_EQ(CloseReason::kAction, promo_data->last_dismissed_by);
+
+  EXPECT_CALL(tracker_, Dismissed(testing::Ref(kTestIPHFeature)));
+  lifecycle->OnContinuedPromoEnded(false);
+  EXPECT_CALL(tracker_, Dismissed).Times(0);
+  promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_TRUE(promo_data->is_dismissed);
+  EXPECT_EQ(CloseReason::kAction, promo_data->last_dismissed_by);
+  EXPECT_EQ(0, promo_data->snooze_count);
+  EXPECT_EQ(1, promo_data->show_count);
+}
+
+TEST_F(FeaturePromoLifecycleTest, ClosePromoBubbleAndContinue_kPerApp) {
+  set_promo_type(PromoType::kTutorial);
+  set_promo_subtype(PromoSubtype::kPerApp);
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  lifecycle->OnPromoEnded(CloseReason::kAction, true);
+  auto promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_TRUE(promo_data->is_dismissed);
+  EXPECT_EQ(CloseReason::kAction, promo_data->last_dismissed_by);
+
+  EXPECT_CALL(tracker_, Dismissed(testing::Ref(kTestIPHFeature)));
+  lifecycle->OnContinuedPromoEnded(false);
+  EXPECT_CALL(tracker_, Dismissed).Times(0);
+  promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_TRUE(promo_data->is_dismissed);
+  EXPECT_EQ(CloseReason::kAction, promo_data->last_dismissed_by);
+  EXPECT_EQ(0, promo_data->snooze_count);
+  EXPECT_EQ(1, promo_data->show_count);
+}
+
+template <typename... Args>
+class FeaturePromoLifecycleParamTest
+    : public FeaturePromoLifecycleTest,
+      public testing::WithParamInterface<
+          std::tuple<PromoType, PromoSubtype, Args...>> {
+ public:
+  FeaturePromoLifecycleParamTest() = default;
+  ~FeaturePromoLifecycleParamTest() override = default;
+
+  using ValueType = std::tuple<PromoType, PromoSubtype, Args...>;
+
+  template <typename T>
+  T GetParamT() const {
+    return std::get<T>(testing::WithParamInterface<ValueType>::GetParam());
+  }
+
+  void SetUp() override {
+    set_promo_type(GetParamT<PromoType>());
+    set_promo_subtype(GetParamT<PromoSubtype>());
+    FeaturePromoLifecycleTest::SetUp();
+  }
+
+  std::unique_ptr<FeaturePromoLifecycle> CreateLifecycle(
+      const base::Feature& feature,
+      const char* app_id = nullptr) {
+    if (!app_id) {
+      app_id = promo_subtype() == PromoSubtype::kPerApp ? kAppName : "";
+    }
+    return std::make_unique<FeaturePromoLifecycle>(
+        &storage_service_, app_id, &feature, promo_type(), promo_subtype());
+  }
+};
+
+using FeaturePromoLifecycleWriteDataTest =
+    FeaturePromoLifecycleParamTest<CloseReason>;
+
+INSTANTIATE_TEST_SUITE_P(
+    ,
+    FeaturePromoLifecycleWriteDataTest,
+    testing::Combine(testing::Values(PromoType::kTutorial),
+                     testing::Values(PromoSubtype::kNormal,
+                                     PromoSubtype::kPerApp,
+                                     PromoSubtype::kLegalNotice),
+                     testing::Values(CloseReason::kDismiss,
+                                     CloseReason::kSnooze,
+                                     CloseReason::kAction,
+                                     CloseReason::kCancel,
+                                     CloseReason::kTimeout,
+                                     CloseReason::kAbortPromo,
+                                     CloseReason::kFeatureEngaged)),
+    (ParamToString<PromoType, PromoSubtype, CloseReason>));
+
+TEST_P(FeaturePromoLifecycleWriteDataTest, DemoDoesNotWriteData) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShownForDemo(CreateHelpBubble());
+  lifecycle->OnPromoEnded(GetParamT<CloseReason>());
+  ASSERT_FALSE(storage_service_.ReadPromoData(kTestIPHFeature).has_value());
+}
+
+TEST_P(FeaturePromoLifecycleWriteDataTest, DataWrittenAndTrackerDismissed) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+
+  const auto close_reason = GetParamT<CloseReason>();
+  EXPECT_CALL(tracker_, Dismissed(testing::Ref(kTestIPHFeature)));
+  lifecycle->OnPromoEnded(close_reason);
+  EXPECT_CALL(tracker_, Dismissed).Times(0);
+
+  auto promo_data = storage_service_.ReadPromoData(kTestIPHFeature);
+  EXPECT_EQ(1, promo_data->show_count);
+  if (close_reason == CloseReason::kAbortPromo) {
+    EXPECT_FALSE(promo_data->is_dismissed);
+    EXPECT_EQ(0, promo_data->snooze_count);
+  } else if (close_reason == CloseReason::kSnooze) {
+    EXPECT_FALSE(promo_data->is_dismissed);
+    EXPECT_EQ(1, promo_data->snooze_count);
+    EXPECT_GE(promo_data->last_snooze_time, promo_data->last_show_time);
+  } else {
+    EXPECT_EQ(0, promo_data->snooze_count);
+    EXPECT_TRUE(promo_data->is_dismissed);
+    EXPECT_EQ(close_reason, promo_data->last_dismissed_by);
+  }
+}
+
+using FeaturePromoLifecycleTypesTest = FeaturePromoLifecycleParamTest<>;
+
+INSTANTIATE_TEST_SUITE_P(
+    ,
+    FeaturePromoLifecycleTypesTest,
+    testing::Combine(testing::Values(PromoType::kLegacy,
+                                     PromoType::kToast,
+                                     PromoType::kSnooze,
+                                     PromoType::kTutorial,
+                                     PromoType::kCustomAction),
+                     testing::Values(PromoSubtype::kNormal,
+                                     PromoSubtype::kPerApp,
+                                     PromoSubtype::kLegalNotice)),
+    (ParamToString<PromoType, PromoSubtype>));
+
+TEST_P(FeaturePromoLifecycleTypesTest, AllowFirstTimeIPH) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_TRUE(lifecycle->CanShow());
+}
+
+TEST_P(FeaturePromoLifecycleTypesTest, BlockDismissedIPH) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  EXPECT_CALL(tracker_, Dismissed);
+  lifecycle->OnPromoEnded(CloseReason::kDismiss);
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  const bool expect_can_show = promo_subtype() == PromoSubtype::kNormal &&
+                               (promo_type() == PromoType::kLegacy ||
+                                promo_type() == PromoType::kToast ||
+                                promo_type() == PromoType::kCustomAction);
+  EXPECT_EQ(expect_can_show, lifecycle->CanShow());
+  storage_service_.Reset(kTestIPHFeature);
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_TRUE(lifecycle->CanShow());
+}
+
+TEST_P(FeaturePromoLifecycleTypesTest, BlockSnoozedIPH) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  EXPECT_CALL(tracker_, Dismissed);
+  lifecycle->OnPromoEnded(CloseReason::kSnooze);
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_EQ(!is_snoozeable(), lifecycle->CanShow());
+  storage_service_.Reset(kTestIPHFeature);
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_TRUE(lifecycle->CanShow());
+}
+
+TEST_P(FeaturePromoLifecycleTypesTest, ReleaseSnoozedIPH) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  EXPECT_CALL(tracker_, Dismissed);
+  lifecycle->OnPromoEnded(CloseReason::kSnooze);
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_EQ(!is_snoozeable(), lifecycle->CanShow());
+  task_environment_.FastForwardBy(
+      FeaturePromoLifecycle::kDefaultSnoozeDuration);
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_TRUE(lifecycle->CanShow());
+}
+
+TEST_P(FeaturePromoLifecycleTypesTest, MultipleIPH) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  EXPECT_CALL(tracker_, Dismissed);
+  lifecycle->OnPromoEnded(CloseReason::kSnooze);
+
+  task_environment_.FastForwardBy(base::Hours(1));
+
+  lifecycle = CreateLifecycle(kTestIPHFeature2);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  EXPECT_CALL(tracker_, Dismissed);
+  lifecycle->OnPromoEnded(CloseReason::kSnooze);
+
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_EQ(!is_snoozeable(), lifecycle->CanShow());
+
+  lifecycle = CreateLifecycle(kTestIPHFeature2);
+  EXPECT_EQ(!is_snoozeable(), lifecycle->CanShow());
+
+  task_environment_.FastForwardBy(
+      FeaturePromoLifecycle::kDefaultSnoozeDuration - base::Hours(1));
+
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_TRUE(lifecycle->CanShow());
+
+  lifecycle = CreateLifecycle(kTestIPHFeature2);
+  EXPECT_EQ(!is_snoozeable(), lifecycle->CanShow());
+
+  task_environment_.FastForwardBy(base::Hours(1));
+
+  lifecycle = CreateLifecycle(kTestIPHFeature2);
+  EXPECT_TRUE(lifecycle->CanShow());
+}
+
+TEST_P(FeaturePromoLifecycleTypesTest, SnoozeNonInteractedIPH) {
+  auto lifecycle = CreateLifecycle(kTestIPHFeature);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  EXPECT_CALL(tracker_, Dismissed);
+  lifecycle.reset();
+
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_EQ(!is_snoozeable(), lifecycle->CanShow());
+
+  task_environment_.FastForwardBy(
+      FeaturePromoLifecycle::kDefaultSnoozeDuration);
+
+  lifecycle = CreateLifecycle(kTestIPHFeature);
+  EXPECT_TRUE(lifecycle->CanShow());
+}
+
+using FeaturePromoLifecycleAppTest = FeaturePromoLifecycleParamTest<>;
+
+INSTANTIATE_TEST_SUITE_P(
+    ,
+    FeaturePromoLifecycleAppTest,
+    testing::Combine(testing::Values(PromoType::kLegacy,
+                                     PromoType::kToast,
+                                     PromoType::kTutorial,
+                                     PromoType::kCustomAction),
+                     testing::Values(PromoSubtype::kPerApp)),
+    (ParamToString<PromoType, PromoSubtype>));
+
+TEST_P(FeaturePromoLifecycleAppTest, IPHBlockedPerApp) {
+  // Show and confirm for one app.
+  auto lifecycle = CreateLifecycle(kTestIPHFeature, kAppName);
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  EXPECT_CALL(tracker_, Dismissed);
+  lifecycle->OnPromoEnded(CloseReason::kDismiss);
+
+  // That app should no longer allow showing.
+  lifecycle = CreateLifecycle(kTestIPHFeature, kAppName);
+  EXPECT_FALSE(lifecycle->CanShow());
+
+  // However a different app should be allowed.
+  lifecycle = CreateLifecycle(kTestIPHFeature, kAppName2);
+  EXPECT_TRUE(lifecycle->CanShow());
+
+  // Show and dismiss in the other app.
+  lifecycle->OnPromoShown(CreateHelpBubble(), &tracker_);
+  EXPECT_CALL(tracker_, Dismissed);
+  lifecycle->OnPromoEnded(CloseReason::kDismiss);
+
+  // Now both apps should be blocked.
+  lifecycle = CreateLifecycle(kTestIPHFeature, kAppName);
+  EXPECT_FALSE(lifecycle->CanShow());
+  lifecycle = CreateLifecycle(kTestIPHFeature, kAppName2);
+  EXPECT_FALSE(lifecycle->CanShow());
+
+  // But a different IPH should not be blocked.
+  lifecycle = CreateLifecycle(kTestIPHFeature2, kAppName);
+  EXPECT_TRUE(lifecycle->CanShow());
+}
+
+}  // namespace user_education
diff --git a/components/user_education/common/feature_promo_snooze_service.cc b/components/user_education/common/feature_promo_snooze_service.cc
deleted file mode 100644
index 7a88365b..0000000
--- a/components/user_education/common/feature_promo_snooze_service.cc
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright 2020 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#include "components/user_education/common/feature_promo_snooze_service.h"
-
-#include <ostream>
-
-#include "base/feature_list.h"
-#include "base/json/values_util.h"
-#include "base/metrics/field_trial_params.h"
-#include "base/metrics/histogram_functions.h"
-#include "base/time/time.h"
-#include "base/values.h"
-#include "components/feature_engagement/public/feature_constants.h"
-#include "third_party/abseil-cpp/absl/types/optional.h"
-
-namespace user_education {
-
-namespace {
-
-constexpr base::FeatureParam<FeaturePromoSnoozeService::NonClickerPolicy>::
-    Option kNonClickerPolicyOptions[] = {
-        {FeaturePromoSnoozeService::NonClickerPolicy::kDismiss, "dismiss"},
-        {FeaturePromoSnoozeService::NonClickerPolicy::kLongSnooze,
-         "long_snooze"}};
-
-// Used in UMA histogram to track if the user snoozes for once or more.
-enum class SnoozeType {
-  // The user snoozes the IPH for the first time.
-  kFirstTimeSnooze = 0,
-  // The user snoozes the IPH for the second time or more.
-  kRepeatingSnooze = 1,
-  kMaxValue = kRepeatingSnooze
-};
-}  // namespace
-
-constexpr int FeaturePromoSnoozeService::kUmaMaxSnoozeCount;
-constexpr base::TimeDelta FeaturePromoSnoozeService::kDefaultSnoozeDuration;
-
-FeaturePromoSnoozeService::FeaturePromoSnoozeService() = default;
-FeaturePromoSnoozeService::~FeaturePromoSnoozeService() = default;
-
-void FeaturePromoSnoozeService::OnUserSnooze(const base::Feature& iph_feature,
-                                             base::TimeDelta snooze_duration) {
-  DCHECK(snooze_duration > base::Seconds(0));
-  auto snooze_data = ReadSnoozeData(iph_feature);
-
-  if (!snooze_data)
-    snooze_data = SnoozeData();
-
-  snooze_data->last_snooze_time = base::Time::Now();
-  snooze_data->last_snooze_duration = snooze_duration;
-  snooze_data->snooze_count++;
-
-  SaveSnoozeData(iph_feature, *snooze_data);
-}
-
-void FeaturePromoSnoozeService::OnUserDismiss(
-    const base::Feature& iph_feature) {
-  auto snooze_data = ReadSnoozeData(iph_feature);
-
-  if (!snooze_data)
-    snooze_data = SnoozeData();
-
-  snooze_data->is_dismissed = true;
-
-  SaveSnoozeData(iph_feature, *snooze_data);
-}
-
-void FeaturePromoSnoozeService::OnPromoShown(const base::Feature& iph_feature) {
-  auto snooze_data = ReadSnoozeData(iph_feature);
-
-  if (!snooze_data)
-    snooze_data = SnoozeData();
-
-  snooze_data->last_show_time = base::Time::Now();
-  snooze_data->show_count++;
-
-  SaveSnoozeData(iph_feature, *snooze_data);
-}
-
-bool FeaturePromoSnoozeService::IsBlocked(const base::Feature& iph_feature) {
-  auto snooze_data = ReadSnoozeData(iph_feature);
-
-  if (!snooze_data)
-    return false;
-
-  // This IPH has been dismissed by user permanently.
-  if (snooze_data->is_dismissed)
-    return true;
-
-  // This IPH is shown for the first time.
-  if (snooze_data->show_count == 0)
-    return false;
-
-  if (snooze_data->snooze_count > 0 &&
-      snooze_data->last_snooze_time >= snooze_data->last_show_time) {
-    // The IPH was snoozed on last display.
-
-    // Corruption: Snooze time is in the future.
-    if (snooze_data->last_snooze_time > base::Time::Now())
-      return true;
-
-    // This IPH is snoozed. Test if snooze period has expired.
-    return base::Time::Now() <
-           snooze_data->last_snooze_time + snooze_data->last_snooze_duration;
-  } else {
-    // The IPH was neither snoozed or dismissed on last display.
-    const base::FeatureParam<FeaturePromoSnoozeService::NonClickerPolicy>
-        kNonClickerPolicy{
-            &iph_feature, "x_iph_snooze_non_clicker_policy",
-            FeaturePromoSnoozeService::NonClickerPolicy::kLongSnooze,
-            &kNonClickerPolicyOptions};
-
-    NonClickerPolicy non_clicker_policy = kNonClickerPolicy.Get();
-
-    if (non_clicker_policy == NonClickerPolicy::kDismiss)
-      return true;
-
-    return base::Time::Now() < snooze_data->last_show_time + base::Days(14);
-  }
-}
-
-int FeaturePromoSnoozeService::GetSnoozeCount(
-    const base::Feature& iph_feature) {
-  absl::optional<SnoozeData> snooze_data = ReadSnoozeData(iph_feature);
-  return snooze_data ? snooze_data->snooze_count : 0;
-}
-
-}  // namespace user_education
diff --git a/components/user_education/common/feature_promo_snooze_service.h b/components/user_education/common/feature_promo_snooze_service.h
deleted file mode 100644
index 160f03b..0000000
--- a/components/user_education/common/feature_promo_snooze_service.h
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright 2020 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#ifndef COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_SNOOZE_SERVICE_H_
-#define COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_SNOOZE_SERVICE_H_
-
-#include "base/feature_list.h"
-#include "base/time/time.h"
-#include "third_party/abseil-cpp/absl/types/optional.h"
-
-// Declare in the global namespace for test purposes.
-class FeaturePromoSnoozeInteractiveTest;
-
-namespace user_education {
-
-// This service manages snooze and dismiss of snoozable in-product help promo.
-// It is an abstract base class in order to support multiple frameworks/
-// platforms and potentially to migrate the backing to the new snooze feature
-// of the feature engagement tracker.
-//
-// Before showing an IPH, the IPH controller should ask if the IPH is blocked.
-// The controller should also notify after the IPH is shown and after the user
-// clicks the snooze/dismiss button.
-class FeaturePromoSnoozeService {
- public:
-  // Policies to handler users who don't interact with the IPH.
-  enum class NonClickerPolicy {
-    // Permanently dismiss the IPH. Equivalent to clicking the dismiss button.
-    kDismiss = 0,
-    // Reshow the IPH later after at least 14 days.
-    kLongSnooze
-  };
-
-  // Maximum count of snoozes to track in UMA histogram.
-  // Snooze counts that are equal or larger than this value will be conflated.
-  static constexpr int kUmaMaxSnoozeCount = 10;
-
-  // The snooze duration defaults to 7 days. This was determined by
-  // thoroughly testing for a helpful, yet non-intrusive time span.
-  static constexpr base::TimeDelta kDefaultSnoozeDuration = base::Hours(168);
-
-  FeaturePromoSnoozeService();
-  virtual ~FeaturePromoSnoozeService();
-
-  // Disallow copy and assign.
-  FeaturePromoSnoozeService(const FeaturePromoSnoozeService&) = delete;
-  FeaturePromoSnoozeService& operator=(const FeaturePromoSnoozeService&) =
-      delete;
-
-  // The IPH controller must call this method when the user snoozes an IPH.
-  void OnUserSnooze(const base::Feature& iph_feature,
-                    base::TimeDelta snooze_duration = kDefaultSnoozeDuration);
-
-  // The IPH controller must call this method when the user actively dismiss an
-  // IPH. Don't call this method in case of a passive dismiss, i.e. auto dismiss
-  // after a fixed amount of time.
-  void OnUserDismiss(const base::Feature& iph_feature);
-
-  // The IPH controller must call this method after an IPH is shown.
-  void OnPromoShown(const base::Feature& iph_feature);
-
-  // The IPH controller must call this method to check if an IPH is blocked by
-  // dismiss or snooze. An IPH will be approved if it is not snoozed or the
-  // snoozing period has timed out.
-  bool IsBlocked(const base::Feature& iph_feature);
-
-  // Reset the state of |iph_feature|.
-  virtual void Reset(const base::Feature& iph_feature) = 0;
-
-  // Read the count of previous snoozes for |iph_feature| from profile.
-  int GetSnoozeCount(const base::Feature& iph_feature);
-
- protected:
-  // Snooze information dictionary saved under path
-  // in_product_help.snoozed_feature.[iph_name] in PerfService.
-  struct SnoozeData {
-    bool is_dismissed = false;
-    base::Time last_show_time = base::Time();
-    base::Time last_snooze_time = base::Time();
-    base::TimeDelta last_snooze_duration = base::TimeDelta();
-    int snooze_count = 0;
-    int show_count = 0;
-  };
-
-  virtual absl::optional<SnoozeData> ReadSnoozeData(
-      const base::Feature& iph_feature) = 0;
-  virtual void SaveSnoozeData(const base::Feature& iph_feature,
-                              const SnoozeData& snooze_data) = 0;
-
- private:
-  friend FeaturePromoSnoozeInteractiveTest;
-};
-
-}  // namespace user_education
-
-#endif  // COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_SNOOZE_SERVICE_H_
diff --git a/components/user_education/common/feature_promo_snooze_service_unittest.cc b/components/user_education/common/feature_promo_snooze_service_unittest.cc
deleted file mode 100644
index b4197a9..0000000
--- a/components/user_education/common/feature_promo_snooze_service_unittest.cc
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright 2020 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#include "components/user_education/common/feature_promo_snooze_service.h"
-
-#include <memory>
-
-#include "base/feature_list.h"
-#include "base/metrics/field_trial_param_associator.h"
-#include "base/test/scoped_feature_list.h"
-#include "base/test/task_environment.h"
-#include "base/time/time.h"
-#include "components/feature_engagement/public/feature_constants.h"
-#include "testing/gtest/include/gtest/gtest.h"
-
-namespace user_education {
-
-namespace {
-BASE_FEATURE(kTestIPHFeature,
-             "TestIPHFeature",
-             base::FEATURE_ENABLED_BY_DEFAULT);
-BASE_FEATURE(kTestIPHFeature2,
-             "TestIPHFeature2",
-             base::FEATURE_ENABLED_BY_DEFAULT);
-
-class TestFeaturePromoSnoozeService : public FeaturePromoSnoozeService {
- public:
-  TestFeaturePromoSnoozeService() = default;
-  ~TestFeaturePromoSnoozeService() override = default;
-
-  void Reset(const base::Feature& iph_feature) override {
-    snooze_data_.erase(&iph_feature);
-  }
-
-  absl::optional<FeaturePromoSnoozeService::SnoozeData> ReadSnoozeData(
-      const base::Feature& iph_feature) override {
-    const auto it = snooze_data_.find(&iph_feature);
-    return it == snooze_data_.end() ? absl::nullopt
-                                    : absl::make_optional(it->second);
-  }
-
-  void SaveSnoozeData(const base::Feature& iph_feature,
-                      const SnoozeData& snooze_data) override {
-    snooze_data_[&iph_feature] = snooze_data;
-  }
-
- private:
-  std::map<const base::Feature*, SnoozeData> snooze_data_;
-};
-
-}  // namespace
-
-class FeaturePromoSnoozeServiceTest : public testing::Test {
- public:
-  FeaturePromoSnoozeServiceTest()
-      : task_environment_{
-            base::test::SingleThreadTaskEnvironment::TimeSource::MOCK_TIME} {}
-
- protected:
-  base::test::TaskEnvironment task_environment_;
-  TestFeaturePromoSnoozeService service_;
-};
-
-TEST_F(FeaturePromoSnoozeServiceTest, AllowFirstTimeIPH) {
-  service_.Reset(kTestIPHFeature);
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-}
-
-TEST_F(FeaturePromoSnoozeServiceTest, BlockDismissedIPH) {
-  service_.Reset(kTestIPHFeature);
-  service_.OnPromoShown(kTestIPHFeature);
-  service_.OnUserDismiss(kTestIPHFeature);
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-  service_.Reset(kTestIPHFeature);
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-}
-
-TEST_F(FeaturePromoSnoozeServiceTest, BlockSnoozedIPH) {
-  service_.Reset(kTestIPHFeature);
-  service_.OnPromoShown(kTestIPHFeature);
-  service_.OnUserSnooze(kTestIPHFeature);
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-}
-
-TEST_F(FeaturePromoSnoozeServiceTest, ReleaseSnoozedIPH) {
-  service_.Reset(kTestIPHFeature);
-  service_.OnPromoShown(kTestIPHFeature);
-  service_.OnUserSnooze(kTestIPHFeature, base::Hours(1));
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-  task_environment_.FastForwardBy(base::Hours(2));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-}
-
-TEST_F(FeaturePromoSnoozeServiceTest, MultipleIPH) {
-  service_.Reset(kTestIPHFeature);
-  service_.Reset(kTestIPHFeature2);
-  service_.OnPromoShown(kTestIPHFeature);
-  service_.OnUserSnooze(kTestIPHFeature, base::Hours(1));
-  service_.OnPromoShown(kTestIPHFeature2);
-  service_.OnUserSnooze(kTestIPHFeature2, base::Hours(3));
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature2));
-  task_environment_.FastForwardBy(base::Hours(2));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature2));
-  task_environment_.FastForwardBy(base::Hours(2));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature2));
-}
-
-TEST_F(FeaturePromoSnoozeServiceTest, SnoozeNonClicker) {
-  base::test::ScopedFeatureList feature_list;
-  service_.Reset(kTestIPHFeature);
-  service_.OnPromoShown(kTestIPHFeature);
-  EXPECT_TRUE(service_.IsBlocked(kTestIPHFeature));
-  task_environment_.FastForwardBy(base::Days(15));
-  EXPECT_FALSE(service_.IsBlocked(kTestIPHFeature));
-}
-
-}  // namespace user_education
diff --git a/components/user_education/common/feature_promo_specification.cc b/components/user_education/common/feature_promo_specification.cc
index 777e69c..ad4d0188 100644
--- a/components/user_education/common/feature_promo_specification.cc
+++ b/components/user_education/common/feature_promo_specification.cc
@@ -16,6 +16,23 @@
 
 namespace user_education {
 
+namespace {
+
+// This function provides the list of allowed legal promos.
+// It is not to be modified except by the Frizzle team.
+bool IsAllowedLegalNotice(const base::Feature& promo_feature) {
+  // Add the text names of allowlisted critical promos here:
+  static const char* const kAllowedPromoNames[] = {};
+  for (const auto* promo_name : kAllowedPromoNames) {
+    if (!strcmp(promo_feature.name, promo_name)) {
+      return true;
+    }
+  }
+  return false;
+}
+
+}  // namespace
+
 FeaturePromoSpecification::AcceleratorInfo::AcceleratorInfo() = default;
 FeaturePromoSpecification::AcceleratorInfo::AcceleratorInfo(
     const AcceleratorInfo& other) = default;
@@ -220,6 +237,19 @@
   return *this;
 }
 
+FeaturePromoSpecification& FeaturePromoSpecification::SetPromoSubtype(
+    PromoSubtype promo_subtype) {
+  CHECK(promo_type_ != PromoType::kUnspecified);
+  CHECK(promo_type_ != PromoType::kSnooze)
+      << "Basic snooze is not compatible with other promo subtypes.";
+  if (promo_subtype == PromoSubtype::kLegalNotice) {
+    CHECK(feature_);
+    CHECK(IsAllowedLegalNotice(*feature_));
+  }
+  promo_subtype_ = promo_subtype;
+  return *this;
+}
+
 FeaturePromoSpecification& FeaturePromoSpecification::SetAnchorElementFilter(
     AnchorElementFilter anchor_element_filter) {
   anchor_element_filter_ = std::move(anchor_element_filter);
@@ -270,4 +300,46 @@
   }
 }
 
+std::ostream& operator<<(std::ostream& oss,
+                         FeaturePromoSpecification::PromoType promo_type) {
+  switch (promo_type) {
+    case FeaturePromoSpecification::PromoType::kLegacy:
+      oss << "kLegacy";
+      break;
+    case FeaturePromoSpecification::PromoType::kToast:
+      oss << "kToast";
+      break;
+    case FeaturePromoSpecification::PromoType::kSnooze:
+      oss << "kSnooze";
+      break;
+    case FeaturePromoSpecification::PromoType::kTutorial:
+      oss << "kTutorial";
+      break;
+    case FeaturePromoSpecification::PromoType::kCustomAction:
+      oss << "kCustomAction";
+      break;
+    case FeaturePromoSpecification::PromoType::kUnspecified:
+      oss << "kUnspecified";
+      break;
+  }
+  return oss;
+}
+
+std::ostream& operator<<(
+    std::ostream& oss,
+    FeaturePromoSpecification::PromoSubtype promo_subtype) {
+  switch (promo_subtype) {
+    case FeaturePromoSpecification::PromoSubtype::kNormal:
+      oss << "kNormal";
+      break;
+    case FeaturePromoSpecification::PromoSubtype::kPerApp:
+      oss << "kPerApp";
+      break;
+    case FeaturePromoSpecification::PromoSubtype::kLegalNotice:
+      oss << "kLegalNotice";
+      break;
+  }
+  return oss;
+}
+
 }  // namespace user_education
diff --git a/components/user_education/common/feature_promo_specification.h b/components/user_education/common/feature_promo_specification.h
index 10d47fc..bb0d93c00 100644
--- a/components/user_education/common/feature_promo_specification.h
+++ b/components/user_education/common/feature_promo_specification.h
@@ -13,7 +13,6 @@
 #include "base/memory/raw_ptr.h"
 #include "components/user_education/common/help_bubble_params.h"
 #include "components/user_education/common/tutorial_identifier.h"
-#include "third_party/abseil-cpp/absl/types/optional.h"
 #include "third_party/abseil-cpp/absl/types/variant.h"
 #include "ui/base/accelerators/accelerator.h"
 #include "ui/base/interaction/element_identifier.h"
@@ -82,6 +81,19 @@
     kLegacy,
   };
 
+  // Specifies the subtype of promo. Almost all promos will be `kNormal`; using
+  // some of the other special types requires being on an allowlist.
+  enum class PromoSubtype {
+    // A normal promo. Follows the default rules for when it can show.
+    kNormal,
+    // A promo designed to be shown in multiple apps (or webapps). Can show once
+    // per app.
+    kPerApp,
+    // A promo that must be able to be shown until explicitly acknowledged and
+    // dismissed by the user. This type requires being on an allowlist.
+    kLegalNotice
+  };
+
   // Represents a command or command accelerator. Can be valueless (falsy) if
   // neither a command ID nor an explicit accelerator is specified.
   class AcceleratorInfo {
@@ -209,6 +221,10 @@
   // Set the bubble arrow. Default is top-left.
   FeaturePromoSpecification& SetBubbleArrow(HelpBubbleArrow bubble_arrow);
 
+  // Set the promo subtype. Setting the subtype to LegalNotice requires being on
+  // an allowlist.
+  FeaturePromoSpecification& SetPromoSubtype(PromoSubtype promo_subtype);
+
   // Set the anchor element filter.
   FeaturePromoSpecification& SetAnchorElementFilter(
       AnchorElementFilter anchor_element_filter);
@@ -227,6 +243,7 @@
 
   const base::Feature* feature() const { return feature_; }
   PromoType promo_type() const { return promo_type_; }
+  PromoSubtype promo_subtype() const { return promo_subtype_; }
   ui::ElementIdentifier anchor_element_id() const { return anchor_element_id_; }
   const AnchorElementFilter& anchor_element_filter() const {
     return anchor_element_filter_;
@@ -264,6 +281,11 @@
     return custom_action_dismiss_string_id_;
   }
 
+  // Force the subtype to a particular value, bypassing permission checks.
+  void set_promo_subtype_for_testing(PromoSubtype promo_subtype) {
+    promo_subtype_ = promo_subtype;
+  }
+
  private:
   static constexpr HelpBubbleArrow kDefaultBubbleArrow =
       HelpBubbleArrow::kTopRight;
@@ -278,6 +300,9 @@
   // The type of promo. A promo with type kUnspecified is not valid.
   PromoType promo_type_ = PromoType::kUnspecified;
 
+  // The subtype of the promo.
+  PromoSubtype promo_subtype_ = PromoSubtype::kNormal;
+
   // The element identifier of the element to attach the promo to.
   ui::ElementIdentifier anchor_element_id_;
 
@@ -331,6 +356,11 @@
   int custom_action_dismiss_string_id_;
 };
 
+std::ostream& operator<<(std::ostream& oss,
+                         FeaturePromoSpecification::PromoType promo_type);
+std::ostream& operator<<(std::ostream& oss,
+                         FeaturePromoSpecification::PromoSubtype promo_subtype);
+
 }  // namespace user_education
 
 #endif  // COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_SPECIFICATION_H_
diff --git a/components/user_education/common/feature_promo_storage_service.cc b/components/user_education/common/feature_promo_storage_service.cc
new file mode 100644
index 0000000..1c00c0d
--- /dev/null
+++ b/components/user_education/common/feature_promo_storage_service.cc
@@ -0,0 +1,80 @@
+// Copyright 2020 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/user_education/common/feature_promo_storage_service.h"
+
+#include "base/feature_list.h"
+#include "third_party/abseil-cpp/absl/types/optional.h"
+
+namespace user_education {
+
+FeaturePromoStorageService::FeaturePromoStorageService() = default;
+FeaturePromoStorageService::~FeaturePromoStorageService() = default;
+
+int FeaturePromoStorageService::GetSnoozeCount(
+    const base::Feature& iph_feature) const {
+  const auto data = ReadPromoData(iph_feature);
+  return data ? data->snooze_count : 0;
+}
+
+std::set<std::string> FeaturePromoStorageService::GetShownForApps(
+    const base::Feature& iph_feature) const {
+  const auto data = ReadPromoData(iph_feature);
+  if (!data) {
+    return std::set<std::string>();
+  }
+
+  return data->shown_for_apps;
+}
+
+FeaturePromoStorageService::PromoData::PromoData() = default;
+FeaturePromoStorageService::PromoData::~PromoData() = default;
+FeaturePromoStorageService::PromoData::PromoData(const PromoData&) = default;
+FeaturePromoStorageService::PromoData::PromoData(PromoData&&) = default;
+FeaturePromoStorageService::PromoData&
+FeaturePromoStorageService::PromoData::operator=(const PromoData&) = default;
+FeaturePromoStorageService::PromoData&
+FeaturePromoStorageService::PromoData::operator=(PromoData&&) = default;
+
+std::ostream& operator<<(std::ostream& oss,
+                         FeaturePromoStorageService::CloseReason close_reason) {
+  switch (close_reason) {
+    case FeaturePromoStorageService::CloseReason::kDismiss:
+      oss << "kDismiss";
+      break;
+    case FeaturePromoStorageService::CloseReason::kSnooze:
+      oss << "kSnooze";
+      break;
+    case FeaturePromoStorageService::CloseReason::kAction:
+      oss << "kAction";
+      break;
+    case FeaturePromoStorageService::CloseReason::kCancel:
+      oss << "kCancel";
+      break;
+    case FeaturePromoStorageService::CloseReason::kTimeout:
+      oss << "kTimeout";
+      break;
+    case FeaturePromoStorageService::CloseReason::kAbortPromo:
+      oss << "kAbortPromo";
+      break;
+    case FeaturePromoStorageService::CloseReason::kFeatureEngaged:
+      oss << "kFeatureEngaged";
+      break;
+    case FeaturePromoStorageService::CloseReason::kOverrideForUIRegionConflict:
+      oss << "kOverrideForUIRegionConflict";
+      break;
+    case FeaturePromoStorageService::CloseReason::kOverrideForDemo:
+      oss << "kOverrideForDemo";
+      break;
+    case FeaturePromoStorageService::CloseReason::kOverrideForTesting:
+      oss << "kOverrideForTesting";
+      break;
+    case FeaturePromoStorageService::CloseReason::kOverrideForPrecedence:
+      oss << "kOverrideForPrecedence";
+      break;
+  }
+  return oss;
+}
+
+}  // namespace user_education
diff --git a/components/user_education/common/feature_promo_storage_service.h b/components/user_education/common/feature_promo_storage_service.h
new file mode 100644
index 0000000..5faa417
--- /dev/null
+++ b/components/user_education/common/feature_promo_storage_service.h
@@ -0,0 +1,106 @@
+// Copyright 2020 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_STORAGE_SERVICE_H_
+#define COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_STORAGE_SERVICE_H_
+
+#include <ostream>
+#include <set>
+
+#include "base/feature_list.h"
+#include "base/time/time.h"
+#include "third_party/abseil-cpp/absl/types/optional.h"
+
+// Declare in the global namespace for test purposes.
+class FeaturePromoStorageInteractiveTest;
+
+namespace user_education {
+
+// This service manages snooze and other display data for in-product help
+// promos.
+//
+// It is an abstract base class in order to support multiple frameworks/
+// platforms, and different data stores.
+//
+// Before showing an IPH, the IPH controller should ask if the IPH is blocked.
+// The controller should also notify after the IPH is shown and after the user
+// clicks the snooze/dismiss button.
+class FeaturePromoStorageService {
+ public:
+  FeaturePromoStorageService();
+  virtual ~FeaturePromoStorageService();
+
+  // Disallow copy and assign.
+  FeaturePromoStorageService(const FeaturePromoStorageService&) = delete;
+  FeaturePromoStorageService& operator=(const FeaturePromoStorageService&) =
+      delete;
+
+  // These values are persisted to logs. Entries should not be renumbered and
+  // numeric values should never be reused.
+  //
+  // Most of these values are internal to the FeaturePromoController. Only
+  // a few are made public to users (through FeaturePromoCloseReason) that
+  // might call EndPromo in order to provide context for emitting metrics.
+  enum CloseReason {
+    // Actions within the FeaturePromo.
+    kDismiss = 0,  // Promo dismissed by user.
+    kSnooze = 1,   // Promo snoozed by user.
+    kAction = 2,   // Custom action taken by user.
+    kCancel = 3,   // Promo was cancelled.
+
+    // Actions outside the FeaturePromo.
+    kTimeout = 4,         // Promo timed out.
+    kAbortPromo = 5,      // Promo aborted by indirect user action.
+    kFeatureEngaged = 6,  // Promo closed by indirect user engagement.
+
+    // Controller system actions.
+    kOverrideForUIRegionConflict = 7,  // Promo aborted to avoid overlap.
+    kOverrideForDemo = 8,              // Promo aborted by the demo system.
+    kOverrideForTesting = 9,           // Promo aborted for tests.
+    kOverrideForPrecedence = 10,  // Promo aborted for higher priority Promo.
+
+    kMaxValue = kOverrideForPrecedence,
+  };
+
+  // Dismissal and snooze information.
+  struct PromoData {
+    PromoData();
+    ~PromoData();
+    PromoData(const PromoData&);
+    PromoData(PromoData&&);
+    PromoData& operator=(const PromoData&);
+    PromoData& operator=(PromoData&&);
+
+    bool is_dismissed = false;
+    CloseReason last_dismissed_by = CloseReason::kCancel;
+    base::Time last_show_time = base::Time();
+    base::Time last_snooze_time = base::Time();
+    base::TimeDelta last_snooze_duration = base::TimeDelta();
+    int snooze_count = 0;
+    int show_count = 0;
+    std::set<std::string> shown_for_apps;
+  };
+
+  virtual absl::optional<PromoData> ReadPromoData(
+      const base::Feature& iph_feature) const = 0;
+
+  virtual void SavePromoData(const base::Feature& iph_feature,
+                             const PromoData& promo_data) = 0;
+
+  // Reset the state of |iph_feature|.
+  virtual void Reset(const base::Feature& iph_feature) = 0;
+
+  // Returns the set of apps that `iph_feature` has been shown for.
+  std::set<std::string> GetShownForApps(const base::Feature& iph_feature) const;
+
+  // Returns the count of previous snoozes for `iph_feature`.
+  int GetSnoozeCount(const base::Feature& iph_feature) const;
+};
+
+std::ostream& operator<<(std::ostream& oss,
+                         FeaturePromoStorageService::CloseReason close_reason);
+
+}  // namespace user_education
+
+#endif  // COMPONENTS_USER_EDUCATION_COMMON_FEATURE_PROMO_STORAGE_SERVICE_H_
diff --git a/components/user_education/common/feature_promo_storage_service_unittest.cc b/components/user_education/common/feature_promo_storage_service_unittest.cc
new file mode 100644
index 0000000..8cf0f5dfa
--- /dev/null
+++ b/components/user_education/common/feature_promo_storage_service_unittest.cc
@@ -0,0 +1,58 @@
+// Copyright 2020 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/user_education/common/feature_promo_storage_service.h"
+
+#include <memory>
+
+#include "base/feature_list.h"
+#include "components/user_education/test/test_feature_promo_storage_service.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace user_education {
+
+namespace {
+BASE_FEATURE(kTestIPHFeature,
+             "TestIPHFeature",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+BASE_FEATURE(kTestIPHFeature2,
+             "TestIPHFeature2",
+             base::FEATURE_ENABLED_BY_DEFAULT);
+}  // namespace
+
+TEST(FeaturePromoStorageServiceTest, GetSnoozeCount) {
+  test::TestFeaturePromoStorageService service;
+  EXPECT_EQ(0, service.GetSnoozeCount(kTestIPHFeature));
+  EXPECT_EQ(0, service.GetSnoozeCount(kTestIPHFeature2));
+  FeaturePromoStorageService::PromoData data;
+  data.snooze_count = 3;
+  service.SavePromoData(kTestIPHFeature, data);
+  EXPECT_EQ(3, service.GetSnoozeCount(kTestIPHFeature));
+  EXPECT_EQ(0, service.GetSnoozeCount(kTestIPHFeature2));
+  service.Reset(kTestIPHFeature);
+  EXPECT_EQ(0, service.GetSnoozeCount(kTestIPHFeature));
+  EXPECT_EQ(0, service.GetSnoozeCount(kTestIPHFeature2));
+}
+
+TEST(FeaturePromoStorageServiceTest, GetShownForApps) {
+  static constexpr char kAppName1[] = "App1";
+  static constexpr char kAppName2[] = "App2";
+
+  test::TestFeaturePromoStorageService service;
+  EXPECT_THAT(service.GetShownForApps(kTestIPHFeature), testing::IsEmpty());
+  EXPECT_THAT(service.GetShownForApps(kTestIPHFeature2), testing::IsEmpty());
+  FeaturePromoStorageService::PromoData data;
+  data.shown_for_apps.insert(kAppName1);
+  data.shown_for_apps.insert(kAppName2);
+  service.SavePromoData(kTestIPHFeature, data);
+  EXPECT_THAT(service.GetShownForApps(kTestIPHFeature),
+              testing::UnorderedElementsAre(kAppName1, kAppName2));
+  EXPECT_THAT(service.GetShownForApps(kTestIPHFeature2), testing::IsEmpty());
+  service.Reset(kTestIPHFeature);
+  EXPECT_THAT(service.GetShownForApps(kTestIPHFeature), testing::IsEmpty());
+  EXPECT_THAT(service.GetShownForApps(kTestIPHFeature2), testing::IsEmpty());
+}
+
+}  // namespace user_education
diff --git a/components/user_education/test/BUILD.gn b/components/user_education/test/BUILD.gn
index 37e6010..f0d784e 100644
--- a/components/user_education/test/BUILD.gn
+++ b/components/user_education/test/BUILD.gn
@@ -13,6 +13,8 @@
     "feature_promo_test_util.h",
     "mock_feature_promo_controller.cc",
     "mock_feature_promo_controller.h",
+    "test_feature_promo_storage_service.cc",
+    "test_feature_promo_storage_service.h",
     "test_help_bubble.cc",
     "test_help_bubble.h",
   ]
diff --git a/components/user_education/test/mock_feature_promo_controller.h b/components/user_education/test/mock_feature_promo_controller.h
index a26a6b46..db2f95134 100644
--- a/components/user_education/test/mock_feature_promo_controller.h
+++ b/components/user_education/test/mock_feature_promo_controller.h
@@ -9,6 +9,7 @@
 #include "base/memory/weak_ptr.h"
 #include "components/user_education/common/feature_promo_controller.h"
 #include "components/user_education/common/feature_promo_specification.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
 #include "testing/gmock/include/gmock/gmock.h"
 
 namespace user_education::test {
@@ -57,6 +58,11 @@
               FinishContinuedPromo,
               (const base::Feature& iph_feature),
               (override));
+  MOCK_METHOD(bool,
+              HasPromoBeenDismissed,
+              (const base::Feature& iph_feature,
+               FeaturePromoStorageService::CloseReason* close_reason),
+              (const, override));
 
   base::WeakPtr<FeaturePromoController> GetAsWeakPtr() override;
 
diff --git a/components/user_education/test/test_feature_promo_storage_service.cc b/components/user_education/test/test_feature_promo_storage_service.cc
new file mode 100644
index 0000000..3d0890c7
--- /dev/null
+++ b/components/user_education/test/test_feature_promo_storage_service.cc
@@ -0,0 +1,30 @@
+// Copyright 2023 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/user_education/test/test_feature_promo_storage_service.h"
+
+namespace user_education::test {
+
+TestFeaturePromoStorageService::TestFeaturePromoStorageService() = default;
+TestFeaturePromoStorageService::~TestFeaturePromoStorageService() = default;
+
+absl::optional<TestFeaturePromoStorageService::PromoData>
+TestFeaturePromoStorageService::ReadPromoData(
+    const base::Feature& iph_feature) const {
+  const auto it = promo_data_.find(&iph_feature);
+  return it == promo_data_.end() ? absl::nullopt
+                                 : absl::make_optional(it->second);
+}
+
+void TestFeaturePromoStorageService::SavePromoData(
+    const base::Feature& iph_feature,
+    const PromoData& promo_data) {
+  promo_data_[&iph_feature] = promo_data;
+}
+
+void TestFeaturePromoStorageService::Reset(const base::Feature& iph_feature) {
+  promo_data_.erase(&iph_feature);
+}
+
+}  // namespace user_education::test
diff --git a/components/user_education/test/test_feature_promo_storage_service.h b/components/user_education/test/test_feature_promo_storage_service.h
new file mode 100644
index 0000000..9006b35
--- /dev/null
+++ b/components/user_education/test/test_feature_promo_storage_service.h
@@ -0,0 +1,36 @@
+// Copyright 2023 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef COMPONENTS_USER_EDUCATION_TEST_TEST_FEATURE_PROMO_STORAGE_SERVICE_H_
+#define COMPONENTS_USER_EDUCATION_TEST_TEST_FEATURE_PROMO_STORAGE_SERVICE_H_
+
+#include <map>
+
+#include "base/feature_list.h"
+#include "components/user_education/common/feature_promo_storage_service.h"
+#include "third_party/abseil-cpp/absl/types/optional.h"
+
+namespace user_education::test {
+
+// Version of FeaturePromoStorageService that stores data in an in-memory map
+// for testing.
+class TestFeaturePromoStorageService : public FeaturePromoStorageService {
+ public:
+  TestFeaturePromoStorageService();
+  ~TestFeaturePromoStorageService() override;
+
+  // FeaturePromoStorageService:
+  absl::optional<PromoData> ReadPromoData(
+      const base::Feature& iph_feature) const override;
+  void SavePromoData(const base::Feature& iph_feature,
+                     const PromoData& promo_data) override;
+  void Reset(const base::Feature& iph_feature) override;
+
+ private:
+  std::map<const base::Feature*, PromoData> promo_data_;
+};
+
+}  // namespace user_education::test
+
+#endif  // COMPONENTS_USER_EDUCATION_TEST_TEST_FEATURE_PROMO_STORAGE_SERVICE_H_
diff --git a/components/user_education/views/help_bubble_view.cc b/components/user_education/views/help_bubble_view.cc
index f90923a2..751f428 100644
--- a/components/user_education/views/help_bubble_view.cc
+++ b/components/user_education/views/help_bubble_view.cc
@@ -753,6 +753,8 @@
             .WithAlignment(views::LayoutAlignment::kEnd));
     close_button_->SetProperty(views::kMarginsKey,
                                gfx::Insets::TLBR(0, default_spacing, 0, 0));
+    close_button_->SetProperty(views::kElementIdentifierKey,
+                               kCloseButtonIdForTesting);
   }
 
   // Icon view should have padding between it and the title or body label.