Save and restore tab groups

This implements basic session restore functionality for tab
groups. Currently, only whole browser session restore is supported;
restoring a window or a tab from the history menu won't restore
groups.

Bug: 905491
Change-Id: Ib6ad643c7efd75f468bb518fa5881b17a3d9f05f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1627947
Reviewed-by: Scott Violet <sky@chromium.org>
Reviewed-by: Bret Sepulveda <bsep@chromium.org>
Commit-Queue: Collin Baker <collinbaker@chromium.org>
Cr-Commit-Position: refs/heads/master@{#666500}
diff --git a/chrome/browser/sessions/session_restore.cc b/chrome/browser/sessions/session_restore.cc
index 6fc31f3..b858066 100644
--- a/chrome/browser/sessions/session_restore.cc
+++ b/chrome/browser/sessions/session_restore.cc
@@ -226,7 +226,7 @@
           use_new_window ? 0 : browser->tab_strip_model()->active_index() + 1;
       web_contents = chrome::AddRestoredTab(
           browser, tab.navigations, tab_index, selected_index,
-          tab.extension_app_id,
+          tab.extension_app_id, base::nullopt,
           disposition == WindowOpenDisposition::NEW_FOREGROUND_TAB,  // selected
           tab.pinned, true, base::TimeTicks(), nullptr, tab.user_agent_override,
           true /* from_session_restore */);
@@ -531,6 +531,11 @@
         latest_last_active_time = tab.last_active_time;
     }
 
+    // TODO(crbug.com/930991): Check that tab groups are contiguous in |window|
+    // to ensure tabs will not be reordered when restoring. This is not possible
+    // yet due the ordering of TabStripModelObserver notifications in an edge
+    // case.
+
     for (int i = 0; i < static_cast<int>(window.tabs.size()); ++i) {
       const sessions::SessionTab& tab = *(window.tabs[i]);
 
@@ -585,16 +590,14 @@
 
     WebContents* web_contents = chrome::AddRestoredTab(
         browser, tab.navigations, tab_index, selected_index,
-        tab.extension_app_id, is_selected_tab, tab.pinned, true,
+        tab.extension_app_id, tab.group, is_selected_tab, tab.pinned, true,
         last_active_time, session_storage_namespace.get(),
         tab.user_agent_override, true /* from_session_restore */);
-
-    // RestoreTab can return nullptr if |tab| doesn't have valid data.
-    if (!web_contents)
-      return;
+    DCHECK(web_contents);
 
     RestoredTab restored_tab(web_contents, is_selected_tab,
-                             tab.extension_app_id.empty(), tab.pinned);
+                             tab.extension_app_id.empty(), tab.pinned,
+                             tab.group);
     created_contents->push_back(restored_tab);
 
     // If this isn't the selected tab, there's nothing else to do.
diff --git a/chrome/browser/sessions/session_restore_browsertest.cc b/chrome/browser/sessions/session_restore_browsertest.cc
index 9a0b0a3..e50cb2cd0 100644
--- a/chrome/browser/sessions/session_restore_browsertest.cc
+++ b/chrome/browser/sessions/session_restore_browsertest.cc
@@ -9,6 +9,7 @@
 
 #include "base/base_switches.h"
 #include "base/command_line.h"
+#include "base/containers/span.h"
 #include "base/files/file_path.h"
 #include "base/files/scoped_temp_dir.h"
 #include "base/macros.h"
@@ -39,6 +40,7 @@
 #include "chrome/browser/ui/browser_navigator_params.h"
 #include "chrome/browser/ui/browser_tabstrip.h"
 #include "chrome/browser/ui/browser_window.h"
+#include "chrome/browser/ui/tabs/tab_group_id.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
 #include "chrome/common/chrome_switches.h"
 #include "chrome/common/url_constants.h"
@@ -872,6 +874,134 @@
             new_browser->tab_strip_model()->GetActiveWebContents()->GetURL());
 }
 
+namespace {
+
+// Groups the tabs in |model| according to |specified_groups|.
+void CreateTabGroups(TabStripModel* model,
+                     base::span<const base::Optional<int>> specified_groups) {
+  ASSERT_EQ(model->count(), static_cast<int>(specified_groups.size()));
+
+  // Maps |specified_groups| IDs to actual group IDs in |model|.
+  base::flat_map<int, TabGroupId> group_map;
+
+  for (int i = 0; i < model->count(); ++i) {
+    if (specified_groups[i] == base::nullopt)
+      continue;
+
+    const int specified_group = specified_groups[i].value();
+    auto match = group_map.find(specified_group);
+
+    // If |group_map| doesn't contain a value for |specified_group|, we can
+    // assume we haven't created the group yet.
+    if (match == group_map.end()) {
+      const TabGroupId actual_group = model->AddToNewGroup({i});
+      group_map.insert(std::make_pair(specified_group, actual_group));
+    } else {
+      const content::WebContents* const contents = model->GetWebContentsAt(i);
+      model->AddToExistingGroup({i}, match->second);
+      // Make sure we didn't move the tab.
+      EXPECT_EQ(contents, model->GetWebContentsAt(i));
+    }
+  }
+}
+
+// Checks that the grouping of tabs in |model| is equivalent to that specified
+// in |specified_groups| up to relabeling of the group IDs.
+void CheckTabGrouping(TabStripModel* model,
+                      base::span<const base::Optional<int>> specified_groups) {
+  ASSERT_EQ(model->count(), static_cast<int>(specified_groups.size()));
+
+  // Maps |specified_groups| IDs to actual group IDs in |model|.
+  base::flat_map<int, TabGroupId> group_map;
+
+  for (int i = 0; i < model->count(); ++i) {
+    SCOPED_TRACE(i);
+
+    const base::Optional<int> specified_group = specified_groups[i];
+    const base::Optional<TabGroupId> actual_group = model->GetTabGroupForTab(i);
+
+    // The tab should be grouped iff it's grouped in |specified_groups|.
+    EXPECT_EQ(actual_group.has_value(), specified_group.has_value());
+
+    if (actual_group.has_value() && specified_group.has_value()) {
+      auto match = group_map.find(specified_group.value());
+      if (match == group_map.end()) {
+        group_map.insert(
+            std::make_pair(specified_group.value(), actual_group.value()));
+      } else {
+        EXPECT_EQ(actual_group.value(), match->second);
+      }
+    }
+  }
+}
+
+// Returns the optional group ID for each tab in a vector.
+std::vector<base::Optional<TabGroupId>> GetTabGroups(
+    const TabStripModel* model) {
+  std::vector<base::Optional<TabGroupId>> result(model->count());
+  for (int i = 0; i < model->count(); ++i)
+    result[i] = model->GetTabGroupForTab(i);
+  return result;
+}
+
+}  // namespace
+
+IN_PROC_BROWSER_TEST_F(SessionRestoreTest, TabsWithGroups) {
+  constexpr int kNumTabs = 6;
+  const std::array<base::Optional<int>, kNumTabs> group_spec = {
+      0, 0, base::nullopt, base::nullopt, 1, 1};
+
+  // Open |kNumTabs| tabs.
+  ui_test_utils::NavigateToURL(browser(), url1_);
+  for (int i = 1; i < kNumTabs; ++i) {
+    ui_test_utils::NavigateToURLWithDisposition(
+        browser(), url1_, WindowOpenDisposition::NEW_FOREGROUND_TAB,
+        ui_test_utils::BROWSER_TEST_WAIT_FOR_NAVIGATION);
+  }
+  ASSERT_EQ(kNumTabs, browser()->tab_strip_model()->count());
+
+  CreateTabGroups(browser()->tab_strip_model(), group_spec);
+  ASSERT_NO_FATAL_FAILURE(
+      CheckTabGrouping(browser()->tab_strip_model(), group_spec));
+  const auto groups = GetTabGroups(browser()->tab_strip_model());
+
+  Browser* new_browser = QuitBrowserAndRestore(browser(), kNumTabs);
+  ASSERT_EQ(kNumTabs, new_browser->tab_strip_model()->count());
+  EXPECT_EQ(groups, GetTabGroups(new_browser->tab_strip_model()));
+}
+
+// Test that tab groups are restored correctly after the command set is rebuilt
+// from the browser state.
+IN_PROC_BROWSER_TEST_F(SessionRestoreTest, TabsWithGroupsCommandReset) {
+  constexpr int kNumTabs = 6;
+  const std::array<base::Optional<int>, kNumTabs> group_spec = {
+      0, 0, base::nullopt, base::nullopt, 1, 1};
+
+  // Open |kNumTabs| tabs.
+  ui_test_utils::NavigateToURL(browser(), url1_);
+  for (int i = 1; i < kNumTabs; ++i) {
+    ui_test_utils::NavigateToURLWithDisposition(
+        browser(), url1_, WindowOpenDisposition::NEW_FOREGROUND_TAB,
+        ui_test_utils::BROWSER_TEST_WAIT_FOR_NAVIGATION);
+  }
+  ASSERT_EQ(kNumTabs, browser()->tab_strip_model()->count());
+
+  CreateTabGroups(browser()->tab_strip_model(), group_spec);
+  ASSERT_NO_FATAL_FAILURE(
+      CheckTabGrouping(browser()->tab_strip_model(), group_spec));
+  const auto groups = GetTabGroups(browser()->tab_strip_model());
+
+  // Rebuild commands.
+  SessionService* const session_service =
+      SessionServiceFactory::GetForProfile(browser()->profile());
+  ASSERT_TRUE(session_service);
+  session_service->ResetFromCurrentBrowsers();
+
+  Browser* new_browser = QuitBrowserAndRestore(browser(), kNumTabs);
+  ASSERT_EQ(kNumTabs, new_browser->tab_strip_model()->count());
+  EXPECT_EQ(groups, GetTabGroups(new_browser->tab_strip_model()));
+}
+
 IN_PROC_BROWSER_TEST_F(SessionRestoreTest, RestoreAfterDelete) {
   ui_test_utils::NavigateToURL(browser(), url1_);
   ui_test_utils::NavigateToURL(browser(), url2_);
diff --git a/chrome/browser/sessions/session_restore_delegate.cc b/chrome/browser/sessions/session_restore_delegate.cc
index c5ca690..7a8cdaa 100644
--- a/chrome/browser/sessions/session_restore_delegate.cc
+++ b/chrome/browser/sessions/session_restore_delegate.cc
@@ -38,16 +38,21 @@
 
 }  // namespace
 
-SessionRestoreDelegate::RestoredTab::RestoredTab(content::WebContents* contents,
-                                                 bool is_active,
-                                                 bool is_app,
-                                                 bool is_pinned)
+SessionRestoreDelegate::RestoredTab::RestoredTab(
+    content::WebContents* contents,
+    bool is_active,
+    bool is_app,
+    bool is_pinned,
+    const base::Optional<base::Token>& group)
     : contents_(contents),
       is_active_(is_active),
       is_app_(is_app),
       is_internal_page_(IsInternalPage(contents->GetLastCommittedURL())),
-      is_pinned_(is_pinned) {
-}
+      is_pinned_(is_pinned),
+      group_(group) {}
+
+SessionRestoreDelegate::RestoredTab::RestoredTab(const RestoredTab& other) =
+    default;
 
 bool SessionRestoreDelegate::RestoredTab::operator<(
     const RestoredTab& right) const {
diff --git a/chrome/browser/sessions/session_restore_delegate.h b/chrome/browser/sessions/session_restore_delegate.h
index 9e5b021..4d0e69d 100644
--- a/chrome/browser/sessions/session_restore_delegate.h
+++ b/chrome/browser/sessions/session_restore_delegate.h
@@ -8,7 +8,10 @@
 #include <vector>
 
 #include "base/macros.h"
+#include "base/optional.h"
 #include "base/time/time.h"
+#include "base/token.h"
+#include "components/sessions/core/session_id.h"
 
 namespace content {
 class WebContents;
@@ -23,7 +26,9 @@
     RestoredTab(content::WebContents* contents,
                 bool is_active,
                 bool is_app,
-                bool is_pinned);
+                bool is_pinned,
+                const base::Optional<base::Token>& group);
+    RestoredTab(const RestoredTab& other);
 
     bool operator<(const RestoredTab& right) const;
 
@@ -32,6 +37,7 @@
     bool is_app() const { return is_app_; }
     bool is_internal_page() const { return is_internal_page_; }
     bool is_pinned() const { return is_pinned_; }
+    const base::Optional<base::Token>& group() const { return group_; }
 
    private:
     content::WebContents* contents_;
@@ -39,6 +45,9 @@
     bool is_app_;            // Browser window is an app.
     bool is_internal_page_;  // Internal web UI page, like NTP or Settings.
     bool is_pinned_;
+    // The ID for the tab group that this tab belonged to, if any. See
+    // |TabStripModel::AddToNewGroup()| for more documentation.
+    base::Optional<base::Token> group_;
   };
 
   static void RestoreTabs(const std::vector<RestoredTab>& tabs,
diff --git a/chrome/browser/sessions/session_restore_observer_unittest.cc b/chrome/browser/sessions/session_restore_observer_unittest.cc
index 4a3fdfd..d4f7891 100644
--- a/chrome/browser/sessions/session_restore_observer_unittest.cc
+++ b/chrome/browser/sessions/session_restore_observer_unittest.cc
@@ -83,7 +83,8 @@
   void SetUp() override {
     ChromeRenderViewHostTestHarness::SetUp();
     SetContents(CreateRestoredWebContents());
-    restored_tabs_.emplace_back(web_contents(), false, false, false);
+    restored_tabs_.emplace_back(web_contents(), false, false, false,
+                                base::nullopt);
   }
 
   void TearDown() override {
@@ -174,7 +175,7 @@
     different_test_contents.emplace_back(CreateRestoredWebContents());
     content::WebContents* test_contents = different_test_contents.back().get();
     std::vector<RestoredTab> restored_tabs{
-        RestoredTab(test_contents, false, false, false)};
+        RestoredTab(test_contents, false, false, false, base::nullopt)};
 
     SessionRestore::NotifySessionRestoreStartedLoadingTabs();
     SessionRestore::OnWillRestoreTab(test_contents);
@@ -198,7 +199,8 @@
 TEST_F(SessionRestoreObserverTest, ConcurrentSessionRestores) {
   std::vector<RestoredTab> another_restored_tabs;
   auto test_contents = CreateRestoredWebContents();
-  another_restored_tabs.emplace_back(test_contents.get(), false, false, false);
+  another_restored_tabs.emplace_back(test_contents.get(), false, false, false,
+                                     base::nullopt);
 
   SessionRestore::NotifySessionRestoreStartedLoadingTabs();
   SessionRestore::OnWillRestoreTab(web_contents());
@@ -226,7 +228,7 @@
 
   std::vector<SessionRestoreDelegate::RestoredTab> restored_tabs{
       SessionRestoreDelegate::RestoredTab(test_contents.get(), false, false,
-                                          false)};
+                                          false, base::nullopt)};
 
   resource_coordinator::TabManager* tab_manager =
       g_browser_process->GetTabManager();
diff --git a/chrome/browser/sessions/session_restore_stats_collector_unittest.cc b/chrome/browser/sessions/session_restore_stats_collector_unittest.cc
index a57ec4b4..f0a7c5c 100644
--- a/chrome/browser/sessions/session_restore_stats_collector_unittest.cc
+++ b/chrome/browser/sessions/session_restore_stats_collector_unittest.cc
@@ -278,7 +278,8 @@
     // Create a last active time in the past.
     content::WebContentsTester::For(contents)->SetLastActiveTime(
         base::TimeTicks::Now() - base::TimeDelta::FromMinutes(1));
-    restored_tabs_.push_back(RestoredTab(contents, is_active, false, false));
+    restored_tabs_.push_back(
+        RestoredTab(contents, is_active, false, false, base::nullopt));
     if (is_active)
       Show(restored_tabs_.size() - 1);
   }
diff --git a/chrome/browser/sessions/session_service.cc b/chrome/browser/sessions/session_service.cc
index 3add921..8e5be04 100644
--- a/chrome/browser/sessions/session_service.cc
+++ b/chrome/browser/sessions/session_service.cc
@@ -16,6 +16,7 @@
 #include "base/command_line.h"
 #include "base/metrics/histogram_macros.h"
 #include "base/pickle.h"
+#include "base/stl_util.h"
 #include "base/threading/thread.h"
 #include "build/build_config.h"
 #include "chrome/browser/background/background_mode_manager.h"
@@ -37,6 +38,7 @@
 #include "chrome/browser/ui/browser_tabstrip.h"
 #include "chrome/browser/ui/browser_window.h"
 #include "chrome/browser/ui/startup/startup_browser_creator.h"
+#include "chrome/browser/ui/tabs/tab_group_id.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
 #include "chrome/browser/web_applications/components/web_app_helpers.h"
 #include "components/sessions/content/content_serialized_navigation_builder.h"
@@ -198,6 +200,21 @@
       sessions::CreateSetTabIndexInWindowCommand(tab_id, new_index));
 }
 
+void SessionService::SetTabGroup(const SessionID& window_id,
+                                 const SessionID& tab_id,
+                                 base::Optional<base::Token> group) {
+  if (!ShouldTrackChangesToWindow(window_id))
+    return;
+
+  // Tabs get ungrouped as they close. However, if the whole window is closing
+  // tabs should stay in their groups. So, ignore this call in that case.
+  if (base::ContainsKey(pending_window_close_ids_, window_id) ||
+      base::ContainsKey(window_closing_ids_, window_id))
+    return;
+
+  ScheduleCommand(sessions::CreateTabGroupCommand(tab_id, group));
+}
+
 void SessionService::SetPinnedState(const SessionID& window_id,
                                     const SessionID& tab_id,
                                     bool is_pinned) {
@@ -464,7 +481,9 @@
   if (!ShouldTrackChangesToWindow(session_tab_helper->window_id()))
     return;
 
-  BuildCommandsForTab(session_tab_helper->window_id(), tab, -1, pinned, NULL);
+  // TODO(crbug.com/930991): handle tab groups here.
+  BuildCommandsForTab(session_tab_helper->window_id(), tab, -1, base::nullopt,
+                      pinned, NULL);
   base_session_service_->StartSaveTimer();
 }
 
@@ -642,6 +661,7 @@
 void SessionService::BuildCommandsForTab(const SessionID& window_id,
                                          WebContents* tab,
                                          int index_in_window,
+                                         base::Optional<base::Token> group,
                                          bool is_pinned,
                                          IdToRange* tab_to_available_range) {
   DCHECK(tab);
@@ -710,6 +730,11 @@
                                                    index_in_window));
   }
 
+  if (group.has_value()) {
+    base_session_service_->AppendRebuildCommand(
+        sessions::CreateTabGroupCommand(session_id, group));
+  }
+
   // Record the association between the sessionStorage namespace and the tab.
   content::SessionStorageNamespace* session_storage_namespace =
       tab->GetController().GetDefaultSessionStorageNamespace();
@@ -750,11 +775,12 @@
   for (int i = 0; i < tab_strip->count(); ++i) {
     WebContents* tab = tab_strip->GetWebContentsAt(i);
     DCHECK(tab);
-    BuildCommandsForTab(browser->session_id(),
-                        tab,
-                        i,
-                        tab_strip->IsTabPinned(i),
-                        tab_to_available_range);
+    const base::Optional<TabGroupId> group_id = tab_strip->GetTabGroupForTab(i);
+    const base::Optional<base::Token> raw_group_id =
+        group_id.has_value() ? base::make_optional(group_id.value().token())
+                             : base::nullopt;
+    BuildCommandsForTab(browser->session_id(), tab, i, raw_group_id,
+                        tab_strip->IsTabPinned(i), tab_to_available_range);
   }
 
   base_session_service_->AppendRebuildCommand(
diff --git a/chrome/browser/sessions/session_service.h b/chrome/browser/sessions/session_service.h
index f6fb5ae..f4b2daef 100644
--- a/chrome/browser/sessions/session_service.h
+++ b/chrome/browser/sessions/session_service.h
@@ -13,8 +13,10 @@
 #include "base/gtest_prod_util.h"
 #include "base/macros.h"
 #include "base/memory/weak_ptr.h"
+#include "base/optional.h"
 #include "base/task/cancelable_task_tracker.h"
 #include "base/time/time.h"
+#include "base/token.h"
 #include "chrome/browser/defaults.h"
 #include "chrome/browser/sessions/session_common_utils.h"
 #include "chrome/browser/sessions/session_service_utils.h"
@@ -129,6 +131,11 @@
                            const SessionID& tab_id,
                            int new_index);
 
+  // Sets a tab's group ID, if any.
+  void SetTabGroup(const SessionID& window_id,
+                   const SessionID& tab_id,
+                   base::Optional<base::Token> group);
+
   // Sets the pinned state of the tab.
   void SetPinnedState(const SessionID& window_id,
                       const SessionID& tab_id,
@@ -268,12 +275,12 @@
   // direction from the current navigation index).
   // A pair is added to tab_to_available_range indicating the range of
   // indices that were written.
-  void BuildCommandsForTab(
-      const SessionID& window_id,
-      content::WebContents* tab,
-      int index_in_window,
-      bool is_pinned,
-      IdToRange* tab_to_available_range);
+  void BuildCommandsForTab(const SessionID& window_id,
+                           content::WebContents* tab,
+                           int index_in_window,
+                           base::Optional<base::Token> group,
+                           bool is_pinned,
+                           IdToRange* tab_to_available_range);
 
   // Adds commands to create the specified browser, and invokes
   // BuildCommandsForTab for each of the tabs in the browser. This ignores
diff --git a/chrome/browser/sessions/session_service_unittest.cc b/chrome/browser/sessions/session_service_unittest.cc
index c09e9cd6..89eef283 100644
--- a/chrome/browser/sessions/session_service_unittest.cc
+++ b/chrome/browser/sessions/session_service_unittest.cc
@@ -21,6 +21,7 @@
 #include "base/synchronization/atomic_flag.h"
 #include "base/threading/platform_thread.h"
 #include "base/time/time.h"
+#include "base/token.h"
 #include "build/build_config.h"
 #include "chrome/browser/browser_process.h"
 #include "chrome/browser/defaults.h"
@@ -39,7 +40,9 @@
 #include "components/sessions/core/session_command.h"
 #include "components/sessions/core/session_types.h"
 #include "content/public/browser/navigation_entry.h"
+#include "content/public/browser/web_contents.h"
 #include "content/public/common/page_state.h"
+#include "content/public/test/web_contents_tester.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 using content::NavigationEntry;
@@ -87,6 +90,16 @@
     }
   }
 
+  SessionID CreateTabWithTestNavigationData(SessionID window_id,
+                                            int visual_index) {
+    const SessionID tab_id = SessionID::NewUnique();
+    const SerializedNavigationEntry nav =
+        SerializedNavigationEntryTestHelper::CreateNavigationForTest();
+    helper_.PrepareTabInWindow(window_id, tab_id, visual_index, true);
+    UpdateNavigation(window_id, tab_id, nav, true);
+    return tab_id;
+  }
+
   void ReadWindows(
       std::vector<std::unique_ptr<sessions::SessionWindow>>* windows,
       SessionID* active_window_id) {
@@ -1164,6 +1177,46 @@
   helper_.AssertNavigationEquals(nav1, tab->navigations[0]);
 }
 
+TEST_F(SessionServiceTest, TabGroupDefaultsToNone) {
+  CreateTabWithTestNavigationData(window_id, 0);
+
+  std::vector<std::unique_ptr<sessions::SessionWindow>> windows;
+  ReadWindows(&windows, nullptr);
+
+  ASSERT_EQ(1U, windows.size());
+  ASSERT_EQ(1U, windows[0]->tabs.size());
+
+  // Verify that the recorded tab has no group.
+  sessions::SessionTab* tab = windows[0]->tabs[0].get();
+  EXPECT_EQ(base::nullopt, tab->group);
+}
+
+TEST_F(SessionServiceTest, TabGroupIdsSaved) {
+  const auto group1_token = base::Token::CreateRandom();
+  const auto group2_token = base::Token::CreateRandom();
+  constexpr int kNumTabs = 5;
+  const std::array<base::Optional<base::Token>, kNumTabs> groups = {
+      base::nullopt, group1_token, group1_token, base::nullopt, group2_token};
+
+  // Create |kNumTabs| tabs with group IDs in |groups|.
+  for (int tab_ndx = 0; tab_ndx < kNumTabs; ++tab_ndx) {
+    const SessionID tab_id =
+        CreateTabWithTestNavigationData(window_id, tab_ndx);
+    service()->SetTabGroup(window_id, tab_id, groups[tab_ndx]);
+  }
+
+  std::vector<std::unique_ptr<sessions::SessionWindow>> windows;
+  ReadWindows(&windows, nullptr);
+
+  ASSERT_EQ(1U, windows.size());
+  ASSERT_EQ(kNumTabs, static_cast<int>(windows[0]->tabs.size()));
+
+  for (int tab_ndx = 0; tab_ndx < kNumTabs; ++tab_ndx) {
+    sessions::SessionTab* tab = windows[0]->tabs[tab_ndx].get();
+    EXPECT_EQ(groups[tab_ndx], tab->group);
+  }
+}
+
 // Functions used by GetSessionsAndDestroy.
 namespace {
 
diff --git a/chrome/browser/sessions/tab_loader_unittest.cc b/chrome/browser/sessions/tab_loader_unittest.cc
index 8d4c561..4f79c90 100644
--- a/chrome/browser/sessions/tab_loader_unittest.cc
+++ b/chrome/browser/sessions/tab_loader_unittest.cc
@@ -103,9 +103,9 @@
     // TabLoadTracker needs the resource_coordinator WebContentsData to be
     // initialized.
     ResourceCoordinatorTabHelper::CreateForWebContents(test_contents);
-    restored_tabs_.push_back(
-        RestoredTab(test_contents, is_active /* is_active */,
-                    false /* is_app */, false /* is_pinned */));
+    restored_tabs_.push_back(RestoredTab(
+        test_contents, is_active /* is_active */, false /* is_app */,
+        false /* is_pinned */, base::nullopt /* group */));
 
     // If the tab is active start "loading" it right away for consistency with
     // session restore code.
diff --git a/chrome/browser/ui/browser.cc b/chrome/browser/ui/browser.cc
index 54f5f800..e9165bee 100644
--- a/chrome/browser/ui/browser.cc
+++ b/chrome/browser/ui/browser.cc
@@ -1026,9 +1026,12 @@
                       replace->index);
       break;
     }
-    case TabStripModelChange::kGroupChanged:
-      // TODO(crbug.com/930991): Save Tab Group data to SessionService.
+    case TabStripModelChange::kGroupChanged: {
+      auto* group_change = change.GetGroupChange();
+      OnTabGroupChanged(group_change->index, group_change->old_group,
+                        group_change->new_group);
       break;
+    }
     case TabStripModelChange::kSelectionOnly:
       break;
   }
@@ -2261,6 +2264,24 @@
   }
 }
 
+void Browser::OnTabGroupChanged(int index,
+                                base::Optional<TabGroupId> old_group,
+                                base::Optional<TabGroupId> new_group) {
+  content::WebContents* const web_contents =
+      tab_strip_model_->GetWebContentsAt(index);
+  SessionService* const session_service =
+      SessionServiceFactory::GetForProfile(profile_);
+  if (session_service) {
+    SessionTabHelper* const session_tab_helper =
+        SessionTabHelper::FromWebContents(web_contents);
+    const base::Optional<base::Token> raw_id =
+        new_group.has_value() ? base::make_optional(new_group.value().token())
+                              : base::nullopt;
+    session_service->SetTabGroup(session_id(), session_tab_helper->session_id(),
+                                 raw_id);
+  }
+}
+
 void Browser::OnDevToolsAvailabilityChanged() {
   using DTPH = policy::DeveloperToolsPolicyHandler;
   // We close all windows as a safety measure, even for
@@ -2429,6 +2450,15 @@
         session_service->SetPinnedState(session_id(),
                                         session_tab_helper->session_id(),
                                         tab_strip_model_->IsTabPinned(i));
+
+        base::Optional<TabGroupId> group_id =
+            tab_strip_model_->GetTabGroupForTab(i);
+        base::Optional<base::Token> raw_group_id =
+            group_id.has_value()
+                ? base::Optional<base::Token>(group_id.value().token())
+                : base::nullopt;
+        session_service->SetTabGroup(
+            session_id(), session_tab_helper->session_id(), raw_group_id);
       }
     }
   }
diff --git a/chrome/browser/ui/browser.h b/chrome/browser/ui/browser.h
index c527ad1..5483fef1 100644
--- a/chrome/browser/ui/browser.h
+++ b/chrome/browser/ui/browser.h
@@ -836,6 +836,9 @@
   void OnTabReplacedAt(content::WebContents* old_contents,
                        content::WebContents* new_contents,
                        int index);
+  void OnTabGroupChanged(int index,
+                         base::Optional<TabGroupId> old_group,
+                         base::Optional<TabGroupId> new_group);
 
   // Handle changes to kDevToolsAvailability preference.
   void OnDevToolsAvailabilityChanged();
diff --git a/chrome/browser/ui/browser_live_tab_context.cc b/chrome/browser/ui/browser_live_tab_context.cc
index 81d6154..a19d07d 100644
--- a/chrome/browser/ui/browser_live_tab_context.cc
+++ b/chrome/browser/ui/browser_live_tab_context.cc
@@ -90,8 +90,8 @@
 
   WebContents* web_contents = chrome::AddRestoredTab(
       browser_, navigations, tab_index, selected_navigation, extension_app_id,
-      select, pin, from_last_session, base::TimeTicks(), storage_namespace,
-      user_agent_override, false /* from_session_restore */);
+      base::nullopt, select, pin, from_last_session, base::TimeTicks(),
+      storage_namespace, user_agent_override, false /* from_session_restore */);
 
 #if BUILDFLAG(ENABLE_SESSION_SERVICE)
   // The focused tab will be loaded by Browser, and TabLoader will load the
@@ -104,7 +104,7 @@
   }
   std::vector<TabLoader::RestoredTab> restored_tabs;
   restored_tabs.emplace_back(web_contents, select, !extension_app_id.empty(),
-                             pin);
+                             pin, base::nullopt);
   TabLoader::RestoreTabs(restored_tabs, base::TimeTicks::Now());
 #else   // BUILDFLAG(ENABLE_SESSION_SERVICE)
   // Load the tab manually if there is no TabLoader.
diff --git a/chrome/browser/ui/browser_tabrestore.cc b/chrome/browser/ui/browser_tabrestore.cc
index 055965a..2b5f55bc 100644
--- a/chrome/browser/ui/browser_tabrestore.cc
+++ b/chrome/browser/ui/browser_tabrestore.cc
@@ -15,6 +15,7 @@
 #include "chrome/browser/tab_contents/tab_util.h"
 #include "chrome/browser/ui/browser.h"
 #include "chrome/browser/ui/browser_window.h"
+#include "chrome/browser/ui/tabs/tab_group_id.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
 #include "chrome/browser/ui/web_contents_sizer.h"
 #include "components/sessions/content/content_serialized_navigation_builder.h"
@@ -100,6 +101,7 @@
     int tab_index,
     int selected_navigation,
     const std::string& extension_app_id,
+    base::Optional<base::Token> raw_group_id,
     bool select,
     bool pin,
     bool from_last_session,
@@ -118,9 +120,16 @@
         tab_index, browser->tab_strip_model()->IndexOfFirstNonPinnedTab());
     add_types |= TabStripModel::ADD_PINNED;
   }
+
   WebContents* raw_web_contents = web_contents.get();
-  browser->tab_strip_model()->InsertWebContentsAt(
+  const int actual_index = browser->tab_strip_model()->InsertWebContentsAt(
       tab_index, std::move(web_contents), add_types);
+
+  if (raw_group_id.has_value()) {
+    auto group_id = TabGroupId::FromRawToken(raw_group_id.value());
+    browser->tab_strip_model()->AddToGroupForRestore({actual_index}, group_id);
+  }
+
   if (select) {
     if (!browser->window()->IsMinimized())
       browser->window()->Activate();
diff --git a/chrome/browser/ui/browser_tabrestore.h b/chrome/browser/ui/browser_tabrestore.h
index 451ead1..78fdbb6 100644
--- a/chrome/browser/ui/browser_tabrestore.h
+++ b/chrome/browser/ui/browser_tabrestore.h
@@ -8,6 +8,7 @@
 #include <string>
 #include <vector>
 
+#include "base/token.h"
 #include "components/sessions/core/session_types.h"
 
 class Browser;
@@ -28,11 +29,12 @@
 // |tab_index| gives the index to insert the tab at. |selected_navigation| is
 // the index of the SerializedNavigationEntry in |navigations| to select. If
 // |extension_app_id| is non-empty the tab is an app tab and |extension_app_id|
-// is the id of the extension. If |pin| is true and |tab_index|/ is the last
-// pinned tab, then the newly created tab is pinned. If |from_last_session| is
-// true, |navigations| are from the previous session. |user_agent_override|
-// contains the string being used as the user agent for all of the tab's
-// navigations when the regular user agent is overridden. If
+// is the id of the extension. If |raw_group_id| has a value, it specifies the
+// token corresponding to the tab's group. If |pin| is true and |tab_index|/ is
+// the last pinned tab, then the newly created tab is pinned. If
+// |from_last_session| is true, |navigations| are from the previous session.
+// |user_agent_override| contains the string being used as the user agent for
+// all of the tab's navigations when the regular user agent is overridden. If
 // |from_session_restore| is true, the restored tab is created by session
 // restore. |last_active_time| is the value to use to indicate the last time the
 // WebContents was made active, if this is left default initialized then the
@@ -43,6 +45,7 @@
     int tab_index,
     int selected_navigation,
     const std::string& extension_app_id,
+    base::Optional<base::Token> raw_group_id,
     bool select,
     bool pin,
     bool from_last_session,
diff --git a/chrome/browser/ui/tabs/tab_group_id.cc b/chrome/browser/ui/tabs/tab_group_id.cc
index fdb59328..80f07f2f 100644
--- a/chrome/browser/ui/tabs/tab_group_id.cc
+++ b/chrome/browser/ui/tabs/tab_group_id.cc
@@ -9,6 +9,11 @@
   return TabGroupId(base::Token::CreateRandom());
 }
 
+// static
+TabGroupId TabGroupId::FromRawToken(base::Token token) {
+  return TabGroupId(token);
+}
+
 TabGroupId::TabGroupId(const TabGroupId& other) = default;
 
 TabGroupId& TabGroupId::operator=(const TabGroupId& other) = default;
diff --git a/chrome/browser/ui/tabs/tab_group_id.h b/chrome/browser/ui/tabs/tab_group_id.h
index 04bc9e6b..d61a8fd 100644
--- a/chrome/browser/ui/tabs/tab_group_id.h
+++ b/chrome/browser/ui/tabs/tab_group_id.h
@@ -11,6 +11,10 @@
  public:
   static TabGroupId GenerateNew();
 
+  // This should only called with |token| returned from a previous |token()|
+  // call on a valid TabGroupId.
+  static TabGroupId FromRawToken(base::Token token);
+
   TabGroupId(const TabGroupId& other);
 
   TabGroupId& operator=(const TabGroupId& other);
diff --git a/chrome/browser/ui/tabs/tab_strip_model.cc b/chrome/browser/ui/tabs/tab_strip_model.cc
index 1a1916c..c5c87a4 100644
--- a/chrome/browser/ui/tabs/tab_strip_model.cc
+++ b/chrome/browser/ui/tabs/tab_strip_model.cc
@@ -769,7 +769,7 @@
 }
 
 bool TabStripModel::IsTabPinned(int index) const {
-  DCHECK(ContainsIndex(index));
+  DCHECK(ContainsIndex(index)) << index;
   return contents_data_[index]->pinned();
 }
 
@@ -981,39 +981,17 @@
   MoveWebContentsAt(active_index(), new_index, true);
 }
 
-void TabStripModel::AddToNewGroup(const std::vector<int>& indices) {
+TabGroupId TabStripModel::AddToNewGroup(const std::vector<int>& indices) {
   DCHECK(!reentrancy_guard_);
   base::AutoReset<bool> resetter(&reentrancy_guard_, true);
 
+  // The odds of |new_group| colliding with an existing group are astronomically
+  // low. If there is a collision, a DCHECK will fail in |AddToNewGroupImpl()|,
+  // in which case there is probably something wrong with
+  // |TabGroupId::GenerateNew()|.
   const TabGroupId new_group = TabGroupId::GenerateNew();
-  // If the random generator is working correctly, the odds of this happening
-  // are astronomically low. If this DCHECK fails, something is probably wrong
-  // in |TabGroupId::GenerateNew()|.
-  DCHECK(!std::any_of(
-      contents_data_.cbegin(), contents_data_.cend(),
-      [new_group](const auto& datum) { return datum->group() == new_group; }));
-
-  group_data_[new_group] = std::make_unique<TabGroupData>();
-
-  // Find a destination for the first tab that's not inside another group. We
-  // will stack the rest of the tabs up to its right.
-  int destination_index = -1;
-  for (int i = indices[0]; i < count(); i++) {
-    const int destination_candidate = i + 1;
-    const bool end_of_strip = !ContainsIndex(destination_candidate);
-    if (end_of_strip || !GetTabGroupForTab(destination_candidate).has_value() ||
-        GetTabGroupForTab(destination_candidate) !=
-            GetTabGroupForTab(indices[0])) {
-      destination_index = destination_candidate;
-      break;
-    }
-  }
-
-  std::vector<int> new_indices = indices;
-  if (IsTabPinned(new_indices[0]))
-    new_indices = SetTabsPinned(new_indices, true);
-
-  MoveTabsIntoGroup(new_indices, destination_index, new_group);
+  AddToNewGroupImpl(indices, new_group);
+  return new_group;
 }
 
 void TabStripModel::AddToExistingGroup(const std::vector<int>& indices,
@@ -1021,25 +999,19 @@
   DCHECK(!reentrancy_guard_);
   base::AutoReset<bool> resetter(&reentrancy_guard_, true);
 
-  int destination_index = -1;
-  bool pin = false;
-  for (int i = contents_data_.size() - 1; i >= 0; i--) {
-    if (contents_data_[i]->group() == group) {
-      destination_index = i + 1;
-      pin = IsTabPinned(i);
-      break;
-    }
-  }
+  AddToExistingGroupImpl(indices, group);
+}
 
-  // Ignore indices that are already in the group.
-  std::vector<int> new_indices;
-  for (int candidate_index : indices) {
-    if (GetTabGroupForTab(candidate_index) != group)
-      new_indices.push_back(candidate_index);
-  }
-  new_indices = SetTabsPinned(new_indices, pin);
+void TabStripModel::AddToGroupForRestore(const std::vector<int>& indices,
+                                         TabGroupId group) {
+  DCHECK(!reentrancy_guard_);
+  base::AutoReset<bool> resetter(&reentrancy_guard_, true);
 
-  MoveTabsIntoGroup(new_indices, destination_index, group);
+  const bool group_exists = base::ContainsKey(group_data_, group);
+  if (group_exists)
+    AddToExistingGroupImpl(indices, group);
+  else
+    AddToNewGroupImpl(indices, group);
 }
 
 void TabStripModel::RemoveFromGroup(const std::vector<int>& indices) {
@@ -1767,6 +1739,58 @@
   }
 }
 
+void TabStripModel::AddToNewGroupImpl(const std::vector<int>& indices,
+                                      TabGroupId new_group) {
+  DCHECK(!std::any_of(
+      contents_data_.cbegin(), contents_data_.cend(),
+      [new_group](const auto& datum) { return datum->group() == new_group; }));
+
+  group_data_[new_group] = std::make_unique<TabGroupData>();
+
+  // Find a destination for the first tab that's not inside another group. We
+  // will stack the rest of the tabs up to its right.
+  int destination_index = -1;
+  for (int i = indices[0]; i < count(); i++) {
+    const int destination_candidate = i + 1;
+    const bool end_of_strip = !ContainsIndex(destination_candidate);
+    if (end_of_strip || !GetTabGroupForTab(destination_candidate).has_value() ||
+        GetTabGroupForTab(destination_candidate) !=
+            GetTabGroupForTab(indices[0])) {
+      destination_index = destination_candidate;
+      break;
+    }
+  }
+
+  std::vector<int> new_indices = indices;
+  if (IsTabPinned(new_indices[0]))
+    new_indices = SetTabsPinned(new_indices, true);
+
+  MoveTabsIntoGroup(new_indices, destination_index, new_group);
+}
+
+void TabStripModel::AddToExistingGroupImpl(const std::vector<int>& indices,
+                                           TabGroupId group) {
+  int destination_index = -1;
+  bool pin = false;
+  for (int i = contents_data_.size() - 1; i >= 0; i--) {
+    if (contents_data_[i]->group() == group) {
+      destination_index = i + 1;
+      pin = IsTabPinned(i);
+      break;
+    }
+  }
+
+  // Ignore indices that are already in the group.
+  std::vector<int> new_indices;
+  for (int candidate_index : indices) {
+    if (GetTabGroupForTab(candidate_index) != group)
+      new_indices.push_back(candidate_index);
+  }
+  new_indices = SetTabsPinned(new_indices, pin);
+
+  MoveTabsIntoGroup(new_indices, destination_index, group);
+}
+
 void TabStripModel::MoveTabsIntoGroup(const std::vector<int>& indices,
                                       int destination_index,
                                       TabGroupId group) {
diff --git a/chrome/browser/ui/tabs/tab_strip_model.h b/chrome/browser/ui/tabs/tab_strip_model.h
index 5d9b6fa..3b22c4cb 100644
--- a/chrome/browser/ui/tabs/tab_strip_model.h
+++ b/chrome/browser/ui/tabs/tab_strip_model.h
@@ -392,10 +392,10 @@
 
   // Create a new tab group and add the set of tabs pointed to be |indices| to
   // it. Pins all of the tabs if any of them were pinned, and reorders the tabs
-  // so they are contiguous and do not split an existing group in half.
-  // |indices| must be sorted in ascending order. This feature is in development
-  // and gated behind a feature flag. https://crbug.com/915956.
-  void AddToNewGroup(const std::vector<int>& indices);
+  // so they are contiguous and do not split an existing group in half. Returns
+  // the new group. |indices| must be sorted in ascending order. This feature is
+  // in development and gated behind a feature flag. https://crbug.com/915956.
+  TabGroupId AddToNewGroup(const std::vector<int>& indices);
 
   // Add the set of tabs pointed to by |indices| to the given tab group |group|.
   // The tabs take on the pinnedness of the tabs already in the group, and are
@@ -404,6 +404,11 @@
   // behind a feature flag (see https://crbug.com/915956).
   void AddToExistingGroup(const std::vector<int>& indices, TabGroupId group);
 
+  // Similar to AddToExistingGroup(), but creates a group with id |group| if it
+  // doesn't exist. This is only intended to be called from session restore
+  // code.
+  void AddToGroupForRestore(const std::vector<int>& indices, TabGroupId group);
+
   // Removes the set of tabs pointed to by |indices| from the the groups they
   // are in, if any. The tabs are moved out of the group if necessary. |indices|
   // must be sorted in ascending order. This feature is in development and gated
@@ -609,6 +614,15 @@
   // starting at |start| to |index|. See MoveSelectedTabsTo for more details.
   void MoveSelectedTabsToImpl(int index, size_t start, size_t length);
 
+  // Adds tabs to newly-allocated group id |new_group|. This group must be new
+  // and have no tabs in it.
+  void AddToNewGroupImpl(const std::vector<int>& indices, TabGroupId new_group);
+
+  // Adds tabs to existing group |group|. This group must have been initialized
+  // by a previous call to |AddToNewGroupImpl()|.
+  void AddToExistingGroupImpl(const std::vector<int>& indices,
+                              TabGroupId group);
+
   // Moves the set of tabs indicated by |indices| to precede the tab at index
   // |destination_index|, maintaining their order and the order of tabs not
   // being moved, and adds them to the tab group |group|.
diff --git a/components/sessions/core/session_service_commands.cc b/components/sessions/core/session_service_commands.cc
index 159accb..99a3301 100644
--- a/components/sessions/core/session_service_commands.cc
+++ b/components/sessions/core/session_service_commands.cc
@@ -12,6 +12,7 @@
 
 #include "base/memory/ptr_util.h"
 #include "base/pickle.h"
+#include "base/token.h"
 #include "components/sessions/core/base_session_service_commands.h"
 #include "components/sessions/core/base_session_service_delegate.h"
 #include "components/sessions/core/session_command.h"
@@ -56,6 +57,7 @@
 // static const SessionCommand::id_type kCommandSetWindowWorkspace = 22;
 static const SessionCommand::id_type kCommandSetWindowWorkspace2 = 23;
 static const SessionCommand::id_type kCommandTabNavigationPathPruned = 24;
+static const SessionCommand::id_type kCommandSetTabGroup = 25;
 
 namespace {
 
@@ -110,6 +112,18 @@
   int32_t count;
 };
 
+struct SerializedTabGroupId {
+  // These fields correspond to the high and low fields of |base::Token|.
+  uint64_t id_high;
+  uint64_t id_low;
+};
+
+struct TabGroupPayload {
+  SessionID::id_type tab_id;
+  SerializedTabGroupId maybe_group;
+  bool has_group;
+};
+
 struct PinnedStatePayload {
   SessionID::id_type tab_id;
   bool pinned_state;
@@ -544,6 +558,21 @@
         break;
       }
 
+      case kCommandSetTabGroup: {
+        TabGroupPayload payload;
+        if (!command->GetPayload(&payload, sizeof(payload))) {
+          DVLOG(1) << "Failed reading command " << command->id();
+          return true;
+        }
+        SessionTab* session_tab =
+            GetTab(SessionID::FromSerializedValue(payload.tab_id), tabs);
+        const base::Token token(payload.maybe_group.id_high,
+                                payload.maybe_group.id_low);
+        session_tab->group =
+            payload.has_group ? base::make_optional(token) : base::nullopt;
+        break;
+      }
+
       case kCommandSetPinnedState: {
         PinnedStatePayload payload;
         if (!command->GetPayload(&payload, sizeof(payload))) {
@@ -745,6 +774,20 @@
   return CreateSessionCommandForPayload(kCommandSetWindowType, payload);
 }
 
+std::unique_ptr<SessionCommand> CreateTabGroupCommand(
+    const SessionID& tab_id,
+    base::Optional<base::Token> group) {
+  TabGroupPayload payload = {0};
+  payload.tab_id = tab_id.id();
+  if (group.has_value()) {
+    DCHECK(!group.value().is_zero());
+    payload.maybe_group.id_high = group.value().high();
+    payload.maybe_group.id_low = group.value().low();
+    payload.has_group = true;
+  }
+  return CreateSessionCommandForPayload(kCommandSetTabGroup, payload);
+}
+
 std::unique_ptr<SessionCommand> CreatePinnedStateCommand(
     const SessionID& tab_id,
     bool is_pinned) {
diff --git a/components/sessions/core/session_service_commands.h b/components/sessions/core/session_service_commands.h
index 0ed6fa72..ce0a0c6 100644
--- a/components/sessions/core/session_service_commands.h
+++ b/components/sessions/core/session_service_commands.h
@@ -11,7 +11,9 @@
 
 #include "base/callback.h"
 #include "base/memory/weak_ptr.h"
+#include "base/optional.h"
 #include "base/task/cancelable_task_tracker.h"
+#include "base/token.h"
 #include "components/sessions/core/base_session_service.h"
 #include "components/sessions/core/session_types.h"
 #include "components/sessions/core/sessions_export.h"
@@ -44,6 +46,9 @@
 SESSIONS_EXPORT std::unique_ptr<SessionCommand> CreateSetWindowTypeCommand(
     const SessionID& window_id,
     SessionWindow::WindowType type);
+SESSIONS_EXPORT std::unique_ptr<SessionCommand> CreateTabGroupCommand(
+    const SessionID& tab_id,
+    base::Optional<base::Token> group);
 SESSIONS_EXPORT std::unique_ptr<SessionCommand> CreatePinnedStateCommand(
     const SessionID& tab_id,
     bool is_pinned);
diff --git a/components/sessions/core/session_types.h b/components/sessions/core/session_types.h
index b161ca5..b160f2e9 100644
--- a/components/sessions/core/session_types.h
+++ b/components/sessions/core/session_types.h
@@ -11,8 +11,10 @@
 #include <vector>
 
 #include "base/macros.h"
+#include "base/optional.h"
 #include "base/strings/string16.h"
 #include "base/time/time.h"
+#include "base/token.h"
 #include "components/sessions/core/serialized_navigation_entry.h"
 #include "components/sessions/core/session_id.h"
 #include "components/sessions/core/sessions_export.h"
@@ -65,6 +67,9 @@
   // checking must be performed before indexing into |navigations|.
   int current_navigation_index;
 
+  // The tab's group ID, if any.
+  base::Optional<base::Token> group;
+
   // True if the tab is pinned.
   bool pinned;