blob: 1cc2bf8427466d1be4e13583eb1cb647c44566ca [file] [log] [blame]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ash/picker/picker_controller.h"
#include <memory>
#include <string>
#include <string_view>
#include <utility>
#include <variant>
#include <vector>
#include "ash/constants/ash_switches.h"
#include "ash/picker/model/picker_model.h"
#include "ash/picker/model/picker_search_results_section.h"
#include "ash/picker/picker_asset_fetcher.h"
#include "ash/picker/picker_asset_fetcher_impl.h"
#include "ash/picker/picker_clipboard_provider.h"
#include "ash/picker/picker_copy_media.h"
#include "ash/picker/picker_insert_media_request.h"
#include "ash/picker/picker_paste_request.h"
#include "ash/picker/picker_rich_media.h"
#include "ash/picker/search/picker_date_search.h"
#include "ash/picker/search/picker_math_search.h"
#include "ash/picker/search/picker_search_controller.h"
#include "ash/picker/views/picker_icons.h"
#include "ash/picker/views/picker_positioning.h"
#include "ash/picker/views/picker_view.h"
#include "ash/picker/views/picker_view_delegate.h"
#include "ash/picker/views/picker_widget.h"
#include "ash/public/cpp/clipboard_history_controller.h"
#include "ash/public/cpp/picker/picker_client.h"
#include "ash/public/cpp/picker/picker_search_result.h"
#include "ash/wm/window_util.h"
#include "base/check.h"
#include "base/check_is_test.h"
#include "base/command_line.h"
#include "base/functional/bind.h"
#include "base/functional/overloaded.h"
#include "base/hash/sha1.h"
#include "base/i18n/case_conversion.h"
#include "base/memory/scoped_refptr.h"
#include "base/notreached.h"
#include "base/strings/utf_string_conversions.h"
#include "services/network/public/cpp/shared_url_loader_factory.h"
#include "ui/aura/client/focus_client.h"
#include "ui/aura/window.h"
#include "ui/base/emoji/emoji_panel_helper.h"
#include "ui/base/ime/ash/ime_bridge.h"
#include "ui/base/ime/ash/ime_keyboard.h"
#include "ui/base/ime/ash/input_method_manager.h"
#include "ui/base/ime/input_method.h"
#include "ui/base/ime/text_input_client.h"
#include "ui/display/screen.h"
#include "ui/gfx/geometry/point.h"
#include "ui/gfx/geometry/rect.h"
namespace ash {
namespace {
bool g_should_check_key = true;
// The hash value for the feature key of the Picker feature, used for
// development.
constexpr std::string_view kPickerFeatureDevKeyHash(
"\xE1\xC0\x09\x7F\xBE\x03\xBF\x48\xA7\xA0\x30\x53\x07\x4F\xFB\xC5\x6D\xD4"
"\x22\x5F",
base::kSHA1Length);
// The hash value for the feature key of the Picker feature, used in some tests.
constexpr std::string_view kPickerFeatureTestKeyHash(
"\xE7\x2C\x99\xD7\x99\x89\xDB\xA5\x9D\x06\x4A\xED\xDF\xE5\x30\xA7\x8C\x76"
"\x00\x89",
base::kSHA1Length);
// Time from when a start starts to when the first set of results are published.
constexpr base::TimeDelta kBurnInPeriod = base::Milliseconds(200);
enum class PickerFeatureKeyType { kNone, kDev, kTest };
PickerFeatureKeyType MatchPickerFeatureKeyHash() {
// Command line looks like:
// out/Default/chrome --user-data-dir=/tmp/tmp123
// --picker-feature-key="INSERT KEY HERE" --enable-features=PickerFeature
const std::string provided_key_hash = base::SHA1HashString(
base::CommandLine::ForCurrentProcess()->GetSwitchValueASCII(
switches::kPickerFeatureKey));
if (provided_key_hash == kPickerFeatureDevKeyHash) {
return PickerFeatureKeyType::kDev;
}
if (provided_key_hash == kPickerFeatureTestKeyHash) {
return PickerFeatureKeyType::kTest;
}
return PickerFeatureKeyType::kNone;
}
ui::TextInputClient* GetFocusedTextInputClient() {
const ui::InputMethod* input_method =
IMEBridge::Get()->GetInputContextHandler()->GetInputMethod();
if (!input_method || !input_method->GetTextInputClient()) {
return nullptr;
}
return input_method->GetTextInputClient();
}
// Gets the current caret bounds in universal screen coordinates in DIP. Returns
// an empty rect if there is no active caret or the caret bounds can't be
// determined (e.g. no focused input field).
gfx::Rect GetCaretBounds() {
if (ui::TextInputClient* client = GetFocusedTextInputClient()) {
return client->GetCaretBounds();
}
return gfx::Rect();
}
// Gets the current cursor point in universal screen coordinates in DIP.
gfx::Point GetCursorPoint() {
return display::Screen::GetScreen()->GetCursorScreenPoint();
}
// Gets the bounds of the current focused window in universal screen coordinates
// in DIP. Returns an empty rect if there is no currently focused window.
gfx::Rect GetFocusedWindowBounds() {
return window_util::GetFocusedWindow()
? window_util::GetFocusedWindow()->GetBoundsInScreen()
: gfx::Rect();
}
input_method::ImeKeyboard& GetImeKeyboard() {
auto* input_method_manager = input_method::InputMethodManager::Get();
CHECK(input_method_manager);
input_method::ImeKeyboard* ime_keyboard =
input_method_manager->GetImeKeyboard();
CHECK(ime_keyboard);
return *ime_keyboard;
}
// The user can ask to insert rich media, a clipboard item, or insert nothing.
using InsertionContent = std::
variant<PickerRichMedia, PickerSearchResult::ClipboardData, std::monostate>;
InsertionContent GetInsertionContentForResult(
const PickerSearchResult& result) {
using ReturnType = InsertionContent;
return std::visit(
base::Overloaded{
[](const PickerSearchResult::TextData& data) -> ReturnType {
return PickerTextMedia(data.primary_text);
},
[](const PickerSearchResult::EmojiData& data) -> ReturnType {
return PickerTextMedia(data.emoji);
},
[](const PickerSearchResult::SymbolData& data) -> ReturnType {
return PickerTextMedia(data.symbol);
},
[](const PickerSearchResult::EmoticonData& data) -> ReturnType {
return PickerTextMedia(data.emoticon);
},
[](const PickerSearchResult::ClipboardData& data) -> ReturnType {
return data;
},
[](const PickerSearchResult::GifData& data) -> ReturnType {
return PickerImageMedia(data.full_url, data.full_dimensions,
data.content_description);
},
[](const PickerSearchResult::BrowsingHistoryData& data)
-> ReturnType { return PickerLinkMedia(data.url); },
[](const PickerSearchResult::LocalFileData& data) -> ReturnType {
return PickerLocalFileMedia(data.file_path);
},
[](const PickerSearchResult::DriveFileData& data) -> ReturnType {
return PickerLinkMedia(data.url);
},
[](const PickerSearchResult::CategoryData& data) -> ReturnType {
return std::monostate();
},
[](const PickerSearchResult::SearchRequestData& data) -> ReturnType {
return std::monostate();
},
[](const PickerSearchResult::EditorData& data) -> ReturnType {
return std::monostate();
},
},
result.data());
}
std::vector<PickerSearchResultsSection> CreateSingleSectionForCategoryResults(
PickerSectionType section_type,
std::vector<PickerSearchResult> results) {
return {PickerSearchResultsSection(section_type, std::move(results),
/*has_more_results*/ false)};
}
bool u16_isalpha(char16_t ch) {
return (ch >= u'A' && ch <= u'Z') || (ch >= u'a' && ch <= u'z');
}
bool u16_is_sentence_end(char16_t ch) {
return ch == u'.' || ch == u'!' || ch == '?';
}
std::u16string ToTitleCase(std::u16string_view text) {
std::u16string result(text);
std::u16string uppercase_text = base::i18n::ToUpper(text);
for (size_t i = 0; i < result.length(); i++) {
if (u16_isalpha(result[i]) && (i == 0 || result[i - 1] == u' ')) {
result[i] = uppercase_text[i];
}
}
return result;
}
std::u16string ToSentenceCase(std::u16string_view text) {
std::u16string result(text);
std::u16string uppercase_text = base::i18n::ToUpper(text);
bool sentence_start = true;
for (size_t i = 0; i < result.length(); i++) {
if (u16_isalpha(result[i]) && sentence_start) {
result[i] = uppercase_text[i];
}
if (u16_is_sentence_end(result[i])) {
sentence_start = true;
} else if (result[i] != u' ') {
sentence_start = false;
}
}
return result;
}
std::u16string TransformText(std::u16string_view text,
PickerCategory category) {
switch (category) {
case PickerCategory::kUpperCase:
return base::i18n::ToUpper(text);
case PickerCategory::kLowerCase:
return base::i18n::ToLower(text);
case PickerCategory::kSentenceCase:
return ToSentenceCase(text);
case PickerCategory::kTitleCase:
return ToTitleCase(text);
case PickerCategory::kEditorWrite:
case PickerCategory::kEditorRewrite:
case PickerCategory::kLinks:
case PickerCategory::kExpressions:
case PickerCategory::kDriveFiles:
case PickerCategory::kLocalFiles:
case PickerCategory::kDatesTimes:
case PickerCategory::kUnitsMaths:
case PickerCategory::kClipboard:
case PickerCategory::kCapsOn:
case PickerCategory::kCapsOff:
NOTREACHED_NORETURN();
}
NOTREACHED_NORETURN();
}
} // namespace
PickerController::PickerController() {
// `base::Unretained` is safe here because this class owns `asset_fetcher_`.
asset_fetcher_ = std::make_unique<PickerAssetFetcherImpl>(base::BindRepeating(
&PickerController::GetSharedURLLoaderFactory, base::Unretained(this)));
clipboard_provider_ = std::make_unique<PickerClipboardProvider>();
}
PickerController::~PickerController() {
// `widget_` depends on `this`. Destroy the widget synchronously to avoid a
// dangling pointer.
if (widget_) {
widget_->CloseNow();
}
}
bool PickerController::IsFeatureKeyMatched() {
if (!g_should_check_key) {
return true;
}
if (MatchPickerFeatureKeyHash() == PickerFeatureKeyType::kNone) {
LOG(ERROR) << "Provided feature key does not match with the expected one.";
return false;
}
return true;
}
void PickerController::DisableFeatureKeyCheckForTesting() {
CHECK_IS_TEST();
g_should_check_key = false;
}
void PickerController::SetClient(PickerClient* client) {
client_ = client;
// The destructor of `PickerSearchRequest` inside `PickerSearchController` may
// result in "stop search" calls to the PREVIOUS `PickerClient`.
if (client_ == nullptr) {
search_controller_ = nullptr;
} else {
search_controller_ =
std::make_unique<PickerSearchController>(client_, kBurnInPeriod);
}
}
void PickerController::ToggleWidget(
const base::TimeTicks trigger_event_timestamp) {
CHECK(client_);
if (widget_) {
session_metrics_->RecordOutcome(
PickerSessionMetrics::SessionOutcome::kAbandoned);
widget_->Close();
model_.reset();
} else {
show_editor_callback_ = client_->CacheEditorContext();
model_ = std::make_unique<PickerModel>(
GetFocusedTextInputClient(), &GetImeKeyboard(),
show_editor_callback_.is_null() ? PickerModel::EditorStatus::kDisabled
: PickerModel::EditorStatus::kEnabled);
widget_ = PickerWidget::Create(
this,
GetPickerAnchorBounds(GetCaretBounds(), GetCursorPoint(),
GetFocusedWindowBounds()),
trigger_event_timestamp);
widget_->Show();
feature_usage_metrics_.StartUsage();
session_metrics_ = std::make_unique<PickerSessionMetrics>();
session_metrics_->OnStartSession(GetFocusedTextInputClient());
widget_observation_.Observe(widget_.get());
}
}
std::vector<PickerCategory> PickerController::GetAvailableCategories() {
return model_ == nullptr ? std::vector<PickerCategory>{}
: model_->GetAvailableCategories();
}
bool PickerController::ShouldShowSuggestedResults() {
return model_ && !model_->HasSelectedText();
}
void PickerController::GetResultsForCategory(PickerCategory category,
SearchResultsCallback callback) {
// TODO: b/325977099 - Get actual results for each category.
std::vector<ash::PickerSearchResult> recent_results;
switch (category) {
case PickerCategory::kEditorWrite:
case PickerCategory::kEditorRewrite:
case PickerCategory::kUpperCase:
case PickerCategory::kLowerCase:
case PickerCategory::kSentenceCase:
case PickerCategory::kTitleCase:
case PickerCategory::kCapsOn:
case PickerCategory::kCapsOff:
NOTREACHED_NORETURN();
case PickerCategory::kLinks:
// TODO: b/330589902 - Use correct PickerSectionType for this.
client_->GetSuggestedLinkResults(
base::BindRepeating(CreateSingleSectionForCategoryResults,
PickerSectionType::kRecentlyUsed)
.Then(std::move(callback)));
return;
case PickerCategory::kExpressions:
NOTREACHED_NORETURN();
case PickerCategory::kDriveFiles:
client_->GetRecentDriveFileResults(
base::BindRepeating(CreateSingleSectionForCategoryResults,
PickerSectionType::kRecentlyUsed)
.Then(std::move(callback)));
return;
case PickerCategory::kLocalFiles:
client_->GetRecentLocalFileResults(
base::BindRepeating(CreateSingleSectionForCategoryResults,
PickerSectionType::kRecentlyUsed)
.Then(std::move(callback)));
return;
case PickerCategory::kDatesTimes:
std::move(callback).Run(CreateSingleSectionForCategoryResults(
PickerSectionType::kSuggestions, PickerSuggestedDateResults()));
break;
case PickerCategory::kUnitsMaths:
std::move(callback).Run(CreateSingleSectionForCategoryResults(
PickerSectionType::kExamples, PickerMathExamples()));
break;
case PickerCategory::kClipboard:
clipboard_provider_->FetchResults(
base::BindRepeating(CreateSingleSectionForCategoryResults,
PickerSectionType::kRecentlyUsed)
.Then(std::move(callback)));
return;
}
}
void PickerController::TransformSelectedText(PickerCategory category) {
if (!model_) {
return;
}
std::u16string_view selected_text = model_->selected_text();
InsertResultOnNextFocus(PickerSearchResult::Text(
TransformText(selected_text, category),
PickerSearchResult::TextData::Source::kCaseTransform));
}
void PickerController::StartSearch(const std::u16string& query,
std::optional<PickerCategory> category,
SearchResultsCallback callback) {
CHECK(search_controller_);
search_controller_->StartSearch(query, std::move(category),
GetAvailableCategories(),
std::move(callback));
}
void PickerController::InsertResultOnNextFocus(
const PickerSearchResult& result) {
if (!widget_) {
return;
}
std::visit(
base::Overloaded{
[&](PickerRichMedia media) {
ui::InputMethod* input_method = widget_->GetInputMethod();
if (input_method == nullptr) {
return;
}
// This cancels the previous request if there was one.
insert_media_request_ = std::make_unique<PickerInsertMediaRequest>(
input_method, media, kInsertMediaTimeout,
base::BindOnce(
[](const PickerRichMedia& media,
PickerInsertMediaRequest::Result result) {
// Fallback to copying to the clipboard on failure.
if (result !=
PickerInsertMediaRequest::Result::kSuccess) {
CopyMediaToClipboard(media);
}
},
media));
},
[&](PickerSearchResult::ClipboardData data) {
// This cancels the previous request if there was one.
paste_request_ = std::make_unique<PickerPasteRequest>(
ClipboardHistoryController::Get(),
aura::client::GetFocusClient(widget_->GetNativeView()),
data.item_id);
},
[](std::monostate) { NOTREACHED_NORETURN(); },
},
GetInsertionContentForResult(result));
session_metrics_->RecordOutcome(
PickerSessionMetrics::SessionOutcome::kInsertedOrCopied);
}
void PickerController::ShowEmojiPicker(ui::EmojiPickerCategory category) {
ui::ShowEmojiPanelInSpecificMode(category,
ui::EmojiPickerFocusBehavior::kAlwaysShow);
}
void PickerController::ShowEditor(std::optional<std::string> preset_query_id,
std::optional<std::string> freeform_text) {
if (!show_editor_callback_.is_null()) {
std::move(show_editor_callback_)
.Run(std::move(preset_query_id), std::move(freeform_text));
}
}
void PickerController::SetCapsLockEnabled(bool enabled) {
GetImeKeyboard().SetCapsLockEnabled(enabled);
}
void PickerController::GetSuggestedEditorResults(
SuggestedEditorResultsCallback callback) {
client_->GetSuggestedEditorResults(std::move(callback));
}
PickerAssetFetcher* PickerController::GetAssetFetcher() {
return asset_fetcher_.get();
}
void PickerController::OnWidgetDestroying(views::Widget* widget) {
feature_usage_metrics_.StopUsage();
session_metrics_.reset();
widget_observation_.Reset();
}
scoped_refptr<network::SharedURLLoaderFactory>
PickerController::GetSharedURLLoaderFactory() {
return client_->GetSharedURLLoaderFactory();
}
} // namespace ash