chromium/ios/chrome/browser/history/model/history_tab_helper.mm

// Copyright 2017 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/history/model/history_tab_helper.h"

#import "base/memory/ptr_util.h"
#import "components/history/core/browser/history_constants.h"
#import "components/history/core/browser/history_service.h"
#import "components/keyed_service/core/service_access_type.h"
#import "components/strings/grit/components_strings.h"
#import "components/translate/core/common/language_detection_details.h"
#import "ios/chrome/browser/complex_tasks/model/ios_content_record_task_id.h"
#import "ios/chrome/browser/complex_tasks/model/ios_task_tab_helper.h"
#import "ios/chrome/browser/history/model/history_service_factory.h"
#import "ios/chrome/browser/sessions/model/ios_chrome_session_tab_helper.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/url/chrome_url_constants.h"
#import "ios/chrome/browser/translate/model/chrome_ios_translate_client.h"
#import "ios/web/public/navigation/navigation_context.h"
#import "ios/web/public/navigation/navigation_item.h"
#import "ios/web/public/navigation/navigation_manager.h"
#import "ios/web/public/web_state.h"
#import "net/http/http_response_headers.h"
#import "ui/base/l10n/l10n_util.h"

namespace {

std::optional<std::u16string> GetPageTitle(const web::NavigationItem& item) {
  const std::u16string& title = item.GetTitleForDisplay();
  if (title.empty() ||
      title == l10n_util::GetStringUTF16(IDS_DEFAULT_TAB_TITLE)) {
    return std::nullopt;
  }

  return std::optional<std::u16string>(title);
}

}  // namespace

HistoryTabHelper::~HistoryTabHelper() {
  DCHECK(!web_state_);
}

void HistoryTabHelper::UpdateHistoryForNavigation(
    const history::HistoryAddPageArgs& add_page_args) {
  history::HistoryService* history_service = GetHistoryService();
  if (!history_service)
    return;

  // Update the previous navigation's end time.
  if (cached_navigation_state_) {
    history_service->UpdateWithPageEndTime(
        GetContextID(), cached_navigation_state_->nav_entry_id,
        cached_navigation_state_->url, base::Time::Now());
  }
  // Cache the relevant fields of the current navigation, so we can later update
  // its end time too.
  cached_navigation_state_ = {add_page_args.nav_entry_id, add_page_args.url};

  // Now, actually add the new navigation to history.
  history_service->AddPage(add_page_args);
}

void HistoryTabHelper::UpdateHistoryPageTitle(const web::NavigationItem& item) {
  DCHECK(!delay_notification_);

  const std::optional<std::u16string> title = GetPageTitle(item);
  // Don't update the history if current entry has no title.
  if (!title) {
    return;
  }

  history::HistoryService* history_service = GetHistoryService();
  if (history_service) {
    history_service->SetPageTitle(item.GetVirtualURL(), title.value());
  }
}

history::HistoryAddPageArgs HistoryTabHelper::CreateHistoryAddPageArgs(
    web::NavigationItem* last_committed_item,
    web::NavigationContext* navigation_context) {
  const GURL& url = last_committed_item->GetURL();

  const ui::PageTransition transition =
      last_committed_item->GetTransitionType();

  history::RedirectList redirects;
  const GURL& original_url = last_committed_item->GetOriginalRequestURL();
  const GURL& referrer_url = last_committed_item->GetReferrer().url;
  if (original_url != url) {
    // Simulate a valid redirect chain in case of URLs that have been modified
    // by CRWWebController -finishHistoryNavigationFromEntry:.
    if (transition & ui::PAGE_TRANSITION_CLIENT_REDIRECT ||
        url.EqualsIgnoringRef(original_url)) {
      redirects.push_back(referrer_url);
    }
    // TODO(crbug.com/40511880): the redirect chain is not constructed the same
    // way as desktop so this part needs to be revised.
    redirects.push_back(original_url);
    redirects.push_back(url);
  }

  // Navigations originating from New Tab Page or Reading List should not
  // contribute to Most Visited.
  const bool content_suggestions_navigation = ui::PageTransitionCoreTypeIs(
      transition, ui::PAGE_TRANSITION_AUTO_BOOKMARK);
  const bool consider_for_ntp_most_visited =
      !content_suggestions_navigation &&
      referrer_url != kReadingListReferrerURL;

  const int http_response_code =
      navigation_context->GetResponseHeaders()
          ? navigation_context->GetResponseHeaders()->response_code()
          : 0;

  // Hide navigations that result in an error in order to prevent the omnibox
  // from suggesting URLs that have never been navigated to successfully.
  // (If a navigation to the URL succeeds at some point, the URL will be
  // unhidden and thus eligible to be suggested by the omnibox.)
  const bool hidden = (http_response_code >= 400);

  history::VisitContextAnnotations::OnVisitFields context_annotations;

  context_annotations.browser_type =
      history::VisitContextAnnotations::BrowserType::kTabbed;

  IOSChromeSessionTabHelper* session_tab_helper =
      IOSChromeSessionTabHelper::FromWebState(web_state_);
  if (session_tab_helper) {
    context_annotations.window_id = session_tab_helper->window_id();
    context_annotations.tab_id = session_tab_helper->session_id();
  }

  IOSTaskTabHelper* task_tab_helper =
      IOSTaskTabHelper::FromWebState(web_state_);
  if (task_tab_helper) {
    const IOSContentRecordTaskId* content_record_task_id =
        task_tab_helper->GetContextRecordTaskId(
            last_committed_item->GetUniqueID());
    if (content_record_task_id) {
      context_annotations.task_id = content_record_task_id->task_id();
      context_annotations.root_task_id = content_record_task_id->root_task_id();
      context_annotations.parent_task_id =
          content_record_task_id->parent_task_id();
    }
  }

  context_annotations.response_code = http_response_code;

  return history::HistoryAddPageArgs(
      url, last_committed_item->GetTimestamp(), GetContextID(),
      last_committed_item->GetUniqueID(), navigation_context->GetNavigationId(),
      referrer_url, redirects, transition, hidden, history::SOURCE_BROWSED,
      /*did_replace_entry=*/false, consider_for_ntp_most_visited,
      navigation_context->IsSameDocument() ? GetPageTitle(*last_committed_item)
                                           : std::nullopt,
      // TODO(crbug.com/40279742): due to WebKit constraints, iOS does not
      // support triple-key partitioning. Once supported, we need to populate
      // `top_level_url` with the correct value. Until then, :visited history on
      // iOS is unpartitioned.
      /*top_level_url=*/std::nullopt,
      /*opener=*/std::nullopt,
      /*bookmark_id=*/std::nullopt,
      /*app_id=*/std::nullopt,
      /*context_annotations=*/std::move(context_annotations));
}

void HistoryTabHelper::SetDelayHistoryServiceNotification(
    bool delay_notification) {
  delay_notification_ = delay_notification;
  if (delay_notification_) {
    return;
  }

  for (const auto& add_page_args : recorded_navigations_) {
    UpdateHistoryForNavigation(add_page_args);
  }

  std::vector<history::HistoryAddPageArgs> empty_vector;
  std::swap(recorded_navigations_, empty_vector);

  web::NavigationItem* last_committed_item =
      web_state_->GetNavigationManager()->GetLastCommittedItem();
  if (last_committed_item) {
    UpdateHistoryPageTitle(*last_committed_item);
  }
}

void HistoryTabHelper::OnLanguageDetermined(
    const translate::LanguageDetectionDetails& details) {
  if (history::HistoryService* hs = GetHistoryService()) {
    web::NavigationItem* last_committed_item =
        web_state_->GetNavigationManager()->GetLastCommittedItem();
    if (last_committed_item) {
      hs->SetPageLanguageForVisit(
          GetContextID(), last_committed_item->GetUniqueID(),
          web_state_->GetLastCommittedURL(), details.adopted_language);
    }
  }
}

HistoryTabHelper::HistoryTabHelper(web::WebState* web_state)
    : web_state_(web_state) {
  web_state_->AddObserver(this);

  // A translate client is not always attached to a web state (e.g. tests).
  if (ChromeIOSTranslateClient* translate_client =
          ChromeIOSTranslateClient::FromWebState(web_state)) {
    translate_observation_.Observe(translate_client->GetTranslateDriver());
  }
}

void HistoryTabHelper::DidFinishNavigation(
    web::WebState* web_state,
    web::NavigationContext* navigation_context) {
  DCHECK_EQ(web_state_, web_state);
  if (web_state_->GetBrowserState()->IsOffTheRecord()) {
    return;
  }

  // Do not record failed navigation nor 404 to the history (to prevent them
  // from showing up as Most Visited tiles on NTP).
  if (navigation_context->GetError()) {
    return;
  }

  if (navigation_context->GetResponseHeaders() &&
      navigation_context->GetResponseHeaders()->response_code() == 404) {
    return;
  }

  // TODO(crbug.com/41441240): Remove GetLastCommittedItem nil check once
  // HasComitted has been fixed.
  if (!navigation_context->HasCommitted() ||
      !web_state_->GetNavigationManager()->GetLastCommittedItem()) {
    // Navigation was replaced or aborted.
    return;
  }

  web::NavigationItem* last_committed_item =
      web_state_->GetNavigationManager()->GetLastCommittedItem();
  DCHECK(!last_committed_item->GetTimestamp().is_null());

  // Do not update the history database for back/forward navigations.
  // TODO(crbug.com/40491761): on iOS the navigation is not currently tagged
  // with a ui::PAGE_TRANSITION_FORWARD_BACK transition.
  const ui::PageTransition transition =
      last_committed_item->GetTransitionType();
  if (transition & ui::PAGE_TRANSITION_FORWARD_BACK) {
    return;
  }

  // Do not update the history database for data: urls. This diverges from
  // desktop, but prevents dumping huge view-source urls into the history
  // database. Keep it NDEBUG only because view-source:// URLs are enabled
  // on NDEBUG builds only.
#ifndef NDEBUG
  const GURL& url = last_committed_item->GetURL();
  if (url.SchemeIs(url::kDataScheme)) {
    return;
  }
#endif

  num_title_changes_ = 0;

  history::HistoryAddPageArgs add_page_args =
      CreateHistoryAddPageArgs(last_committed_item, navigation_context);

  if (delay_notification_) {
    recorded_navigations_.push_back(std::move(add_page_args));
  } else {
    DCHECK(recorded_navigations_.empty());

    UpdateHistoryForNavigation(add_page_args);
    UpdateHistoryPageTitle(*last_committed_item);
  }
}

void HistoryTabHelper::PageLoaded(
    web::WebState* web_state,
    web::PageLoadCompletionStatus load_completion_status) {
  last_load_completion_ = base::TimeTicks::Now();
}

void HistoryTabHelper::TitleWasSet(web::WebState* web_state) {
  DCHECK_EQ(web_state_, web_state);
  if (delay_notification_) {
    return;
  }

  // Protect against pages changing their title too often during page load.
  if (num_title_changes_ >= history::kMaxTitleChanges)
    return;

  // Only store page titles into history if they were set while the page was
  // loading or during a brief span after load is complete. This fixes the case
  // where a page uses a title change to alert a user of a situation but that
  // title change ends up saved in history.
  if (web_state->IsLoading() ||
      (base::TimeTicks::Now() - last_load_completion_ <
       history::GetTitleSettingWindow())) {
    web::NavigationItem* last_committed_item =
        web_state_->GetNavigationManager()->GetLastCommittedItem();
    if (last_committed_item) {
      UpdateHistoryPageTitle(*last_committed_item);
      ++num_title_changes_;
    }
  }
}

void HistoryTabHelper::WebStateDestroyed(web::WebState* web_state) {
  DCHECK_EQ(web_state_, web_state);

  translate_observation_.Reset();

  history::HistoryService* history_service = GetHistoryService();
  if (history_service) {
    // If there is a current history-eligible navigation in this tab (i.e.
    // `cached_navigation_state_` exists), that visit is concluded now, so
    // update its end time.
    if (cached_navigation_state_) {
      history_service->UpdateWithPageEndTime(
          GetContextID(), cached_navigation_state_->nav_entry_id,
          cached_navigation_state_->url, base::Time::Now());
    }

    history_service->ClearCachedDataForContextID(GetContextID());
  }

  web_state_->RemoveObserver(this);
  web_state_ = nullptr;
}

history::HistoryService* HistoryTabHelper::GetHistoryService() {
  ChromeBrowserState* browser_state =
      ChromeBrowserState::FromBrowserState(web_state_->GetBrowserState());
  if (browser_state->IsOffTheRecord())
    return nullptr;

  return ios::HistoryServiceFactory::GetForBrowserState(
      browser_state, ServiceAccessType::IMPLICIT_ACCESS);
}

WEB_STATE_USER_DATA_KEY_IMPL(HistoryTabHelper)