chromium/ios/chrome/browser/bring_android_tabs/model/bring_android_tabs_to_ios_service.mm

// 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.

#import "ios/chrome/browser/bring_android_tabs/model/bring_android_tabs_to_ios_service.h"

#import <numeric>
#import <string>

#import "base/containers/contains.h"
#import "base/files/file.h"
#import "base/files/file_path.h"
#import "base/files/file_util.h"
#import "base/metrics/histogram_functions.h"
#import "base/time/time.h"
#import "components/prefs/pref_service.h"
#import "components/segmentation_platform/embedder/default_model/device_switcher_model.h"
#import "components/segmentation_platform/embedder/default_model/device_switcher_result_dispatcher.h"
#import "components/segmentation_platform/public/result.h"
#import "components/sync/service/sync_prefs.h"
#import "components/sync/service/sync_service.h"
#import "components/sync/service/sync_user_settings.h"
#import "components/sync_device_info/device_info.h"
#import "components/sync_sessions/session_sync_service.h"
#import "components/url_formatter/elide_url.h"
#import "ios/chrome/browser/bring_android_tabs/model/metrics.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/model/utils/first_run_util.h"
#import "ios/chrome/browser/shared/public/features/system_flags.h"
#import "ios/chrome/browser/synced_sessions/model/distant_session.h"
#import "ios/chrome/browser/synced_sessions/model/distant_tab.h"
#import "ios/chrome/browser/synced_sessions/model/synced_sessions.h"
#import "ios/chrome/browser/synced_sessions/model/synced_sessions_util.h"
#import "ios/chrome/browser/url_loading/model/url_loading_browser_agent.h"
#import "ios/chrome/browser/url_loading/model/url_loading_params.h"
#import "ui/base/device_form_factor.h"

namespace {

using ::bring_android_tabs::kPromptAttemptStatusHistogramName;
using ::bring_android_tabs::PromptAttemptStatus;

// The length of time from now in which the tabs will be brought over.
const base::TimeDelta kTimeRangeOfTabsImported = base::Days(14);

// Maximum number of tabs that should be imported.
const size_t kMaxNumberOfTabs = 20;

// Logs `status` on UMA.
void RecordPromptAttemptStatus(PromptAttemptStatus status) {
  base::UmaHistogramEnumeration(kPromptAttemptStatusHistogramName, status);
}

// Returns true if the user is eligible for the Bring Android Tabs prompt. Logs
// attempt status metric on UMA if the user is NOT eligible.
bool UserEligibleForAndroidSwitcherPrompt() {
  if (IsFirstRunRecent(base::Days(7))) {
    return ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_PHONE;
  }
  return false;
}

// Returns true if the user is segmented as an Android switcher, either by
// the segmentation platform or by using the forced device switcher flag.
bool UserIsAndroidSwitcher(
    segmentation_platform::DeviceSwitcherResultDispatcher* dispatcher) {
  bool device_switcher_forced =
      experimental_flags::GetSegmentForForcedDeviceSwitcherExperience() ==
      segmentation_platform::DeviceSwitcherModel::kAndroidPhoneLabel;
  if (device_switcher_forced) {
    return true;
  }

  segmentation_platform::ClassificationResult result =
      dispatcher->GetCachedClassificationResult();
  return result.status == segmentation_platform::PredictionStatus::kSucceeded &&
         result.ordered_labels[0] ==
             segmentation_platform::DeviceSwitcherModel::kAndroidPhoneLabel &&
         !base::Contains(
             result.ordered_labels,
             segmentation_platform::DeviceSwitcherModel::kIosPhoneChromeLabel);
}

}  // namespace

BringAndroidTabsToIOSService::BringAndroidTabsToIOSService(
    segmentation_platform::DeviceSwitcherResultDispatcher* dispatcher,
    syncer::SyncService* sync_service,
    sync_sessions::SessionSyncService* session_sync_service,
    PrefService* browser_state_prefs)
    : device_switcher_result_dispatcher_(dispatcher),
      sync_service_(sync_service),
      session_sync_service_(session_sync_service),
      browser_state_prefs_(browser_state_prefs) {
  DCHECK(device_switcher_result_dispatcher_);
  DCHECK(sync_service_);
  DCHECK(session_sync_service_);
  DCHECK(browser_state_prefs_);
}

BringAndroidTabsToIOSService::~BringAndroidTabsToIOSService() {}

void BringAndroidTabsToIOSService::LoadTabs() {
  load_tabs_invoked_ = true;
  // Early returns for users who should NOT be enrolled in the feature
  // experiment. This includes current iPad users and those who aren't recent
  // Android switchers.
  if (!UserEligibleForAndroidSwitcherPrompt() ||
      !UserIsAndroidSwitcher(device_switcher_result_dispatcher_)) {
    return;
  }

  // In case the user is previously eligible for the prompt but not
  // anymore, clear the tabs so that future calls to `GetNumberOfAndroidTabs()`
  // will return 0 and the caller won't show the prompt.
  if (PromptShownAndShouldNotShowAgain()) {
    RecordPromptAttemptStatus(PromptAttemptStatus::kPromptShownAndDismissed);
    synced_sessions_.reset();
    position_of_tabs_in_synced_sessions_.clear();
    return;
  }

  // Load the tabs if they aren't loaded.
  if (position_of_tabs_in_synced_sessions_.empty()) {
    PromptAttemptStatus status = LoadSyncedSessionsAndComputeTabPositions();
    RecordPromptAttemptStatus(status);
  }
}

size_t BringAndroidTabsToIOSService::GetNumberOfAndroidTabs() const {
  CHECK(load_tabs_invoked_);
  size_t tab_count = position_of_tabs_in_synced_sessions_.size();
  CHECK_LE(tab_count, kMaxNumberOfTabs);
  return tab_count;
}

synced_sessions::DistantTab* BringAndroidTabsToIOSService::GetTabAtIndex(
    size_t index) const {
  CHECK_LT(index, position_of_tabs_in_synced_sessions_.size());
  std::tuple<size_t, size_t> indices =
      position_of_tabs_in_synced_sessions_[index];
  size_t session_idx = std::get<0>(indices);
  size_t tab_idx = std::get<1>(indices);
  return synced_sessions_->GetSession(session_idx)->tabs[tab_idx].get();
}

void BringAndroidTabsToIOSService::OpenTabsAtIndices(
    const std::vector<size_t>& indices,
    UrlLoadingBrowserAgent* url_loader) {
  const int tab_count = static_cast<int>(indices.size());
  const bool in_incognito = false;
  const int maximum_instant_load_tabs =
      GetDefaultNumberOfTabsToLoadSimultaneously();
  for (int i = 0; i < tab_count; i++) {
    const bool instant_load = i < maximum_instant_load_tabs;
    OpenDistantTab(GetTabAtIndex(indices[i]), in_incognito, instant_load,
                   url_loader, UrlLoadStrategy::NORMAL);
  }
}

void BringAndroidTabsToIOSService::OpenAllTabs(
    UrlLoadingBrowserAgent* url_loader) {
  std::vector<size_t> indices(GetNumberOfAndroidTabs());
  std::iota(std::begin(indices), std::end(indices), 0);
  OpenTabsAtIndices(indices, url_loader);
}

void BringAndroidTabsToIOSService::OnBringAndroidTabsPromptDisplayed() {
  browser_state_prefs_->SetBoolean(prefs::kIosBringAndroidTabsPromptDisplayed,
                                   true);
  prompt_shown_current_session_ = true;
}

void BringAndroidTabsToIOSService::OnUserInteractWithBringAndroidTabsPrompt() {
  prompt_interacted_ = true;
}

bool BringAndroidTabsToIOSService::PromptShownAndShouldNotShowAgain() const {
  bool shown_before = browser_state_prefs_->GetBoolean(
      prefs::kIosBringAndroidTabsPromptDisplayed);
  bool should_not_show_again =
      prompt_interacted_ || (shown_before && !prompt_shown_current_session_);
  return should_not_show_again;
}

PromptAttemptStatus
BringAndroidTabsToIOSService::LoadSyncedSessionsAndComputeTabPositions() {
  bool tab_sync_disabled =
      !sync_service_ ||
      !sync_service_->GetUserSettings()->GetSelectedTypes().Has(
          syncer::UserSelectableType::kTabs);
  if (tab_sync_disabled) {
    return PromptAttemptStatus::kTabSyncDisabled;
  }

  // Synced sessions sorted by recency.
  synced_sessions_ =
      std::make_unique<synced_sessions::SyncedSessions>(session_sync_service_);
  size_t session_count = synced_sessions_->GetSessionCount();
  std::set<std::pair<std::u16string, std::u16string>> tab_titles_and_urls;

  for (size_t session_idx = 0; session_idx < session_count; session_idx++) {
    // Only tabs from an Android phone device within the last
    // `kTimeRangeOfTabsImported` are considered Android tabs.
    const synced_sessions::DistantSession* session =
        synced_sessions_->GetSession(session_idx);
    if (session->form_factor != syncer::DeviceInfo::FormFactor::kPhone ||
        session->modified_time < base::Time::Now() - kTimeRangeOfTabsImported) {
      continue;
    }
    size_t tab_size = session->tabs.size();
    // Tabs are already ordered by recency.
    for (size_t tab_idx = 0; tab_idx < tab_size; tab_idx++) {
      std::tuple<size_t, size_t> indices = {session_idx, tab_idx};

      // Skip tabs with the same title and URL.
      const synced_sessions::DistantTab* tab_candidate =
          synced_sessions_->GetSession(session_idx)->tabs[tab_idx].get();
      std::pair<std::u16string, std::u16string> tab_candidate_key = {
          tab_candidate->title,
          url_formatter::
              FormatUrlForDisplayOmitSchemePathTrivialSubdomainsAndMobilePrefix(
                  tab_candidate->virtual_url)};
      if (!tab_titles_and_urls.contains(tab_candidate_key)) {
        position_of_tabs_in_synced_sessions_.push_back(indices);
        tab_titles_and_urls.insert(tab_candidate_key);
      }

      if (position_of_tabs_in_synced_sessions_.size() >= kMaxNumberOfTabs) {
        break;
      }
    }
    if (position_of_tabs_in_synced_sessions_.size() >= kMaxNumberOfTabs) {
      break;
    }
  }
  return position_of_tabs_in_synced_sessions_.empty()
             ? PromptAttemptStatus::kNoActiveTabs
             : PromptAttemptStatus::kSuccess;
}