chromium/ios/chrome/browser/saved_tab_groups/model/ios_tab_group_sync_delegate.mm

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/browser/saved_tab_groups/model/ios_tab_group_sync_delegate.h"

#import <vector>

#import "base/check.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "base/uuid.h"
#import "components/saved_tab_groups/saved_tab_group_tab.h"
#import "components/saved_tab_groups/tab_group_sync_service.h"
#import "components/saved_tab_groups/types.h"
#import "components/saved_tab_groups/utils.h"
#import "components/tab_groups/tab_group_id.h"
#import "components/tab_groups/tab_group_visual_data.h"
#import "ios/chrome/app/application_delegate/app_state.h"
#import "ios/chrome/browser/saved_tab_groups/model/ios_tab_group_action_context.h"
#import "ios/chrome/browser/saved_tab_groups/model/ios_tab_group_sync_util.h"
#import "ios/chrome/browser/saved_tab_groups/model/tab_group_local_update_observer.h"
#import "ios/chrome/browser/saved_tab_groups/model/tab_group_sync_service_factory.h"
#import "ios/chrome/browser/shared/coordinator/scene/scene_state.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/browser/browser_list.h"
#import "ios/chrome/browser/shared/model/browser/browser_provider.h"
#import "ios/chrome/browser/shared/model/browser/browser_provider_interface.h"
#import "ios/chrome/browser/shared/model/web_state_list/tab_group.h"
#import "ios/chrome/browser/shared/model/web_state_list/tab_utils.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/public/commands/application_commands.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/shared/public/commands/tab_grid_commands.h"
#import "ios/chrome/browser/shared/public/commands/tab_groups_commands.h"
#import "ios/chrome/browser/tab_insertion/model/tab_insertion_browser_agent.h"
#import "ios/web/public/navigation/navigation_manager.h"
#import "ios/web/public/web_state.h"
#import "ios/web/public/web_state_id.h"

using tab_groups::utils::GetLocalTabGroupInfo;
using tab_groups::utils::LocalTabGroupInfo;

namespace tab_groups {
namespace {

class ScopedLocalObservationPauserImpl : public ScopedLocalObservationPauser {
 public:
  explicit ScopedLocalObservationPauserImpl(
      TabGroupLocalUpdateObserver* local_observer);
  ~ScopedLocalObservationPauserImpl() override;

  // Disallow copy/assign.
  ScopedLocalObservationPauserImpl(const ScopedLocalObservationPauserImpl&) =
      delete;
  ScopedLocalObservationPauserImpl& operator=(
      const ScopedLocalObservationPauserImpl&) = delete;

 private:
  raw_ptr<TabGroupLocalUpdateObserver> local_observer_;
};

ScopedLocalObservationPauserImpl::ScopedLocalObservationPauserImpl(
    TabGroupLocalUpdateObserver* local_observer)
    : local_observer_(local_observer) {
  local_observer_->SetSyncUpdatePaused(/*paused=*/true);
}

ScopedLocalObservationPauserImpl::~ScopedLocalObservationPauserImpl() {
  local_observer_->SetSyncUpdatePaused(/*paused=*/false);
}

}  // namespace

IOSTabGroupSyncDelegate::IOSTabGroupSyncDelegate(
    BrowserList* browser_list,
    TabGroupSyncService* sync_service,
    std::unique_ptr<TabGroupLocalUpdateObserver> local_update_observer)
    : browser_list_(browser_list),
      sync_service_(sync_service),
      local_update_observer_(std::move(local_update_observer)) {
  CHECK(local_update_observer_);
}

IOSTabGroupSyncDelegate::~IOSTabGroupSyncDelegate() {}

void IOSTabGroupSyncDelegate::HandleOpenTabGroupRequest(
    const base::Uuid& sync_tab_group_id,
    std::unique_ptr<TabGroupActionContext> context) {
  IOSTabGroupActionContext* ios_context =
      static_cast<IOSTabGroupActionContext*>(context.get());
  const auto saved_tab_group = sync_service_->GetGroup(sync_tab_group_id);
  Browser* origin_browser = ios_context->browser;

  if (!saved_tab_group || !origin_browser) {
    // The group doesn't exist or there is no origin browser.
    return;
  }

  Browser* target_browser = origin_browser;

  LocalTabGroupInfo tab_group_info =
      GetLocalTabGroupInfo(browser_list_, *saved_tab_group);
  const TabGroup* group = tab_group_info.tab_group;
  if (group) {
    if (!tab_group_info.browser) {
      return;
    }
    target_browser = tab_group_info.browser;

    if (target_browser != origin_browser) {
      base::RecordAction(
          base::UserMetricsAction("MobileOpenGroupOpenInOtherBrowser"));
      // The group is in another window.
      SceneState* target_scene_state = target_browser->GetSceneState();
      UISceneActivationRequestOptions* options =
          [[UISceneActivationRequestOptions alloc] init];
      options.requestingScene = origin_browser->GetSceneState().scene;

      if (@available(iOS 17, *)) {
        UISceneSessionActivationRequest* request =
            [UISceneSessionActivationRequest
                requestWithSession:target_scene_state.scene.session];
        request.options = options;
        [[UIApplication sharedApplication]
            activateSceneSessionForRequest:request
                              errorHandler:^(NSError* error) {
                                LOG(ERROR) << base::SysNSStringToUTF8(
                                    error.localizedDescription);
                                NOTREACHED();
                              }];

      } else {
        [[UIApplication sharedApplication]
            requestSceneSessionActivation:target_scene_state.scene.session
                             userActivity:nil
                                  options:options
                             errorHandler:^(NSError* error) {
                               LOG(ERROR) << base::SysNSStringToUTF8(
                                   error.localizedDescription);
                               NOTREACHED();
                             }];
      }

      if (!target_scene_state.UIEnabled) {
        return;
      }

      CommandDispatcher* dispatcher = target_browser->GetCommandDispatcher();
      id<ApplicationCommands> applicationHandler =
          HandlerForProtocol(dispatcher, ApplicationCommands);
      [applicationHandler displayTabGridInMode:TabGridOpeningMode::kRegular];
      id<TabGroupsCommands> tabGroupsHandler =
          HandlerForProtocol(dispatcher, TabGroupsCommands);
      [tabGroupsHandler showTabGroup:group];

      return;
    }
    base::RecordAction(base::UserMetricsAction("MobileOpenGroupOpenInBrowser"));
  } else {
    base::RecordAction(base::UserMetricsAction("MobileOpenGroupClosed"));

    std::optional<LocalTabGroupID> tab_group_id =
        CreateLocalTabGroupImpl(*saved_tab_group, origin_browser);
    if (!tab_group_id) {
      return;
    }
    LocalTabGroupInfo new_tab_group_info =
        GetLocalTabGroupInfo(browser_list_, tab_group_id.value());
    group = new_tab_group_info.tab_group;
  }

  CommandDispatcher* dispatcher = target_browser->GetCommandDispatcher();
  id<ApplicationCommands> applicationHandler =
      HandlerForProtocol(dispatcher, ApplicationCommands);
  [applicationHandler displayTabGridInMode:TabGridOpeningMode::kRegular];

  id<TabGroupsCommands> tabGroupsHandler =
      HandlerForProtocol(dispatcher, TabGroupsCommands);
  [tabGroupsHandler showTabGroup:group];

  // Moves back to the grid containing the group after it has been opened.
  dispatch_after(
      dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.15 * NSEC_PER_SEC)),
      dispatch_get_main_queue(), ^{
        id<TabGridCommands> tabGridHandler =
            HandlerForProtocol(dispatcher, TabGridCommands);
        [tabGridHandler bringGroupIntoView:group animated:NO];
      });
}

std::unique_ptr<ScopedLocalObservationPauser>
IOSTabGroupSyncDelegate::CreateScopedLocalObserverPauser() {
  return std::make_unique<ScopedLocalObservationPauserImpl>(
      local_update_observer_.get());
}

void IOSTabGroupSyncDelegate::CreateLocalTabGroup(
    const SavedTabGroup& saved_tab_group) {
  CreateLocalTabGroupImpl(saved_tab_group, nullptr);
}

void IOSTabGroupSyncDelegate::CloseLocalTabGroup(
    const LocalTabGroupID& local_tab_group_id) {
  auto lock = CreateScopedLocalObserverPauser();

  LocalTabGroupInfo tab_group_info =
      GetLocalTabGroupInfo(browser_list_, local_tab_group_id);
  if (!tab_group_info.tab_group) {
    // The group is closed locally.
    return;
  }

  CloseAllWebStatesInGroup(*tab_group_info.web_state_list,
                           tab_group_info.tab_group,
                           WebStateList::CLOSE_NO_FLAGS);
}

void IOSTabGroupSyncDelegate::UpdateLocalTabGroup(
    const SavedTabGroup& saved_tab_group) {
  LocalTabGroupInfo tab_group_info =
      GetLocalTabGroupInfo(browser_list_, saved_tab_group);
  if (!tab_group_info.tab_group) {
    // The group is closed locally.
    return;
  }
  auto lock = CreateScopedLocalObserverPauser();

  const TabGroup* tab_group = tab_group_info.tab_group;
  const TabGroupRange& tab_group_range = tab_group->range();
  WebStateList* web_state_list = tab_group_info.web_state_list;

  // Start a batch operation.
  WebStateList::ScopedBatchOperation observer_lock =
      web_state_list->StartBatchOperation();

  // Update the visual data.
  UpdateLocalGroupVisualData(tab_group_info, saved_tab_group);

  // Loop on each `saved_tabs` entry to synchronize local tabs, this involves:
  // - Matching and updating existing local tabs.
  // - Creating new local tabs for missing saved entries.
  // - Removing local tabs that are no longer present in the `saved_tab_group`.
  std::vector<SavedTabGroupTab> saved_tabs = saved_tab_group.saved_tabs();
  for (size_t index = 0; index < saved_tabs.size(); index++) {
    const SavedTabGroupTab& saved_tab = saved_tabs[index];
    // The local index of the `saved_tab`.
    int local_web_state_index = tab_group_range.range_begin() + index;

    // Check if the computed `local_web_state_index` exists locally.
    bool local_web_state_index_exists =
        saved_tab.local_tab_id().has_value() &&
        tab_group_range.contains(local_web_state_index);
    if (local_web_state_index_exists) {
      // Retreive the `WebState` at the `local_web_state_index`.
      const web::WebStateID local_web_state_id =
          web::WebStateID::FromSerializedValue(
              saved_tab.local_tab_id().value());
      web::WebState* local_web_state =
          web_state_list->GetWebStateAt(local_web_state_index);

      // Check if the `saved_tab` id is the same as the `local_web_state` id.
      // If true, update the `local_web_state` and continue.
      if (local_web_state_id == local_web_state->GetUniqueIdentifier()) {
        UpdateLocalWebState(local_web_state_index, local_web_state,
                            tab_group_info, saved_tab);
        continue;
      }

      // Otherwise, check if there is a local tab in the group that matches the
      // `saved_tab` id.
      // If true :
      //   - move the local webState to `local_web_state_index`.
      //   - update it and continue.
      int source_web_state_index =
          GetWebStateIndex(web_state_list, WebStateSearchCriteria{
                                               .identifier = local_web_state_id,
                                           });
      if (tab_group_range.contains(source_web_state_index)) {
        web_state_list->MoveWebStateAt(source_web_state_index,
                                       local_web_state_index);

        UpdateLocalWebState(
            local_web_state_index,
            web_state_list->GetWebStateAt(local_web_state_index),
            tab_group_info, saved_tab);
        continue;
      }
    }

    // If the `saved_tab` does not match any local webState, add a new local
    // webState and move it to the group.
    TabInsertionBrowserAgent* tab_insertion_browser_agent =
        TabInsertionBrowserAgent::FromBrowser(tab_group_info.browser);
    web::WebState* local_web_state =
        InsertDistantTab(saved_tab, tab_insertion_browser_agent,
                         local_web_state_index, tab_group);

    // Do the association on the server.
    UpdateLocalTabId(local_web_state, tab_group, saved_tab);
  }

  // If there are more tabs in the local group, that means some tabs have been
  // deleted. Remove them.
  int tabs_to_delete = tab_group_range.count() -
                       static_cast<int>(saved_tab_group.saved_tabs().size());
  CHECK(tabs_to_delete >= 0);
  for (int count = 0; count < tabs_to_delete; count++) {
    web_state_list->CloseWebStateAt(tab_group_range.range_end() - 1,
                                    WebStateList::CLOSE_NO_FLAGS);
  }
}

std::vector<LocalTabGroupID> IOSTabGroupSyncDelegate::GetLocalTabGroupIds() {
  std::vector<LocalTabGroupID> local_tab_group_ids;
  for (Browser* browser :
       browser_list_->BrowsersOfType(BrowserList::BrowserType::kRegular)) {
    WebStateList* web_state_list = browser->GetWebStateList();
    for (const TabGroup* group : web_state_list->GetGroups()) {
      local_tab_group_ids.emplace_back(group->tab_group_id());
    }
  }

  return local_tab_group_ids;
}

std::vector<LocalTabID> IOSTabGroupSyncDelegate::GetLocalTabIdsForTabGroup(
    const LocalTabGroupID& local_tab_group_id) {
  std::vector<LocalTabID> local_tab_ids;

  LocalTabGroupInfo tab_group_info =
      GetLocalTabGroupInfo(browser_list_, local_tab_group_id);
  if (!tab_group_info.tab_group) {
    // The group is closed locally.
    return local_tab_ids;
  }

  for (int i : tab_group_info.tab_group->range()) {
    LocalTabID local_tab_id = tab_group_info.web_state_list->GetWebStateAt(i)
                                  ->GetUniqueIdentifier()
                                  .identifier();
    local_tab_ids.emplace_back(local_tab_id);
  }

  return local_tab_ids;
}

void IOSTabGroupSyncDelegate::CreateRemoteTabGroup(
    const LocalTabGroupID& local_tab_group_id) {
  if (sync_service_->GetGroup(local_tab_group_id)) {
    // The group already exists.
    return;
  }

  LocalTabGroupInfo tab_group_info =
      GetLocalTabGroupInfo(browser_list_, local_tab_group_id);
  if (!tab_group_info.tab_group) {
    // This group doesn't exists locally.
    return;
  }

  const TabGroup* tab_group = tab_group_info.tab_group;
  WebStateList* web_state_list = tab_group_info.web_state_list;
  const TabGroupRange& tab_group_range = tab_group->range();

  auto lock = CreateScopedLocalObserverPauser();

  // Generate and id for the synced tab group.
  base::Uuid saved_tab_group_id = base::Uuid::GenerateRandomV4();

  // Create a vector of `saved_tabs` based on local tabs.
  std::vector<SavedTabGroupTab> saved_tabs;
  for (int index = 0; index < tab_group_range.count(); ++index) {
    int web_state_index = tab_group_range.range_begin() + index;
    web::WebState* web_state = web_state_list->GetWebStateAt(web_state_index);

    SavedTabGroupTab saved_tab(
        web_state->GetVisibleURL(), web_state->GetTitle(), saved_tab_group_id,
        std::make_optional(index), /*position=*/std::nullopt,
        web_state->GetUniqueIdentifier().identifier());
    saved_tabs.push_back(saved_tab);
  }

  SavedTabGroup saved_group(base::SysNSStringToUTF16(tab_group->GetRawTitle()),
                            tab_group->visual_data().color(), saved_tabs,
                            /*position=*/std::nullopt, saved_tab_group_id,
                            tab_group->tab_group_id());
  sync_service_->AddGroup(saved_group);
}

Browser* IOSTabGroupSyncDelegate::GetMostActiveSceneBrowser() {
  std::set<Browser*> all_browsers =
      browser_list_->BrowsersOfType(BrowserList::BrowserType::kRegular);

  Browser* browser = nullptr;
  for (Browser* browser_to_check : all_browsers) {
    // The pointer to the scene state is weak, so it could be nil. In that case,
    // the activation level will be 0 (lowest).
    if (browser && browser->GetSceneState().activationLevel >=
                       browser_to_check->GetSceneState().activationLevel) {
      continue;
    }
    browser = browser_to_check;
    if (browser_to_check->GetSceneState().activationLevel ==
        SceneActivationLevelForegroundActive) {
      break;
    }
  }
  return browser;
}

web::WebState* IOSTabGroupSyncDelegate::InsertDistantTab(
    const SavedTabGroupTab& tab,
    TabInsertionBrowserAgent* tab_insertion_browser_agent,
    int web_state_index,
    const TabGroup* tab_group) {
  GURL url_to_open = tab.url();
  std::u16string title = tab.title();
  if (!IsURLValidForSavedTabGroups(url_to_open)) {
    url_to_open = GetDefaultUrlAndTitle().first;
    title = GetDefaultUrlAndTitle().second;
  }

  web::NavigationManager::WebLoadParams web_params(url_to_open);
  TabInsertion::Params tab_insertion_params;
  tab_insertion_params.index = web_state_index;
  tab_insertion_params.in_background = true;
  tab_insertion_params.instant_load = false;
  tab_insertion_params.placeholder_title = title;
  if (tab_group) {
    tab_insertion_params.insert_in_group = true;
    tab_insertion_params.tab_group = tab_group->GetWeakPtr();
  }
  web::WebState* web_state = tab_insertion_browser_agent->InsertWebState(
      web_params, tab_insertion_params);
  local_update_observer_->IgnoreNavigationForWebState(web_state);
  return web_state;
}

void IOSTabGroupSyncDelegate::UpdateLocalWebState(
    int web_state_index,
    web::WebState* web_state,
    LocalTabGroupInfo tab_group_info,
    const SavedTabGroupTab& saved_tab) {
  // Early return if URLs are the same.
  if (saved_tab.url() == web_state->GetVisibleURL()) {
    return;
  }

  // Dont navigate to the new URL if its not valid for sync. We allow local
  // state to differ from sync in this case, especially since we want to honor
  // the local URL after restarts.
  if (!IsURLValidForSavedTabGroups(saved_tab.url())) {
    return;
  }

  WebStateList* web_state_list = tab_group_info.web_state_list;

  // If the `web_state` is the active index, open and load the updated URL.
  if (web_state_list->active_index() == web_state_index) {
    local_update_observer_->IgnoreNavigationForWebState(web_state);

    web_state->OpenURL(web::WebState::OpenURLParams(
        saved_tab.url(), web::Referrer(), WindowOpenDisposition::CURRENT_TAB,
        ui::PAGE_TRANSITION_GENERATED, /*is_renderer_initiated=*/false));
    return;
  }

  // Otherwise, in order to update the URL without loading it, replace the
  // current `web_state` with a new one that has not been loaded.
  // To avoid accidentally closing a group with only one tab, add the new tab to
  // the group before removing the old one.
  TabInsertionBrowserAgent* tab_insertion_browser_agent =
      TabInsertionBrowserAgent::FromBrowser(tab_group_info.browser);
  web::WebState* local_web_state =
      InsertDistantTab(saved_tab, tab_insertion_browser_agent, web_state_index,
                       tab_group_info.tab_group);
  web_state_list->CloseWebStateAt(web_state_index + 1,
                                  WebStateList::CLOSE_NO_FLAGS);

  // Do the association on the server.
  UpdateLocalTabId(local_web_state, tab_group_info.tab_group, saved_tab);
}

void IOSTabGroupSyncDelegate::UpdateLocalTabId(
    web::WebState* web_state,
    const TabGroup* tab_group,
    const SavedTabGroupTab& saved_tab) {
  sync_service_->UpdateLocalTabId(
      tab_group->tab_group_id(), saved_tab.saved_tab_guid(),
      web_state->GetUniqueIdentifier().identifier());
}

// Updates the visual data of the local `tab_group` to match the
// `SavedTabGroup`.
void IOSTabGroupSyncDelegate::UpdateLocalGroupVisualData(
    utils::LocalTabGroupInfo tab_group_info,
    const SavedTabGroup& saved_tab_group) {
  const TabGroupVisualData visual_data = tab_groups::TabGroupVisualData(
      saved_tab_group.title(), saved_tab_group.color(),
      tab_group_info.tab_group->visual_data().is_collapsed());
  tab_group_info.web_state_list->UpdateGroupVisualData(tab_group_info.tab_group,
                                                       visual_data);
}

std::optional<LocalTabGroupID> IOSTabGroupSyncDelegate::CreateLocalTabGroupImpl(
    const SavedTabGroup& saved_tab_group,
    Browser* browser) {
  if (saved_tab_group.saved_tabs().size() == 0) {
    return std::nullopt;
  }

  LocalTabGroupInfo tab_group_info =
      GetLocalTabGroupInfo(browser_list_, saved_tab_group);
  if (tab_group_info.tab_group) {
    // This group already exists locally.
    return std::nullopt;
  }

  // If no browser was passed, get the most active one.
  browser = browser ? browser : GetMostActiveSceneBrowser();

  if (!browser) {
    return std::nullopt;
  }

  auto lock = CreateScopedLocalObserverPauser();
  WebStateList* web_state_list = browser->GetWebStateList();

  TabInsertionBrowserAgent* tab_insertion_browser_agent =
      TabInsertionBrowserAgent::FromBrowser(browser);
  int insertion_index = web_state_list->count();
  std::set<int> inserted_indexes;
  // To do the mapping on the service, the local group ID is necessary. Keep a
  // temporary mapping until the group is created.
  std::map<const base::Uuid, const LocalTabID> sync_to_local_tab_mapping;

  for (const SavedTabGroupTab& tab : saved_tab_group.saved_tabs()) {
    web::WebState* web_state =
        InsertDistantTab(tab, tab_insertion_browser_agent, insertion_index,
                         /*web_state_index=*/nil);
    sync_to_local_tab_mapping.insert(
        {tab.saved_tab_guid(), web_state->GetUniqueIdentifier().identifier()});
    inserted_indexes.insert(insertion_index);
    insertion_index++;
  }

  TabGroupVisualData visual_data = {saved_tab_group.title(),
                                    saved_tab_group.color()};
  TabGroupId local_group_id = TabGroupId::GenerateNew();

  // Do the association on the server before creating it in the WebStateList to
  // avoid creating another group in the service.
  sync_service_->UpdateLocalTabGroupMapping(saved_tab_group.saved_guid(),
                                            local_group_id);
  for (auto const& [sync_tab_id, local_tab_id] : sync_to_local_tab_mapping) {
    sync_service_->UpdateLocalTabId(local_group_id, sync_tab_id, local_tab_id);
  }

  web_state_list->CreateGroup(inserted_indexes, visual_data, local_group_id);

  return std::make_optional(local_group_id);
}
}  // namespace tab_groups