// 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/ash/input_method/emoji_suggester.h"
#include <optional>
#include "ash/constants/ash_features.h"
#include "ash/constants/ash_pref_names.h"
#include "base/files/file_util.h"
#include "base/i18n/number_formatting.h"
#include "base/metrics/field_trial_params.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_split.h"
#include "base/strings/string_util.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/task_traits.h"
#include "base/task/thread_pool.h"
#include "chrome/browser/ash/input_method/assistive_prefs.h"
#include "chrome/browser/ui/ash/keyboard/chrome_keyboard_controller_client.h"
#include "chrome/grit/generated_resources.h"
#include "chromeos/ash/services/ime/constants.h"
#include "chromeos/ash/services/ime/public/cpp/assistive_suggestions.h"
#include "components/prefs/scoped_user_pref_update.h"
#include "components/strings/grit/components_strings.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/events/keycodes/dom/dom_code.h"
namespace ash {
namespace input_method {
namespace {
using AssistiveSuggestion = ime::AssistiveSuggestion;
using AssistiveSuggestionMode = ime::AssistiveSuggestionMode;
using AssistiveSuggestionType = ime::AssistiveSuggestionType;
using SuggestionsTextContext = ime::SuggestionsTextContext;
constexpr char kEmojiSuggesterShowSettingCount[] =
"emoji_suggester.show_setting_count";
const int kMaxCandidateSize = 5;
const char kSpaceChar = ' ';
constexpr char kTrimLeadingChars[] = "(";
constexpr char kEmojiMapFilePathName[] = "/emoji/emoji-map.csv";
const int kMaxSuggestionIndex = 31;
const int kMaxSuggestionSize = kMaxSuggestionIndex + 1;
const int kNoneHighlighted = -1;
std::string ReadEmojiDataFromFile() {
if (!base::DirectoryExists(
base::FilePath(ime::kBundledInputMethodsDirPath))) {
return std::string();
}
std::string emoji_data;
base::FilePath::StringType path(ime::kBundledInputMethodsDirPath);
path.append(FILE_PATH_LITERAL(kEmojiMapFilePathName));
if (!base::ReadFileToString(base::FilePath(path), &emoji_data)) {
LOG(WARNING) << "Emoji map file missing.";
}
return emoji_data;
}
std::vector<std::string> SplitString(const std::string& str,
const std::string& delimiter) {
return base::SplitString(str, delimiter, base::TRIM_WHITESPACE,
base::SPLIT_WANT_NONEMPTY);
}
std::string GetLastWord(const std::string& str) {
// We only suggest if last char is a white space so search for last word from
// second last char.
DCHECK_EQ(kSpaceChar, str.back());
size_t last_pos_to_search = str.length() - 2;
auto space_before_last_word = str.find_last_of(" \n", last_pos_to_search);
// If not found, return the entire string up to the last position to search
// else return the last word.
const std::string last_word =
space_before_last_word == std::string::npos
? str.substr(0, last_pos_to_search + 1)
: str.substr(space_before_last_word + 1,
last_pos_to_search - space_before_last_word);
// Remove any leading special characters
return base::ToLowerASCII(
base::TrimString(last_word, kTrimLeadingChars, base::TRIM_LEADING));
}
AssistiveSuggestion MapToAssistiveSuggestion(std::u16string candidate_string) {
return {.mode = AssistiveSuggestionMode::kPrediction,
.type = AssistiveSuggestionType::kAssistiveEmoji,
.text = base::UTF16ToUTF8(candidate_string)};
}
} // namespace
EmojiSuggester::EmojiSuggester(SuggestionHandlerInterface* suggestion_handler,
Profile* profile)
: suggestion_handler_(suggestion_handler),
profile_(profile),
highlighted_index_(kNoneHighlighted) {
LoadEmojiMap();
properties_.type = ash::ime::AssistiveWindowType::kEmojiSuggestion;
suggestion_button_.id = ui::ime::ButtonId::kSuggestion;
suggestion_button_.window_type =
ash::ime::AssistiveWindowType::kEmojiSuggestion;
learn_more_button_.id = ui::ime::ButtonId::kLearnMore;
learn_more_button_.announce_string =
l10n_util::GetStringUTF16(IDS_LEARN_MORE);
learn_more_button_.window_type =
ash::ime::AssistiveWindowType::kEmojiSuggestion;
}
EmojiSuggester::~EmojiSuggester() = default;
void EmojiSuggester::LoadEmojiMap() {
base::ThreadPool::PostTaskAndReplyWithResult(
FROM_HERE, {base::MayBlock()}, base::BindOnce(&ReadEmojiDataFromFile),
base::BindOnce(&EmojiSuggester::OnEmojiDataLoaded,
weak_factory_.GetWeakPtr()));
}
void EmojiSuggester::LoadEmojiMapForTesting(const std::string& emoji_data) {
OnEmojiDataLoaded(emoji_data);
}
void EmojiSuggester::OnEmojiDataLoaded(const std::string& emoji_data) {
// Split data into lines.
for (const auto& line : SplitString(emoji_data, "\n")) {
// Get a word and a string of emojis from the line.
const auto comma_pos = line.find_first_of(",");
DCHECK(comma_pos != std::string::npos);
std::string word = line.substr(0, comma_pos);
std::u16string emojis = base::UTF8ToUTF16(line.substr(comma_pos + 1));
// Build emoji_map_ from splitting the string of emojis.
emoji_map_[word] = base::SplitString(emojis, u";", base::TRIM_WHITESPACE,
base::SPLIT_WANT_NONEMPTY);
// TODO(crbug/1093179): Implement arrow to indicate more emojis available.
// Only loads 5 emojis for now until arrow is implemented.
if (emoji_map_[word].size() > kMaxCandidateSize) {
emoji_map_[word].resize(kMaxCandidateSize);
}
DCHECK_LE(static_cast<int>(emoji_map_[word].size()), kMaxSuggestionSize);
}
}
void EmojiSuggester::RecordAcceptanceIndex(int index) {
base::UmaHistogramExactLinear(
"InputMethod.Assistive.EmojiSuggestAddition.AcceptanceIndex", index,
kMaxSuggestionIndex);
}
void EmojiSuggester::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;
}
void EmojiSuggester::OnBlur() {
focused_context_id_ = std::nullopt;
}
void EmojiSuggester::OnExternalSuggestionsUpdated(
const std::vector<AssistiveSuggestion>& suggestions,
const std::optional<SuggestionsTextContext>& context) {
// EmojiSuggester doesn't utilize any suggestions produced externally, so
// ignore this call.
}
SuggestionStatus EmojiSuggester::HandleKeyEvent(const ui::KeyEvent& event) {
if (!suggestion_shown_) {
return SuggestionStatus::kNotHandled;
}
if (event.code() == ui::DomCode::ESCAPE) {
DismissSuggestion();
return SuggestionStatus::kDismiss;
}
if (highlighted_index_ == kNoneHighlighted && buttons_.size() > 0) {
if (event.code() == ui::DomCode::ARROW_DOWN ||
event.code() == ui::DomCode::ARROW_UP) {
highlighted_index_ =
event.code() == ui::DomCode::ARROW_DOWN ? 0 : buttons_.size() - 1;
SetButtonHighlighted(buttons_[highlighted_index_], true);
return SuggestionStatus::kBrowsing;
}
} else {
if (event.code() == ui::DomCode::ENTER) {
switch (buttons_[highlighted_index_].id) {
case ui::ime::ButtonId::kSuggestion:
AcceptSuggestion(highlighted_index_);
return SuggestionStatus::kAccept;
case ui::ime::ButtonId::kLearnMore:
suggestion_handler_->ClickButton(buttons_[highlighted_index_]);
return SuggestionStatus::kOpenSettings;
default:
break;
}
} else if (event.code() == ui::DomCode::ARROW_UP ||
event.code() == ui::DomCode::ARROW_DOWN) {
SetButtonHighlighted(buttons_[highlighted_index_], false);
if (event.code() == ui::DomCode::ARROW_UP) {
highlighted_index_ =
(highlighted_index_ + buttons_.size() - 1) % buttons_.size();
} else {
highlighted_index_ = (highlighted_index_ + 1) % buttons_.size();
}
SetButtonHighlighted(buttons_[highlighted_index_], true);
return SuggestionStatus::kBrowsing;
}
}
return SuggestionStatus::kNotHandled;
}
bool EmojiSuggester::ShouldShowSuggestion(const std::u16string& text) {
if (text[text.length() - 1] != kSpaceChar) {
return false;
}
std::string last_word =
base::ToLowerASCII(GetLastWord(base::UTF16ToUTF8(text)));
if (!last_word.empty() && emoji_map_.count(last_word)) {
return true;
}
return false;
}
bool EmojiSuggester::TrySuggestWithSurroundingText(
const std::u16string& text,
const gfx::Range selection_range) {
if (emoji_map_.empty() || !focused_context_id_.has_value()) {
return false;
}
// All these below conditions are required for a emoji suggestion to be
// triggered.
// eg. "wow |" where '|' denotes cursor position should trigger an emoji
// suggestion.
const uint32_t len = text.length();
const uint32_t cursor_pos = selection_range.start();
if (!(len && cursor_pos == len // text not empty and cursor is end of text
&& selection_range.is_empty() // no selection
&& text[cursor_pos - 1] == kSpaceChar // space before cursor
)) {
return false;
}
std::string last_word =
base::ToLowerASCII(GetLastWord(base::UTF16ToUTF8(text)));
if (!last_word.empty() && emoji_map_.count(last_word)) {
ShowSuggestion(last_word);
return true;
}
return false;
}
void EmojiSuggester::ShowSuggestion(const std::string& text) {
if (ChromeKeyboardControllerClient::Get()->is_keyboard_visible()) {
return;
}
highlighted_index_ = kNoneHighlighted;
std::string error;
// TODO(crbug.com/40137521): Move suggestion_show_ after checking for error
// and fix tests.
suggestion_shown_ = true;
candidates_ = emoji_map_.at(text);
properties_.visible = true;
properties_.candidates = candidates_;
properties_.announce_string =
l10n_util::GetStringUTF16(IDS_SUGGESTION_EMOJI_SUGGESTED);
properties_.show_setting_link =
GetPrefValue(kEmojiSuggesterShowSettingCount, *profile_) <
kEmojiSuggesterShowSettingMaxCount;
IncrementPrefValueUntilCapped(kEmojiSuggesterShowSettingCount,
kEmojiSuggesterShowSettingMaxCount, *profile_);
ShowSuggestionWindow();
buttons_.clear();
for (size_t i = 0; i < candidates_.size(); i++) {
suggestion_button_.suggestion_index = i;
suggestion_button_.announce_string = l10n_util::GetStringFUTF16(
IDS_SUGGESTION_EMOJI_CHOSEN, candidates_[i], base::FormatNumber(i + 1),
base::FormatNumber(candidates_.size()));
buttons_.push_back(suggestion_button_);
}
if (properties_.show_setting_link) {
buttons_.push_back(learn_more_button_);
}
}
void EmojiSuggester::ShowSuggestionWindow() {
if (!focused_context_id_.has_value()) {
LOG(ERROR) << "suggest: Failed to show suggestion. No context id.";
}
std::string error;
suggestion_handler_->SetAssistiveWindowProperties(*focused_context_id_,
properties_, &error);
if (!error.empty()) {
LOG(ERROR) << "Fail to show suggestion. " << error;
}
}
bool EmojiSuggester::AcceptSuggestion(size_t index) {
if (!focused_context_id_.has_value()) {
LOG(ERROR) << "suggest: Failed to accept suggestion. No context id.";
return false;
}
if (index < 0 || index >= candidates_.size()) {
return false;
}
std::string error;
suggestion_handler_->AcceptSuggestionCandidate(
*focused_context_id_, candidates_[index],
/* delete_previous_utf16_len=*/0, /*use_replace_surrounding_text=*/false,
&error);
if (!error.empty()) {
LOG(ERROR) << "Failed to accept suggestion. " << error;
return false;
}
suggestion_shown_ = false;
RecordAcceptanceIndex(index);
return true;
}
void EmojiSuggester::DismissSuggestion() {
if (!focused_context_id_.has_value()) {
LOG(ERROR) << "suggest: Failed to dismiss suggestion. No context id.";
return;
}
std::string error;
properties_.visible = false;
properties_.announce_string =
l10n_util::GetStringUTF16(IDS_SUGGESTION_DISMISSED);
suggestion_handler_->SetAssistiveWindowProperties(*focused_context_id_,
properties_, &error);
if (!error.empty()) {
LOG(ERROR) << "Failed to dismiss suggestion. " << error;
return;
}
suggestion_shown_ = false;
}
void EmojiSuggester::SetButtonHighlighted(
const ui::ime::AssistiveWindowButton& button,
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_, button,
highlighted, &error);
if (!error.empty()) {
LOG(ERROR) << "Failed to set button highlighted. " << error;
}
}
AssistiveType EmojiSuggester::GetProposeActionType() {
return AssistiveType::kEmoji;
}
bool EmojiSuggester::HasSuggestions() {
return suggestion_shown_;
}
std::vector<AssistiveSuggestion> EmojiSuggester::GetSuggestions() {
std::vector<AssistiveSuggestion> suggestions;
if (HasSuggestions()) {
for (const auto& candidate : candidates_) {
suggestions.emplace_back(MapToAssistiveSuggestion(candidate));
}
}
return suggestions;
}
size_t EmojiSuggester::GetCandidatesSizeForTesting() const {
return candidates_.size();
}
} // namespace input_method
} // namespace ash