chromium/chrome/browser/ui/quick_answers/quick_answers_controller_impl.cc

// Copyright 2020 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/quick_answers/quick_answers_controller_impl.h"

#include "base/check_is_test.h"
#include "base/functional/bind.h"
#include "base/metrics/histogram_functions.h"
#include "base/time/time.h"
#include "build/chromeos_buildflags.h"
#include "chrome/browser/ui/chromeos/read_write_cards/read_write_cards_ui_controller.h"
#include "chrome/browser/ui/quick_answers/quick_answers_ui_controller.h"
#include "chromeos/components/quick_answers/public/cpp/quick_answers_prefs.h"
#include "chromeos/components/quick_answers/public/cpp/quick_answers_state.h"
#include "chromeos/components/quick_answers/quick_answers_client.h"
#include "chromeos/components/quick_answers/quick_answers_model.h"
#include "chromeos/strings/grit/chromeos_strings.h"
#include "components/prefs/pref_service.h"
#include "components/user_manager/user_manager.h"
#include "google_apis/gaia/gaia_auth_util.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/views/controls/menu/menu_controller.h"

#if BUILDFLAG(IS_CHROMEOS_ASH)
#include "chrome/browser/ui/quick_answers/quick_answers_state_ash.h"
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

#if BUILDFLAG(IS_CHROMEOS_LACROS)
#include "chrome/browser/lacros/feedback_util.h"
#include "chrome/browser/ui/quick_answers/lacros/quick_answers_state_lacros.h"
#endif  // BUILDFLAG(IS_CHROMEOS_LACROS)

namespace {

using ::quick_answers::Context;
using ::quick_answers::IntentType;
using ::quick_answers::QuickAnswer;
using ::quick_answers::QuickAnswersClient;
using ::quick_answers::QuickAnswersExitPoint;
using ::quick_answers::QuickAnswersRequest;
using ::quick_answers::ResultType;

constexpr char kQuickAnswersExitPoint[] = "QuickAnswers.ExitPoint";
constexpr char kQuickAnswersConsent[] = "QuickAnswers.V2.Consent";
constexpr char kQuickAnswersConsentImpression[] =
    "QuickAnswers.V2.Consent.Impression";
constexpr char kQuickAnswersConsentDuration[] =
    "QuickAnswers.V2.Consent.Duration";

std::string ConsentResultTypeToString(ConsentResultType type) {
  switch (type) {
    case ConsentResultType::kAllow:
      return "Allow";
    case ConsentResultType::kNoThanks:
      return "NoThanks";
    case ConsentResultType::kDismiss:
      return "Dismiss";
  }
}

void RecordConsentUiHistograms(ConsentResultType consent_result_type,
                               int32_t impression_count,
                               const base::TimeDelta& ui_duration) {
  base::UmaHistogramExactLinear(kQuickAnswersConsent, impression_count,
                                kConsentImpressionCap);

  std::string interaction_type = ConsentResultTypeToString(consent_result_type);
  base::UmaHistogramExactLinear(
      base::StringPrintf("%s.%s", kQuickAnswersConsentImpression,
                         interaction_type.c_str()),
      impression_count, kConsentImpressionCap);
  base::UmaHistogramTimes(
      base::StringPrintf("%s.%s", kQuickAnswersConsentDuration,
                         interaction_type.c_str()),
      ui_duration);
}

// Returns if the request has already been processed (by the text annotator).
bool IsProcessedRequest(const QuickAnswersRequest& request) {
  return (request.preprocessed_output.intent_info.intent_type !=
          quick_answers::IntentType::kUnknown);
}

bool ShouldShowQuickAnswers() {
  if (!QuickAnswersState::IsEligible()) {
    return false;
  }

  if (QuickAnswersState::IsEnabled()) {
    return true;
  }

  // If feature type is `kQuickAnswers`, return `true` for the case `kUnknown`
  // to show a consent UI.
  if (QuickAnswersState::GetFeatureType() ==
      QuickAnswersState::FeatureType::kQuickAnswers) {
    base::expected<quick_answers::prefs::ConsentStatus,
                   QuickAnswersState::Error>
        maybe_consent_status = QuickAnswersState::GetConsentStatus();
    if (!maybe_consent_status.has_value()) {
      return false;
    }

    if (maybe_consent_status.value() ==
        quick_answers::prefs::ConsentStatus::kUnknown) {
      return true;
    }
  }

  return false;
}

bool IsActiveUserInternal() {
#if BUILDFLAG(IS_CHROMEOS_ASH)
  auto* user = user_manager::UserManager::Get()->GetActiveUser();
  const std::string email = user->GetAccountId().GetUserEmail();
#elif BUILDFLAG(IS_CHROMEOS_LACROS)
  const std::string email = feedback_util::GetSignedInUserEmail();
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

  return gaia::IsGoogleInternalAccountEmail(email);
}

// Error case might return nullptr(s). Consider an error case as no result.
// TODO(b/349920395): use std::variant<NoResult, DefinitionResult, ...> for
// structured result.
bool IsNoResult(
    const quick_answers::QuickAnswersSession* quick_answers_session) {
  if (!quick_answers_session) {
    return true;
  }

  if (!quick_answers_session->structured_result) {
    return true;
  }

  return quick_answers_session->structured_result->GetResultType() ==
         ResultType::kNoResult;
}

// TODO(b/340628526): This can be IsEnabled waiter as IsEnabled is gated by a
// consent status now.
class PerformOnConsentAccepted : public QuickAnswersStateObserver {
 public:
  explicit PerformOnConsentAccepted(base::OnceCallback<void()> action)
      : action_(std::move(action)) {
    CHECK(action_);

    // `QuickAnswersState::AddObserver` calls an added observer with a current
    // value (or a pref value later if it's not initialized yet).
    scoped_observation_.Observe(QuickAnswersState::Get());
  }

  // QuickAnswersStateObserver:
  void OnSettingsEnabled(bool enabled) override { MaybeRun(); }

  void OnConsentStatusUpdated(
      quick_answers::prefs::ConsentStatus consent_status) override {
    MaybeRun();
  }

 private:
  void MaybeRun() {
    if (!action_) {
      return;
    }

    bool settings_enabled = QuickAnswersState::IsEnabledAs(
        QuickAnswersState::FeatureType::kQuickAnswers);
    if (!settings_enabled) {
      return;
    }

    if (QuickAnswersState::GetConsentStatusAs(
            QuickAnswersState::FeatureType::kQuickAnswers) !=
        quick_answers::prefs::ConsentStatus::kAccepted) {
      return;
    }

    scoped_observation_.Reset();
    std::move(action_).Run();
  }

  base::ScopedObservation<QuickAnswersState, PerformOnConsentAccepted>
      scoped_observation_{this};
  base::OnceCallback<void()> action_;
};

}  // namespace

QuickAnswersControllerImpl::QuickAnswersControllerImpl(
    chromeos::ReadWriteCardsUiController& read_write_cards_ui_controller)
    : read_write_cards_ui_controller_(read_write_cards_ui_controller),
      quick_answers_ui_controller_(
          std::make_unique<QuickAnswersUiController>(this)) {
#if BUILDFLAG(IS_CHROMEOS_ASH)
  quick_answers_state_ = std::make_unique<QuickAnswersStateAsh>();
#elif BUILDFLAG(IS_CHROMEOS_LACROS)
  quick_answers_state_ = std::make_unique<QuickAnswersStateLacros>();
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)
}

QuickAnswersControllerImpl::~QuickAnswersControllerImpl() {
  // `PerformOnConsentAccepted` depends on `QuickAnswersState`. It has to be
  // destructed before `QuickAnswersState`.
  perform_on_consent_accepted_.reset();

  quick_answers_client_.reset();
  quick_answers_state_.reset();
}

void QuickAnswersControllerImpl::OnContextMenuShown(Profile* profile) {
  menu_shown_time_ = base::TimeTicks::Now();
  visibility_ = QuickAnswersVisibility::kPending;
  profile_ = profile;
}

void QuickAnswersControllerImpl::OnTextAvailable(
    const gfx::Rect& anchor_bounds,
    const std::string& selected_text,
    const std::string& surrounding_text) {
  if (!ShouldShowQuickAnswers())
    return;

  if (visibility_ != QuickAnswersVisibility::kPending) {
    return;
  }

  Context context;
  context.surrounding_text = surrounding_text;
  context.device_properties.is_internal = IsActiveUserInternal();

  // Cache anchor-bounds and query.
  anchor_bounds_ = anchor_bounds;
  // Initially, title is same as query. Title and query can be overridden based
  // on text annotation result at |OnRequestPreprocessFinish|.
  title_ = selected_text;
  query_ = selected_text;
  context_ = context;
  quick_answers_session_.reset();

  QuickAnswersRequest request = BuildRequest();
  if (QuickAnswersState::Get()->ShouldUseQuickAnswersTextAnnotator()) {
    // Send the request for preprocessing. Only shows quick answers view if the
    // predicted intent is not |kUnknown| at |OnRequestPreprocessFinish|.
    quick_answers_client_->SendRequestForPreprocessing(request);
  } else {
    HandleQuickAnswerRequest(request);
  }
}

void QuickAnswersControllerImpl::OnAnchorBoundsChanged(
    const gfx::Rect& anchor_bounds) {
  anchor_bounds_ = anchor_bounds;
}

void QuickAnswersControllerImpl::OnDismiss(bool is_other_command_executed) {
  const base::TimeDelta time_since_request_sent =
      base::TimeTicks::Now() - menu_shown_time_;
  if (is_other_command_executed) {
    base::UmaHistogramTimes("QuickAnswers.ContextMenu.Close.DurationWithClick",
                            time_since_request_sent);
  } else {
    base::UmaHistogramTimes(
        "QuickAnswers.ContextMenu.Close.DurationWithoutClick",
        time_since_request_sent);
  }

  base::UmaHistogramBoolean("QuickAnswers.ContextMenu.Close",
                            is_other_command_executed);

  QuickAnswersExitPoint exit_point =
      is_other_command_executed ? QuickAnswersExitPoint::kContextMenuClick
                                : QuickAnswersExitPoint::kContextMenuDismiss;
  DismissQuickAnswers(exit_point);

  profile_ = nullptr;
}

void QuickAnswersControllerImpl::SetClient(
    std::unique_ptr<QuickAnswersClient> client) {
  quick_answers_client_ = std::move(client);
}

QuickAnswersClient* QuickAnswersControllerImpl::GetClient() const {
  return quick_answers_client_.get();
}

void QuickAnswersControllerImpl::DismissQuickAnswers(
    QuickAnswersExitPoint exit_point) {
  switch (visibility_) {
    case QuickAnswersVisibility::kRichAnswersVisible: {
      // For the rich-answers view, ignore dismissal by context-menu related
      // actions as they should only affect the companion quick-answers views.
      if (exit_point == QuickAnswersExitPoint::kContextMenuDismiss ||
          exit_point == QuickAnswersExitPoint::kContextMenuClick) {
        return;
      }
      quick_answers_ui_controller_->CloseRichAnswersView();
      visibility_ = QuickAnswersVisibility::kClosed;
      return;
    }
    case QuickAnswersVisibility::kUserConsentVisible: {
      // TODO(b/340628526): Replace IsShowingUserConsentView condition and
      // visibility_ write with CHECK.
      if (quick_answers_ui_controller_->IsShowingUserConsentView()) {
        OnUserConsent(ConsentResultType::kDismiss);
      }
      visibility_ = QuickAnswersVisibility::kClosed;
      return;
    }
    case QuickAnswersVisibility::kQuickAnswersVisible:
    case QuickAnswersVisibility::kPending:
    case QuickAnswersVisibility::kClosed: {
      bool closed = quick_answers_ui_controller_->CloseQuickAnswersView();
      visibility_ = QuickAnswersVisibility::kClosed;
      // |quick_answers_session_| could be null before we receive the result
      // from the server. Do not send the signal since the quick answer is
      // dismissed before ready.
      if (quick_answers_session_ && quick_answer()) {
        // For quick-answer rendered along with browser context menu, if user
        // didn't click on other context menu items, it is considered as active
        // impression.
        bool is_active = exit_point != QuickAnswersExitPoint::kContextMenuClick;
        quick_answers_client_->OnQuickAnswersDismissed(
            quick_answer()->result_type, is_active && closed);

        // Record Quick Answers exit point.
        // Make sure |closed| is true so that only the direct exit point is
        // recorded when multiple dismiss requests are received (For example,
        // dismiss request from context menu will also fire when the settings
        // button is pressed).
        if (closed) {
          base::UmaHistogramEnumeration(kQuickAnswersExitPoint, exit_point);
        }
      }
      return;
    }
  }
}

void QuickAnswersControllerImpl::HandleQuickAnswerRequest(
    const quick_answers::QuickAnswersRequest& request) {
  base::expected<quick_answers::prefs::ConsentStatus, QuickAnswersState::Error>
      maybe_consent_status = QuickAnswersState::GetConsentStatus();
  if (!maybe_consent_status.has_value()) {
    // No UI should be shown at this point, i.e., there should be no need to
    // reset UI. Reset is done in `OnTextAvailable` by a next request.
    // TODO(b/352469160): move those states to `QuickAnswersSession` as we can
    // easily reset a state.
    return;
  }

  switch (maybe_consent_status.value()) {
    case quick_answers::prefs::ConsentStatus::kRejected:
      CHECK(false) << "No request should be made if kRejected.";
      return;
    case quick_answers::prefs::ConsentStatus::kUnknown:
      MaybeShowUserConsent(
          request.preprocessed_output.intent_info.intent_type,
          base::UTF8ToUTF16(
              request.preprocessed_output.intent_info.intent_text));
      return;
    case quick_answers::prefs::ConsentStatus::kAccepted:
      visibility_ = QuickAnswersVisibility::kQuickAnswersVisible;
      // TODO(b/327501381): Use `ReadWriteCardsUiController` for this view.
      quick_answers_ui_controller_->CreateQuickAnswersView(
          profile_, title_, query_,
          ToIntent(request.preprocessed_output.intent_info.intent_type),
          quick_answers_state_->GetFeatureType(),
          request.context.device_properties.is_internal);

      if (IsProcessedRequest(request)) {
        quick_answers_client_->FetchQuickAnswers(request);
      } else {
        quick_answers_client_->SendRequest(request);
      }
      return;
  }

  CHECK(false) << "Invalid ConsentStatus enum value provided.";
}

quick_answers::QuickAnswersDelegate*
QuickAnswersControllerImpl::GetQuickAnswersDelegate() {
  return this;
}

QuickAnswersVisibility QuickAnswersControllerImpl::GetQuickAnswersVisibility()
    const {
  return visibility_;
}

void QuickAnswersControllerImpl::SetVisibility(
    QuickAnswersVisibility visibility) {
  visibility_ = visibility;
}

void QuickAnswersControllerImpl::OnQuickAnswerReceived(
    std::unique_ptr<quick_answers::QuickAnswersSession> quick_answers_session) {
  if (visibility_ != QuickAnswersVisibility::kQuickAnswersVisible) {
    return;
  }

  quick_answers_session_ = std::move(quick_answers_session);

  if (IsNoResult(quick_answers_session_.get())) {
    // Fallback query to title if no result is available.
    query_ = title_;
    quick_answers_ui_controller_->SetActiveQuery(profile_, query_);

    // `quick_answers_session_` can be nullptr. Create an empty result session
    // for the case if nullptr.
    if (!quick_answers_session_) {
      quick_answers_session_ =
          std::make_unique<quick_answers::QuickAnswersSession>();
    }
    if (!quick_answers_session_->structured_result) {
      quick_answers_session_->structured_result =
          std::make_unique<quick_answers::StructuredResult>();
    }
  }

  quick_answers_ui_controller_->RenderQuickAnswersViewWithResult(
      *(quick_answers_session_->structured_result));
}

void QuickAnswersControllerImpl::OnNetworkError() {
  if (visibility_ != QuickAnswersVisibility::kQuickAnswersVisible) {
    return;
  }

  // Notify quick_answers_ui_controller_ to show retry UI.
  quick_answers_ui_controller_->ShowRetry();
}

void QuickAnswersControllerImpl::OnRequestPreprocessFinished(
    const QuickAnswersRequest& processed_request) {
  if (!QuickAnswersState::Get()->ShouldUseQuickAnswersTextAnnotator()) {
    // Ignore preprocessing result if text annotator is not enabled.
    return;
  }

  auto intent_type =
      processed_request.preprocessed_output.intent_info.intent_type;

  if (intent_type == quick_answers::IntentType::kUnknown) {
    return;
  }

  auto* active_menu_controller = views::MenuController::GetActiveInstance();
  if (visibility_ == QuickAnswersVisibility::kClosed ||
      !active_menu_controller || !active_menu_controller->owner()) {
    return;
  }

  query_ = processed_request.preprocessed_output.query;
  title_ = processed_request.preprocessed_output.intent_info.intent_text;

  HandleQuickAnswerRequest(processed_request);
}

void QuickAnswersControllerImpl::OnRetryQuickAnswersRequest() {
  QuickAnswersRequest request = BuildRequest();
  if (QuickAnswersState::Get()->ShouldUseQuickAnswersTextAnnotator()) {
    quick_answers_client_->SendRequestForPreprocessing(request);
  } else {
    quick_answers_client_->SendRequest(request);
  }
}

void QuickAnswersControllerImpl::OnQuickAnswersResultClick() {
  CHECK(quick_answers_client_);
  CHECK(quick_answers_session_);
  CHECK(quick_answers_session_->structured_result);

  quick_answers_client_->OnQuickAnswerClick(
      quick_answers_session_->structured_result->GetResultType());
}

void QuickAnswersControllerImpl::OnUserConsent(
    ConsentResultType consent_result_type) {
  CHECK(!consent_ui_shown_.is_null()) << "Consent ui is not shown.";

  quick_answers_ui_controller_->CloseUserConsentView();

  QuickAnswersState* quick_answers_state = QuickAnswersState::Get();
  CHECK(quick_answers_state);

  // It's okay to initialize this as false since there is no chance that this
  // becomes true if `consent_ui_duration` is less than the minimum cap:
  // a. there is no increment for increment cap for the case.
  // b. consent ui should not be shown in the first place for the case.
  bool reached_impression_cap = false;

  base::TimeDelta consent_ui_duration = GetTimeTicksNow() - consent_ui_shown_;
  if (consent_ui_duration.InSeconds() >= kConsentImpressionMinimumDuration) {
    int incremented_count =
        quick_answers_state->AsyncIncrementImpressionCount();
    RecordConsentUiHistograms(consent_result_type, incremented_count,
                              consent_ui_duration);

    reached_impression_cap = incremented_count >= kConsentImpressionCap;
  }

  switch (consent_result_type) {
    case ConsentResultType::kAllow: {
      CHECK_EQ(QuickAnswersState::GetFeatureType(),
               QuickAnswersState::FeatureType::kQuickAnswers)
          << "User consent is handled by Magic Boost if not kQuickAnswers";

      visibility_ = QuickAnswersVisibility::kPending;
      quick_answers_state->AsyncSetConsentStatus(
          quick_answers::prefs::ConsentStatus::kAccepted);

      // Preference value can be updated as an async operation. Wait the value
      // change and then display quick answer for the cached query. There should
      // be no need to reset `perform_on_consent_accepted_` as there is no case
      // a user accepts a consent twice on a device. Toggling from OS settings
      // will set value directly to `kAccepted`.
      CHECK(!perform_on_consent_accepted_)
          << "There is already a pending action. A user should not accept "
             "a consent twice or more.";
      perform_on_consent_accepted_ =
          std::make_unique<PerformOnConsentAccepted>(base::BindOnce(
              &QuickAnswersControllerImpl::OnTextAvailable, GetWeakPtr(),
              anchor_bounds_, title_, context_.surrounding_text));
      break;
    }
    case ConsentResultType::kNoThanks: {
      visibility_ = QuickAnswersVisibility::kClosed;
      quick_answers_state->AsyncSetConsentStatus(
          quick_answers::prefs::ConsentStatus::kRejected);
      break;
    }
    case ConsentResultType::kDismiss:
      visibility_ = QuickAnswersVisibility::kClosed;
      if (reached_impression_cap) {
        quick_answers_state->AsyncSetConsentStatus(
            quick_answers::prefs::ConsentStatus::kRejected);
      }
      break;
  }
}

void QuickAnswersControllerImpl::OnUserConsentResult(bool consented) {
  OnUserConsent(consented ? ConsentResultType::kAllow
                          : ConsentResultType::kNoThanks);
}

base::TimeTicks QuickAnswersControllerImpl::GetTimeTicksNow() {
  if (time_tick_now_function_.is_null()) {
    return base::TimeTicks::Now();
  }

  CHECK_IS_TEST();
  return time_tick_now_function_.Run();
}

void QuickAnswersControllerImpl::OverrideTimeTickNowForTesting(
    TimeTickNowFunction time_tick_now_function) {
  CHECK_IS_TEST();
  CHECK(time_tick_now_function_.is_null());

  time_tick_now_function_ = time_tick_now_function;
}

base::WeakPtr<QuickAnswersControllerImpl>
QuickAnswersControllerImpl::GetWeakPtr() {
  return weak_factory_.GetWeakPtr();
}

bool QuickAnswersControllerImpl::MaybeShowUserConsent(
    IntentType intent_type,
    const std::u16string& intent_text) {
  // For non-QuickAnswers case (i.e., HMR), user consent is handled outside of
  // QuickAnswers code.
  if (QuickAnswersState::GetFeatureType() !=
      QuickAnswersState::FeatureType::kQuickAnswers) {
    return false;
  }

  if (quick_answers_ui_controller_->IsShowingUserConsentView()) {
    return false;
  }

  quick_answers_ui_controller_->CreateUserConsentView(anchor_bounds_,
                                                      intent_type, intent_text);

  consent_ui_shown_ = GetTimeTicksNow();
  visibility_ = QuickAnswersVisibility::kUserConsentVisible;

  return true;
}

QuickAnswersRequest QuickAnswersControllerImpl::BuildRequest() {
  QuickAnswersRequest request;
  request.selected_text = title_;
  request.context = context_;
  return request;
}