chromium/chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.cc

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

#include "chrome/browser/ui/autofill/autofill_keyboard_accessory_controller_impl.h"

#include <algorithm>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

#include "base/check_op.h"
#include "base/memory/weak_ptr.h"
#include "base/metrics/histogram_functions.h"
#include "base/notreached.h"
#include "base/strings/strcat.h"
#include "base/time/time.h"
#include "chrome/browser/autofill/personal_data_manager_factory.h"
#include "chrome/browser/keyboard_accessory/android/manual_filling_controller.h"
#include "chrome/browser/password_manager/android/local_passwords_migration_warning_util.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/autofill/autofill_keyboard_accessory_view.h"
#include "chrome/browser/ui/autofill/autofill_popup_view.h"
#include "chrome/browser/ui/autofill/autofill_suggestion_controller_utils.h"
#include "chrome/browser/ui/autofill/next_idle_barrier.h"
#include "components/autofill/core/browser/address_data_manager.h"
#include "components/autofill/core/browser/filling_product.h"
#include "components/autofill/core/browser/metrics/granular_filling_metrics.h"
#include "components/autofill/core/browser/payments_data_manager.h"
#include "components/autofill/core/browser/personal_data_manager.h"
#include "components/autofill/core/browser/ui/autofill_suggestion_delegate.h"
#include "components/autofill/core/browser/ui/popup_open_enums.h"
#include "components/autofill/core/browser/ui/suggestion_hiding_reason.h"
#include "components/autofill/core/browser/ui/suggestion_type.h"
#include "components/autofill/core/common/autofill_features.h"
#include "components/password_manager/core/browser/password_manager_metrics_util.h"
#include "components/password_manager/core/common/password_manager_features.h"
#include "components/strings/grit/components_strings.h"
#include "content/public/browser/render_widget_host_view.h"
#include "content/public/browser/web_contents.h"
#include "ui/base/l10n/l10n_util.h"

namespace autofill {

namespace {

using FillingSource = ManualFillingController::FillingSource;

constexpr std::u16string_view kLabelSeparator = u" ";
constexpr size_t kMaxBulletCount = 8;

// Creates a text label used by the keyboard accessory. For password
// suggestions, constructs the label from the password stored in
// `Suggestion::additional_label` and an optional signon realm stored in
// `Suggestion::labels`. For other suggestions, constructs the label from
// `Suggestion::labels`.
Suggestion::Text CreateLabel(const Suggestion& suggestion) {
  if (suggestion.labels.empty()) {
    return Suggestion::Text();
  }
  // TODO(crbug.com/40221039): Re-consider whether using CHECK is an appropriate
  // way to explicitly regulate what information should be populated for the
  // interface.
  CHECK_EQ(suggestion.labels.size(), 1U);
  CHECK_EQ(suggestion.labels[0].size(), 1U);
  if (GetFillingProductFromSuggestionType(suggestion.type) ==
      FillingProduct::kPassword) {
    // The `Suggestion::labels` can never be empty since it must contain a
    // password.
    const std::u16string password =
        suggestion.labels[0][0].value.substr(0, kMaxBulletCount);

    // The `Suggestion::additional_label` contains the signon_realm or is empty.
    if (suggestion.additional_label.empty()) {
      return Suggestion::Text(password);
    }
    return Suggestion::Text(
        base::StrCat({suggestion.additional_label, kLabelSeparator, password}));
  }

  return Suggestion::Text(suggestion.labels[0][0].value);
}

}  // namespace

// static
base::WeakPtr<AutofillSuggestionController>
AutofillSuggestionController::GetOrCreate(
    base::WeakPtr<AutofillSuggestionController> previous,
    base::WeakPtr<AutofillSuggestionDelegate> delegate,
    content::WebContents* web_contents,
    PopupControllerCommon controller_common,
    int32_t form_control_ax_id) {
  // All controllers on Android derive from
  // `AutofillKeyboardAccessoryControllerImpl`.
  if (AutofillKeyboardAccessoryControllerImpl* previous_impl =
          static_cast<AutofillKeyboardAccessoryControllerImpl*>(previous.get());
      previous_impl && previous_impl->delegate_.get() == delegate.get() &&
      previous_impl->container_view() == controller_common.container_view) {
    if (previous_impl->self_deletion_weak_ptr_factory_.HasWeakPtrs()) {
      previous_impl->self_deletion_weak_ptr_factory_.InvalidateWeakPtrs();
    }
    previous_impl->controller_common_ = std::move(controller_common);
    previous_impl->suggestions_.clear();
    return previous_impl->GetWeakPtr();
  }

  if (previous) {
    previous->Hide(SuggestionHidingReason::kViewDestroyed);
  }
  auto* controller = new AutofillKeyboardAccessoryControllerImpl(
      delegate, web_contents, std::move(controller_common),
      base::BindRepeating(&local_password_migration::ShowWarning));
  return controller->GetWeakPtr();
}

AutofillKeyboardAccessoryControllerImpl::
    AutofillKeyboardAccessoryControllerImpl(
        base::WeakPtr<AutofillSuggestionDelegate> delegate,
        content::WebContents* web_contents,
        PopupControllerCommon controller_common,
        ShowPasswordMigrationWarningCallback
            show_pwd_migration_warning_callback)
    : delegate_(delegate),
      web_contents_(web_contents->GetWeakPtr()),
      controller_common_(std::move(controller_common)),
      show_pwd_migration_warning_callback_(
          std::move(show_pwd_migration_warning_callback)) {}

AutofillKeyboardAccessoryControllerImpl::
    ~AutofillKeyboardAccessoryControllerImpl() = default;

void AutofillKeyboardAccessoryControllerImpl::Hide(
    SuggestionHidingReason reason) {
  // If the reason for hiding is only stale data or a user interacting with
  // native Chrome UI (kFocusChanged/kEndEditing), the popup might be kept open.
  if (is_view_pinned_ && (reason == SuggestionHidingReason::kStaleData ||
                          reason == SuggestionHidingReason::kFocusChanged ||
                          reason == SuggestionHidingReason::kEndEditing)) {
    return;  // Don't close the popup while waiting for an update.
  }
  // For tests, keep open when hiding is due to external stimuli.
  if (keep_popup_open_for_testing_ &&
      (reason == SuggestionHidingReason::kWidgetChanged ||
       reason == SuggestionHidingReason::kEndEditing)) {
    // Don't close the popup because the browser window is resized or because
    // too many fields get focus one after each other (this can happen on
    // Desktop, if multiple password forms are present, and they are all
    // autofilled by default).
    return;
  }

  if (delegate_) {
    delegate_->ClearPreviewedForm();
    delegate_->OnSuggestionsHidden();
  }
  popup_hide_helper_.reset();
  AutofillMetrics::LogAutofillSuggestionHidingReason(
      suggestions_filling_product_, reason);
  HideViewAndDie();
}

void AutofillKeyboardAccessoryControllerImpl::HideViewAndDie() {
  // Invalidates in particular ChromeAutofillClient's WeakPtr to `this`, which
  // prevents recursive calls triggered by `view_->Hide()`
  // (crbug.com/1267047).
  weak_ptr_factory_.InvalidateWeakPtrs();

  // Mark the popup-like filling sources as unavailable.
  // Note: We don't invoke ManualFillingController::Hide() here, as we might
  // switch between text input fields.
  if (web_contents_) {
    if (base::WeakPtr<ManualFillingController> manual_filling_controller =
            ManualFillingController::GetOrCreate(web_contents_.get())) {
      manual_filling_controller->UpdateSourceAvailability(
          FillingSource::AUTOFILL,
          /*has_suggestions=*/false);
    }
  }

  // TODO(crbug.com/1341374, crbug.com/1277218): Move this into the asynchronous
  // call?
  if (view_) {
    view_->Hide();
    view_.reset();
  }

  if (self_deletion_weak_ptr_factory_.HasWeakPtrs()) {
    return;
  }

  // TODO: Examine whether this is really enough or revert to the one from
  // the popup controller.
  base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
      FROM_HERE,
      base::BindOnce(
          [](base::WeakPtr<AutofillKeyboardAccessoryControllerImpl> weak_this) {
            delete weak_this.get();
          },
          self_deletion_weak_ptr_factory_.GetWeakPtr()));
}

void AutofillKeyboardAccessoryControllerImpl::ViewDestroyed() {
  Hide(SuggestionHidingReason::kViewDestroyed);
}

gfx::NativeView AutofillKeyboardAccessoryControllerImpl::container_view()
    const {
  return controller_common_.container_view;
}

content::WebContents* AutofillKeyboardAccessoryControllerImpl::GetWebContents()
    const {
  return web_contents_.get();
}

const gfx::RectF& AutofillKeyboardAccessoryControllerImpl::element_bounds()
    const {
  return controller_common_.element_bounds;
}

PopupAnchorType AutofillKeyboardAccessoryControllerImpl::anchor_type() const {
  return controller_common_.anchor_type;
}

base::i18n::TextDirection
AutofillKeyboardAccessoryControllerImpl::GetElementTextDirection() const {
  return controller_common_.text_direction;
}

void AutofillKeyboardAccessoryControllerImpl::OnSuggestionsChanged() {
  // Assume that suggestions are (still) available. If this is wrong, the method
  // `HideViewAndDie` will be called soon after and will hide all suggestions.
  if (base::WeakPtr<ManualFillingController> manual_filling_controller =
          ManualFillingController::GetOrCreate(web_contents_.get())) {
    manual_filling_controller->UpdateSourceAvailability(
        FillingSource::AUTOFILL,
        /*has_suggestions=*/true);
  }
  if (view_) {
    view_->Show();
  }
}

void AutofillKeyboardAccessoryControllerImpl::AcceptSuggestion(int index) {
  // Ignore clicks immediately after the popup was shown. This is to prevent
  // users accidentally accepting suggestions (crbug.com/1279268).
  if (!barrier_for_accepting_.value() && !disable_threshold_for_testing_) {
    return;
  }

  if (base::checked_cast<size_t>(index) >= suggestions_.size()) {
    return;
  }
  if (IsPointerLocked(web_contents_.get())) {
    Hide(SuggestionHidingReason::kMouseLocked);
    return;
  }

  // Use a copy instead of a reference here. Under certain circumstances,
  // `DidAcceptSuggestion()` invalidate the reference.
  Suggestion suggestion = suggestions_[index];
  if (!suggestion.is_acceptable) {
    return;
  }

  if (base::WeakPtr<ManualFillingController> manual_filling_controller =
          ManualFillingController::GetOrCreate(web_contents_.get())) {
    // Accepting a suggestion should hide all suggestions. To prevent them from
    // coming up in Multi-Window mode, mark the source as unavailable.
    manual_filling_controller->UpdateSourceAvailability(
        FillingSource::AUTOFILL,
        /*has_suggestions=*/false);
    manual_filling_controller->Hide();
  }

  NotifyUserEducationAboutAcceptedSuggestion(web_contents_->GetBrowserContext(),
                                             suggestion);
  if (suggestion.acceptance_a11y_announcement && view_) {
    view_->AxAnnounce(*suggestion.acceptance_a11y_announcement);
  }

  delegate_->DidAcceptSuggestion(
      suggestion, AutofillSuggestionDelegate::SuggestionPosition{.row = index});

  if (suggestion.type == SuggestionType::kPasswordEntry &&
      base::FeatureList::IsEnabled(
          password_manager::features::
              kUnifiedPasswordManagerLocalPasswordsMigrationWarning)) {
    show_pwd_migration_warning_callback_.Run(
        web_contents_->GetTopLevelNativeWindow(),
        Profile::FromBrowserContext(web_contents_->GetBrowserContext()),
        password_manager::metrics_util::PasswordMigrationWarningTriggers::
            kKeyboardAcessoryBar);
  }
}

bool AutofillKeyboardAccessoryControllerImpl::RemoveSuggestion(
    int index,
    AutofillMetrics::SingleEntryRemovalMethod removal_method) {
  CHECK_EQ(removal_method,
           AutofillMetrics::SingleEntryRemovalMethod::kKeyboardAccessory);
  std::u16string title;
  std::u16string body;
  if (!GetRemovalConfirmationText(index, &title, &body)) {
    return false;
  }

  view_->ConfirmDeletion(
      title, body,
      base::BindOnce(
          &AutofillKeyboardAccessoryControllerImpl::OnDeletionDialogClosed,
          GetWeakPtr(), index));
  return true;
}

void AutofillKeyboardAccessoryControllerImpl::OnDeletionDialogClosed(
    int index,
    bool confirmed) {
  // This function might be called in a callback, so ensure the list index is
  // still in bounds. If not, terminate the removing and consider it failed.
  // TODO(crbug.com/40766704): Replace these checks with a stronger identifier.
  if (base::checked_cast<size_t>(index) >= suggestions_.size()) {
    return;
  }
  CHECK_EQ(suggestions_.size(), labels_.size());

  const FillingProduct filling_product =
      GetFillingProductFromSuggestionType(GetSuggestionAt(index).type);
  if (!confirmed) {
    if (filling_product == FillingProduct::kAddress) {
      autofill_metrics::LogDeleteAddressProfileFromExtendedMenu(
          /*user_accepted_delete=*/false);
    }
    return;
  }

  if (!delegate_->RemoveSuggestion(suggestions_[index])) {
    return;
  }
  switch (filling_product) {
    case FillingProduct::kAddress:
      AutofillMetrics::LogDeleteAddressProfileFromKeyboardAccessory();
      break;
    case FillingProduct::kAutocomplete:
      AutofillMetrics::OnAutocompleteSuggestionDeleted(
          AutofillMetrics::SingleEntryRemovalMethod::kKeyboardAccessory);
      if (view_) {
        view_->AxAnnounce(l10n_util::GetStringFUTF16(
            IDS_AUTOFILL_AUTOCOMPLETE_ENTRY_DELETED_A11Y_HINT,
            suggestions_[index].main_text.value));
      }
      break;
    case FillingProduct::kCreditCard:
    case FillingProduct::kStandaloneCvc:
      // TODO(crbug.com/41482065): Add metrics for credit cards.
      break;
    case FillingProduct::kNone:
    case FillingProduct::kMerchantPromoCode:
    case FillingProduct::kIban:
    case FillingProduct::kPassword:
    case FillingProduct::kCompose:
    case FillingProduct::kPlusAddresses:
    case FillingProduct::kPredictionImprovements:
      break;
  }

  // Remove the deleted element.
  suggestions_.erase(suggestions_.begin() + index);
  labels_.erase(labels_.begin() + index);

  if (HasSuggestions()) {
    delegate_->ClearPreviewedForm();
    OnSuggestionsChanged();
  } else {
    Hide(SuggestionHidingReason::kNoSuggestions);
  }
}

int AutofillKeyboardAccessoryControllerImpl::GetLineCount() const {
  return suggestions_.size();
}

const std::vector<Suggestion>&
AutofillKeyboardAccessoryControllerImpl::GetSuggestions() const {
  return suggestions_;
}

const Suggestion& AutofillKeyboardAccessoryControllerImpl::GetSuggestionAt(
    int row) const {
  return suggestions_[row];
}

FillingProduct AutofillKeyboardAccessoryControllerImpl::GetMainFillingProduct()
    const {
  return delegate_->GetMainFillingProduct();
}

std::optional<AutofillClient::PopupScreenLocation>
AutofillKeyboardAccessoryControllerImpl::GetPopupScreenLocation() const {
  return std::nullopt;
}

void AutofillKeyboardAccessoryControllerImpl::Show(
    std::vector<Suggestion> suggestions,
    AutofillSuggestionTriggerSource trigger_source,
    AutoselectFirstSuggestion autoselect_first_suggestion) {
  suggestions_filling_product_ =
      !suggestions.empty() && IsStandaloneSuggestionType(suggestions[0].type)
          ? GetFillingProductFromSuggestionType(suggestions[0].type)
          : FillingProduct::kNone;
  if (auto* rwhv = web_contents_->GetRenderWidgetHostView();
      !rwhv || !rwhv->HasFocus()) {
    Hide(SuggestionHidingReason::kNoFrameHasFocus);
    return;
  }

  // The focused frame may be a different frame than the one the delegate is
  // associated with. This happens in two scenarios:
  // - With frame-transcending forms: the focused frame is subframe, whose
  //   form has been flattened into an ancestor form.
  // - With race conditions: while Autofill parsed the form, the focused may
  //   have moved to another frame.
  // We support the case where the focused frame is a descendant of the
  // `delegate_`'s frame. We observe the focused frame's RenderFrameDeleted()
  // event.
  content::RenderFrameHost* rfh = web_contents_->GetFocusedFrame();
  if (!rfh || !delegate_ ||
      !IsAncestorOf(GetRenderFrameHost(*delegate_), rfh)) {
    Hide(SuggestionHidingReason::kNoFrameHasFocus);
    return;
  }

  if (IsPointerLocked(web_contents_.get())) {
    Hide(SuggestionHidingReason::kMouseLocked);
    return;
  }

  AutofillPopupHideHelper::HidingParams hiding_params = {
      .hide_on_web_contents_lost_focus = true};
  AutofillPopupHideHelper::HidingCallback hiding_callback = base::BindRepeating(
      &AutofillKeyboardAccessoryControllerImpl::Hide, base::Unretained(this));
  // TODO(crbug.com/40280362): Implement PIP hiding for Android.
  popup_hide_helper_.emplace(
      web_contents_.get(), rfh->GetGlobalId(), std::move(hiding_params),
      std::move(hiding_callback),
      /*pip_detection_callback=*/base::BindRepeating([] { return false; }));

  suggestions_ = std::move(suggestions);
  OrderSuggestionsAndCreateLabels();
  trigger_source_ = trigger_source;

  if (view_) {
    OnSuggestionsChanged();
  } else {
    view_ = AutofillKeyboardAccessoryView::Create(GetWeakPtr());
    // It is possible to fail to create the accessory view.
    if (!view_) {
      Hide(SuggestionHidingReason::kViewDestroyed);
      return;
    }

    if (base::WeakPtr<ManualFillingController> manual_filling_controller =
            ManualFillingController::GetOrCreate(web_contents_.get())) {
      manual_filling_controller->UpdateSourceAvailability(
          FillingSource::AUTOFILL, !suggestions_.empty());
    }
    if (view_) {
      view_->Show();
    }
  }

  barrier_for_accepting_ = NextIdleBarrier::CreateNextIdleBarrierWithDelay(
      kIgnoreEarlyClicksOnSuggestionsDuration);
  delegate_->OnSuggestionsShown();
}

void AutofillKeyboardAccessoryControllerImpl::SetKeepPopupOpenForTesting(
    bool keep_popup_open_for_testing) {
  keep_popup_open_for_testing_ = keep_popup_open_for_testing;
}

void AutofillKeyboardAccessoryControllerImpl::UpdateDataListValues(
    base::span<const SelectOption> options) {
  suggestions_ =
      UpdateSuggestionsFromDataList(options, std::move(suggestions_));
  OrderSuggestionsAndCreateLabels();
  if (HasSuggestions()) {
    OnSuggestionsChanged();
  } else {
    Hide(SuggestionHidingReason::kNoSuggestions);
  }
}

void AutofillKeyboardAccessoryControllerImpl::PinView() {
  is_view_pinned_ = true;
}

bool AutofillKeyboardAccessoryControllerImpl::HasSuggestions() const {
  return !suggestions_.empty() &&
         IsStandaloneSuggestionType(suggestions_[0].type);
}

// AutofillKeyboardAccessoryController implementation:

std::vector<std::vector<Suggestion::Text>>
AutofillKeyboardAccessoryControllerImpl::GetSuggestionLabelsAt(int row) const {
  CHECK_LT(base::checked_cast<size_t>(row), labels_.size());
  return {{labels_[row]}};
}

bool AutofillKeyboardAccessoryControllerImpl::GetRemovalConfirmationText(
    int index,
    std::u16string* title,
    std::u16string* body) {
  CHECK_LT(base::checked_cast<size_t>(index), suggestions_.size());
  const std::u16string& value = suggestions_[index].main_text.value;
  const SuggestionType type = suggestions_[index].type;
  const Suggestion::BackendId backend_id =
      suggestions_[index].GetPayload<Suggestion::BackendId>();

  if (type == SuggestionType::kAutocompleteEntry) {
    if (title) {
      title->assign(value);
    }
    if (body) {
      body->assign(l10n_util::GetStringUTF16(
          IDS_AUTOFILL_DELETE_AUTOCOMPLETE_SUGGESTION_CONFIRMATION_BODY));
    }
    return true;
  }

  if (type != SuggestionType::kAddressEntry &&
      type != SuggestionType::kCreditCardEntry) {
    return false;
  }
  PersonalDataManager* pdm = PersonalDataManagerFactory::GetForBrowserContext(
      web_contents_->GetBrowserContext());

  if (const CreditCard* credit_card =
          pdm->payments_data_manager().GetCreditCardByGUID(
              absl::get<Suggestion::Guid>(backend_id).value())) {
    if (!CreditCard::IsLocalCard(credit_card)) {
      return false;
    }
    if (title) {
      title->assign(credit_card->CardNameAndLastFourDigits());
    }
    if (body) {
      body->assign(l10n_util::GetStringUTF16(
          IDS_AUTOFILL_DELETE_CREDIT_CARD_SUGGESTION_CONFIRMATION_BODY));
    }
    return true;
  }

  if (const AutofillProfile* profile =
          pdm->address_data_manager().GetProfileByGUID(
              absl::get<Suggestion::Guid>(backend_id).value())) {
    if (title) {
      std::u16string street_address = profile->GetRawInfo(ADDRESS_HOME_CITY);
      if (!street_address.empty()) {
        title->swap(street_address);
      } else {
        title->assign(value);
      }
    }
    if (body) {
      body->assign(l10n_util::GetStringUTF16(
          IDS_AUTOFILL_DELETE_PROFILE_SUGGESTION_CONFIRMATION_BODY));
    }

    return true;
  }

  return false;  // The ID was valid. The entry may have been deleted in a race.
}

void AutofillKeyboardAccessoryControllerImpl::
    OrderSuggestionsAndCreateLabels() {
  // If there is an Undo suggestion, move it to the front.
  if (auto it = base::ranges::find(suggestions_, SuggestionType::kUndoOrClear,
                                   &Suggestion::type);
      it != suggestions_.end()) {
    std::rotate(suggestions_.begin(), it, it + 1);
  }

  labels_.clear();
  labels_.reserve(suggestions_.size());
  for (const Suggestion& suggestion : suggestions_) {
    labels_.push_back(CreateLabel(suggestion));
  }
}

}  // namespace autofill