chromium/chrome/browser/ash/input_method/multi_word_suggester.cc

// Copyright 2021 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/ash/input_method/multi_word_suggester.h"

#include <cmath>
#include <optional>
#include <string_view>

#include "ash/constants/ash_pref_names.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/string_util.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/time/time.h"
#include "chrome/browser/ash/input_method/suggestion_enums.h"
#include "chrome/browser/ui/ash/input_method/suggestion_details.h"
#include "chromeos/ash/services/ime/public/cpp/assistive_suggestions.h"
#include "components/prefs/scoped_user_pref_update.h"
#include "ui/events/keycodes/dom/dom_code.h"

namespace ash {
namespace input_method {
namespace {

using ime::AssistiveSuggestion;
using ime::AssistiveSuggestionMode;
using ime::AssistiveSuggestionType;
using ime::SuggestionsTextContext;

// Used for UmaHistogramExactLinear, should remain <= 101.
constexpr size_t kMaxSuggestionLength = 101;
constexpr size_t kMinimumNumberOfCharsToProduceSuggestion = 3;
constexpr char kMultiWordFirstAcceptTimeDays[] = "multi_word_first_accept";
constexpr char16_t kSuggestionShownMessage[] =
    u"predictive writing candidate shown, press down to select or "
    u"press tab to accept";
constexpr char kSuggestionSelectedMessage[] =
    "predictive writing candidate selected, candidate text is %s, "
    "press tab to accept or press up to deselect";
constexpr char16_t kSuggestionAcceptedMessage[] =
    u"predictive writing candidate inserted";
constexpr char16_t kSuggestionDismissedMessage[] =
    u"predictive writing candidate dismissed";

std::optional<AssistiveSuggestion> GetMultiWordSuggestion(
    const std::vector<AssistiveSuggestion>& suggestions) {
  if (suggestions.empty()) {
    return std::nullopt;
  }
  if (suggestions[0].type == AssistiveSuggestionType::kMultiWord) {
    // There should only ever be one multi word suggestion given at a time.
    DCHECK_EQ(suggestions.size(), 1u);
    return suggestions[0];
  }
  return std::nullopt;
}

size_t CalculateConfirmedLength(const std::u16string& surrounding_text,
                                const std::u16string& suggestion_text) {
  if (surrounding_text.empty() || suggestion_text.empty()) {
    return 0;
  }

  for (size_t i = suggestion_text.length(); i >= 1; i--) {
    if (base::EndsWith(surrounding_text, suggestion_text.substr(0, i))) {
      return i;
    }
  }

  return 0;
}

MultiWordSuggestionType ToSuggestionType(
    const ime::AssistiveSuggestionMode& suggestion_mode) {
  switch (suggestion_mode) {
    case ime::AssistiveSuggestionMode::kCompletion:
      return MultiWordSuggestionType::kCompletion;
    case ime::AssistiveSuggestionMode::kPrediction:
      return MultiWordSuggestionType::kPrediction;
    default:
      return MultiWordSuggestionType::kUnknown;
  }
}

void RecordTimeToAccept(base::TimeDelta delta) {
  base::UmaHistogramTimes("InputMethod.Assistive.TimeToAccept.MultiWord",
                          delta);
}

void RecordTimeToDismiss(base::TimeDelta delta) {
  base::UmaHistogramTimes("InputMethod.Assistive.TimeToDismiss.MultiWord",
                          delta);
}

void RecordSuggestionLength(size_t suggestion_length) {
  base::UmaHistogramExactLinear(
      "InputMethod.Assistive.MultiWord.SuggestionLength", suggestion_length,
      kMaxSuggestionLength);
}

void RecordCouldPossiblyShowSuggestion(
    const ime::AssistiveSuggestionMode& suggestion_mode) {
  base::UmaHistogramEnumeration(
      "InputMethod.Assistive.MultiWord.CouldPossiblyShowSuggestion",
      ToSuggestionType(suggestion_mode));
}

void RecordImplicitAcceptance(
    const ime::AssistiveSuggestionMode& suggestion_mode) {
  base::UmaHistogramEnumeration(
      "InputMethod.Assistive.MultiWord.ImplicitAcceptance",
      ToSuggestionType(suggestion_mode));
}

void RecordImplicitRejection(
    const ime::AssistiveSuggestionMode& suggestion_mode) {
  base::UmaHistogramEnumeration(
      "InputMethod.Assistive.MultiWord.ImplicitRejection",
      ToSuggestionType(suggestion_mode));
}

void RecordMultiWordSuggestionState(const MultiWordSuggestionState& state,
                                    const ime::AssistiveSuggestionMode& mode) {
  const std::string histogram =
      mode == ime::AssistiveSuggestionMode::kCompletion
          ? "InputMethod.Assistive.MultiWord.SuggestionState.Completion"
          : "InputMethod.Assistive.MultiWord.SuggestionState.Prediction";
  base::UmaHistogramEnumeration(histogram, state);
}

std::optional<int> GetTimeFirstAcceptedSuggestion(Profile* profile) {
  ScopedDictPrefUpdate update(profile->GetPrefs(),
                              prefs::kAssistiveInputFeatureSettings);
  auto value = update->FindInt(kMultiWordFirstAcceptTimeDays);
  if (value.has_value()) {
    return value.value();
  }
  return std::nullopt;
}

void SetTimeFirstAcceptedSuggestion(Profile* profile) {
  ScopedDictPrefUpdate update(profile->GetPrefs(),
                              prefs::kAssistiveInputFeatureSettings);
  auto time_since_epoch = base::Time::Now() - base::Time::UnixEpoch();
  update->Set(kMultiWordFirstAcceptTimeDays, time_since_epoch.InDaysFloored());
}

bool ShouldShowTabGuide(Profile* profile) {
  auto time_first_accepted = GetTimeFirstAcceptedSuggestion(profile);
  if (!time_first_accepted) {
    return true;
  }

  base::TimeDelta first_accepted = base::Days(*time_first_accepted);
  base::TimeDelta time_since_epoch =
      base::Time::Now() - base::Time::UnixEpoch();
  return (time_since_epoch - first_accepted) <= base::Days(7);
}

bool CouldSuggestWithSurroundingText(std::u16string_view text,
                                     const gfx::Range selection_range) {
  return selection_range.is_empty() && selection_range.end() == text.size() &&
         text.size() >= kMinimumNumberOfCharsToProduceSuggestion;
}

bool u16_isalpha(char16_t ch) {
  return (ch >= u'A' && ch <= u'Z') || (ch >= u'a' && ch <= u'z');
}

bool WouldBeInCompletionMode(std::u16string_view text) {
  return !text.empty() && u16_isalpha(text.back());
}

// TODO(crbug/1146266): Add DismissedAccuracy metric back in.

}  // namespace

MultiWordSuggester::MultiWordSuggester(
    SuggestionHandlerInterface* suggestion_handler,
    Profile* profile)
    : suggestion_handler_(suggestion_handler), state_(this), profile_(profile) {
  suggestion_button_.id = ui::ime::ButtonId::kSuggestion;
  suggestion_button_.window_type =
      ash::ime::AssistiveWindowType::kMultiWordSuggestion;
  suggestion_button_.suggestion_index = 0;
}

MultiWordSuggester::~MultiWordSuggester() = default;

void MultiWordSuggester::OnFocus(int context_id) {
  // Some parts of the code reserve negative/zero context_id for unfocused
  // context. As a result we should make sure it is not being erroneously set to
  // a negative number, and cause unexpected behaviour.
  DCHECK(context_id > 0);
  focused_context_id_ = context_id;
  state_.ResetSuggestion();
}

void MultiWordSuggester::OnBlur() {
  focused_context_id_ = std::nullopt;
  state_.ResetSuggestion();
}

void MultiWordSuggester::OnSurroundingTextChanged(
    const std::u16string& text,
    const gfx::Range selection_range) {
  if (CouldSuggestWithSurroundingText(text, selection_range) &&
      !state_.IsSuggestionShowing()) {
    RecordCouldPossiblyShowSuggestion(
        WouldBeInCompletionMode(text)
            ? ime::AssistiveSuggestionMode::kCompletion
            : ime::AssistiveSuggestionMode::kPrediction);
  }

  const uint32_t cursor_pos = selection_range.start();
  auto surrounding_text = SuggestionState::SurroundingText{
      .text = text,
      .cursor_pos = cursor_pos,
      .cursor_at_end_of_text =
          (selection_range.is_empty() && cursor_pos == text.length())};
  state_.UpdateSurroundingText(surrounding_text);
  DisplaySuggestionIfAvailable();
}

void MultiWordSuggester::OnExternalSuggestionsUpdated(
    const std::vector<AssistiveSuggestion>& suggestions,
    const std::optional<SuggestionsTextContext>& context) {
  if (state_.IsSuggestionShowing() || !state_.IsCursorAtEndOfText()) {
    return;
  }

  std::optional<AssistiveSuggestion> multi_word_suggestion =
      GetMultiWordSuggestion(suggestions);

  if (!multi_word_suggestion) {
    state_.UpdateState(SuggestionState::State::kNoSuggestionShown);
    return;
  }

  if (auto suggestion_length = multi_word_suggestion->text.size();
      suggestion_length < kMaxSuggestionLength) {
    RecordSuggestionLength(suggestion_length);
  }

  auto suggestion = SuggestionState::Suggestion{
      .mode = multi_word_suggestion->mode,
      .text = base::UTF8ToUTF16(multi_word_suggestion->text),
      .time_first_shown = base::TimeTicks::Now()};

  if (context) {
    auto suggestion_state = state_.ValidateSuggestion(suggestion, *context);
    RecordMultiWordSuggestionState(suggestion_state, suggestion.mode);
    if (suggestion_state != MultiWordSuggestionState::kValid) {
      return;
    }
  }

  state_.UpdateSuggestion(/*suggestion=*/suggestion,
                          /*new_tracking_behavior=*/context.has_value());
  DisplaySuggestionIfAvailable();
}

SuggestionStatus MultiWordSuggester::HandleKeyEvent(const ui::KeyEvent& event) {
  if (!state_.IsSuggestionShowing()) {
    return SuggestionStatus::kNotHandled;
  }

  switch (event.code()) {
    case ui::DomCode::TAB:
      AcceptSuggestion();
      return SuggestionStatus::kAccept;
    case ui::DomCode::ARROW_DOWN:
      if (state_.IsSuggestionHighlighted()) {
        return SuggestionStatus::kNotHandled;
      }
      state_.ToggleSuggestionHighlight();
      SetSuggestionHighlight(true);
      return SuggestionStatus::kBrowsing;
    case ui::DomCode::ARROW_UP:
      if (!state_.IsSuggestionHighlighted()) {
        return SuggestionStatus::kNotHandled;
      }
      state_.ToggleSuggestionHighlight();
      SetSuggestionHighlight(false);
      return SuggestionStatus::kBrowsing;
    case ui::DomCode::ENTER:
      if (!state_.IsSuggestionHighlighted()) {
        return SuggestionStatus::kNotHandled;
      }
      AcceptSuggestion();
      return SuggestionStatus::kAccept;
    default:
      return SuggestionStatus::kNotHandled;
  }
}

bool MultiWordSuggester::TrySuggestWithSurroundingText(
    const std::u16string& text,
    const gfx::Range selection_range) {
  // MultiWordSuggester does not trigger a suggest based on surrounding text
  // events. It only triggers suggestions OnExternalSuggestionsUpdated.
  //
  // Hence we should return whether the current suggestion is showing from
  // internal state.
  return state_.IsSuggestionShowing();
}

bool MultiWordSuggester::AcceptSuggestion(size_t index) {
  if (!focused_context_id_.has_value()) {
    LOG(ERROR) << "suggest: Failed to accept suggestion. No context id.";
    return false;
  }

  std::string error;
  suggestion_handler_->AcceptSuggestion(*focused_context_id_, &error);
  if (!error.empty()) {
    LOG(ERROR) << "suggest: Failed to accept suggestion - " << error;
    return false;
  }

  auto suggestion = state_.GetSuggestion();
  if (suggestion) {
    RecordTimeToAccept(base::TimeTicks::Now() - suggestion->time_first_shown);
  }

  if (!GetTimeFirstAcceptedSuggestion(profile_)) {
    SetTimeFirstAcceptedSuggestion(profile_);
  }

  state_.UpdateState(SuggestionState::State::kSuggestionAccepted);
  state_.ResetSuggestion();
  return true;
}

void MultiWordSuggester::DismissSuggestion() {
  if (!focused_context_id_.has_value()) {
    LOG(ERROR) << "suggest: Failed to dismiss suggestion. No context id.";
    return;
  }
  std::string error;
  suggestion_handler_->DismissSuggestion(*focused_context_id_, &error);
  if (!error.empty()) {
    LOG(ERROR) << "suggest: Failed to dismiss suggestion - " << error;
    return;
  }

  auto suggestion = state_.GetSuggestion();
  if (suggestion) {
    RecordTimeToDismiss(base::TimeTicks::Now() - suggestion->time_first_shown);
  }

  state_.UpdateState(SuggestionState::State::kSuggestionDismissed);
  state_.ResetSuggestion();
}

AssistiveType MultiWordSuggester::GetProposeActionType() {
  return state_.GetLastSuggestionType();
}

bool MultiWordSuggester::HasSuggestions() {
  return state_.GetSuggestion().has_value();
}

std::vector<AssistiveSuggestion> MultiWordSuggester::GetSuggestions() {
  auto suggestion = state_.GetSuggestion();
  if (!suggestion) {
    return {};
  }
  return {
      AssistiveSuggestion{.mode = suggestion->mode,
                          .type = AssistiveSuggestionType::kMultiWord,
                          .text = base::UTF16ToUTF8(suggestion->text),
                          .confirmed_length = suggestion->confirmed_length}};
}

void MultiWordSuggester::DisplaySuggestionIfAvailable() {
  auto suggestion_to_display = state_.GetSuggestion();
  if (suggestion_to_display.has_value()) {
    DisplaySuggestion(*suggestion_to_display);
  }
}

void MultiWordSuggester::DisplaySuggestion(
    const SuggestionState::Suggestion& suggestion) {
  if (!focused_context_id_.has_value()) {
    LOG(ERROR) << "suggest: Failed to show suggestion. No context id.";
    return;
  }
  ui::ime::SuggestionDetails details;
  details.text = suggestion.text;
  details.show_accept_annotation = false;
  details.show_quick_accept_annotation = ShouldShowTabGuide(profile_);
  details.confirmed_length = suggestion.confirmed_length;
  details.show_setting_link = false;

  suggestion_button_.announce_string = base::UTF8ToUTF16(base::StringPrintf(
      kSuggestionSelectedMessage, base::UTF16ToUTF8(details.text).c_str()));

  std::string error;
  suggestion_handler_->SetSuggestion(*focused_context_id_, details, &error);
  if (!error.empty()) {
    LOG(ERROR) << "suggest: Failed to show suggestion in assistive framework"
               << " - " << error;
  }
}

void MultiWordSuggester::SetSuggestionHighlight(bool highlighted) {
  if (!focused_context_id_.has_value()) {
    LOG(ERROR) << "suggest: Failed to set button highlighted. No context id.";
    return;
  }
  std::string error;
  suggestion_handler_->SetButtonHighlighted(
      *focused_context_id_, suggestion_button_, highlighted, &error);
  if (!error.empty()) {
    LOG(ERROR) << "Failed to set button highlighted. " << error;
  }
}

void MultiWordSuggester::Announce(const std::u16string& message) {
  if (suggestion_handler_) {
    suggestion_handler_->Announce(message);
  }
}

MultiWordSuggester::SuggestionState::SuggestionState(
    MultiWordSuggester* suggester)
    : suggester_(suggester) {}

MultiWordSuggester::SuggestionState::~SuggestionState() = default;

void MultiWordSuggester::SuggestionState::UpdateState(const State& state) {
  if (state == State::kPredictionSuggestionShown) {
    last_suggestion_type_ = AssistiveType::kMultiWordPrediction;
  }

  if (state == State::kCompletionSuggestionShown) {
    last_suggestion_type_ = AssistiveType::kMultiWordCompletion;
  }

  if (state_ == State::kNoSuggestionShown &&
      (state == State::kPredictionSuggestionShown ||
       state == State::kCompletionSuggestionShown)) {
    suggester_->Announce(kSuggestionShownMessage);
  }

  if ((state_ == State::kPredictionSuggestionShown ||
       state_ == State::kCompletionSuggestionShown ||
       state_ == State::kTrackingLastSuggestionShown) &&
      state == State::kSuggestionAccepted) {
    suggester_->Announce(kSuggestionAcceptedMessage);
  }

  if ((state_ == State::kPredictionSuggestionShown ||
       state_ == State::kCompletionSuggestionShown ||
       state_ == State::kTrackingLastSuggestionShown) &&
      state == State::kSuggestionDismissed) {
    suggester_->Announce(kSuggestionDismissedMessage);
  }

  state_ = state;
}

void MultiWordSuggester::SuggestionState::UpdateSurroundingText(
    const MultiWordSuggester::SuggestionState::SurroundingText&
        surrounding_text) {
  size_t prev_cursor_pos =
      surrounding_text_.has_value() ? surrounding_text_->cursor_pos : 0;

  surrounding_text_ = SurroundingText{
      .text = surrounding_text.text,
      .cursor_pos = surrounding_text.cursor_pos,
      .prev_cursor_pos = prev_cursor_pos,
      .cursor_at_end_of_text = surrounding_text.cursor_at_end_of_text};

  ReconcileSuggestionWithText();
}

void MultiWordSuggester::SuggestionState::UpdateSuggestion(
    const MultiWordSuggester::SuggestionState::Suggestion& suggestion,
    bool new_tracking_behavior) {
  suggestion_ = suggestion;
  suggestion_->original_surrounding_text_length =
      surrounding_text_ ? surrounding_text_->text.length() : 0;
  UpdateState(suggestion.mode == AssistiveSuggestionMode::kCompletion
                  ? State::kCompletionSuggestionShown
                  : State::kPredictionSuggestionShown);
  if (suggestion.mode == AssistiveSuggestionMode::kCompletion) {
    ReconcileSuggestionWithText();
  }
  if (new_tracking_behavior &&
      suggestion.mode == AssistiveSuggestionMode::kPrediction) {
    // With the new tracking behavior we are guaranteed that any new suggestion
    // is not stale, and thus can be simply appended to the current surrrounding
    // text. Therefore there is no need to reconcile with the current text and
    // we can transition straight to tracking mode.
    UpdateState(State::kTrackingLastSuggestionShown);
  }
}

MultiWordSuggestionState
MultiWordSuggester::SuggestionState::ValidateSuggestion(
    const MultiWordSuggester::SuggestionState::Suggestion& suggestion,
    const ime::SuggestionsTextContext& context) {
  if (!surrounding_text_) {
    return MultiWordSuggestionState::kOther;
  }

  // IME service works with UTF8 whereas here in Chromium surrounding text is
  // UTF16. The length of the surrounding text from the IME service was
  // calculated on a UTF8 string, so transforming context.last_n_chars to
  // UTF16 would invalidate the length sent from IME service.
  const std::string current_text = base::UTF16ToUTF8(surrounding_text_->text);
  size_t current_text_length = current_text.length();
  size_t text_length_when_suggested = context.surrounding_text_length;
  bool text_matches = base::EndsWith(current_text, context.last_n_chars);

  if (current_text_length == text_length_when_suggested && text_matches) {
    return MultiWordSuggestionState::kValid;
  }

  if (current_text_length == text_length_when_suggested && !text_matches) {
    return MultiWordSuggestionState::kStaleAndUserEditedText;
  }

  if (current_text_length < text_length_when_suggested) {
    return MultiWordSuggestionState::kStaleAndUserDeletedText;
  }

  if (current_text_length > text_length_when_suggested) {
    return CalculateConfirmedLength(surrounding_text_->text, suggestion.text) >
                   0
               ? MultiWordSuggestionState::kStaleAndUserAddedMatchingText
               : MultiWordSuggestionState::kStaleAndUserAddedDifferentText;
  }

  return MultiWordSuggestionState::kOther;
}

void MultiWordSuggester::SuggestionState::ReconcileSuggestionWithText() {
  if (!(suggestion_ && surrounding_text_)) {
    return;
  }

  size_t new_confirmed_length =
      CalculateConfirmedLength(surrounding_text_->text, suggestion_->text);

  // Save the calculated confirmed length on first showing of a completion
  // suggestion. This will be used later when determining if a suggestion
  // should be dismissed or not.
  auto initial_confirmed_length = state_ == State::kCompletionSuggestionShown
                                      ? new_confirmed_length
                                      : suggestion_->initial_confirmed_length;

  bool user_typed_suggestion =
      new_confirmed_length == suggestion_->text.length();

  // Are we still tracking the last suggestion shown to the user?
  //
  // TODO(b/279114189): Prediction suggestions are not dismissed correctly on
  //    first mismatched character typed, need to investigate.
  bool no_longer_tracking =
      state_ == State::kTrackingLastSuggestionShown &&
      ((new_confirmed_length == 0 ||
        new_confirmed_length < suggestion_->initial_confirmed_length) ||
       (new_confirmed_length == suggestion_->confirmed_length &&
        surrounding_text_->cursor_pos != surrounding_text_->prev_cursor_pos) ||
       user_typed_suggestion);

  bool user_has_typed_more = surrounding_text_->text.length() >
                             suggestion_->original_surrounding_text_length;
  if ((state_ == State::kPredictionSuggestionShown ||
       state_ == State::kTrackingLastSuggestionShown) &&
      new_confirmed_length == 0 && user_has_typed_more) {
    RecordImplicitRejection(suggestion_->mode);
  }

  if (no_longer_tracking && user_typed_suggestion) {
    RecordImplicitAcceptance(suggestion_->mode);
  }

  if (no_longer_tracking || !surrounding_text_->cursor_at_end_of_text) {
    UpdateState(State::kSuggestionDismissed);
    ResetSuggestion();
    return;
  }

  if (state_ == State::kPredictionSuggestionShown ||
      state_ == State::kCompletionSuggestionShown) {
    UpdateState(State::kTrackingLastSuggestionShown);
  }

  suggestion_ = Suggestion{.mode = suggestion_->mode,
                           .text = suggestion_->text,
                           .confirmed_length = new_confirmed_length,
                           .initial_confirmed_length = initial_confirmed_length,
                           .time_first_shown = suggestion_->time_first_shown,
                           .highlighted = suggestion_->highlighted,
                           .original_surrounding_text_length =
                               suggestion_->original_surrounding_text_length};
}

void MultiWordSuggester::SuggestionState::ToggleSuggestionHighlight() {
  if (!suggestion_) {
    return;
  }
  suggestion_->highlighted = !suggestion_->highlighted;
}

bool MultiWordSuggester::SuggestionState::IsSuggestionHighlighted() {
  if (!suggestion_) {
    return false;
  }
  return suggestion_->highlighted;
}

bool MultiWordSuggester::SuggestionState::IsSuggestionShowing() {
  return (state_ == State::kPredictionSuggestionShown ||
          state_ == State::kCompletionSuggestionShown ||
          state_ == State::kTrackingLastSuggestionShown);
}

bool MultiWordSuggester::SuggestionState::IsCursorAtEndOfText() {
  if (!surrounding_text_) {
    return false;
  }
  return surrounding_text_->cursor_at_end_of_text;
}

std::optional<MultiWordSuggester::SuggestionState::Suggestion>
MultiWordSuggester::SuggestionState::GetSuggestion() {
  return suggestion_;
}

void MultiWordSuggester::SuggestionState::ResetSuggestion() {
  suggestion_ = std::nullopt;
  UpdateState(State::kNoSuggestionShown);
}

AssistiveType MultiWordSuggester::SuggestionState::GetLastSuggestionType() {
  return last_suggestion_type_;
}

}  // namespace input_method
}  // namespace ash