// 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 <memory>
#include <string>
#include <vector>
#include "ash/app_list/app_list_controller_impl.h"
#include "ash/clipboard/clipboard_history.h"
#include "ash/clipboard/clipboard_history_controller_impl.h"
#include "ash/clipboard/clipboard_history_item.h"
#include "ash/clipboard/clipboard_history_util.h"
#include "ash/public/cpp/clipboard_image_model_factory.h"
#include "ash/public/cpp/session/session_types.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/style/color_util.h"
#include "ash/test/ash_test_base.h"
#include "ash/test/ash_test_util.h"
#include "ash/test/view_drawn_waiter.h"
#include "base/location.h"
#include "base/memory/raw_ptr.h"
#include "base/notreached.h"
#include "base/run_loop.h"
#include "base/scoped_observation.h"
#include "base/task/sequenced_task_runner.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/test_future.h"
#include "base/unguessable_token.h"
#include "build/build_config.h"
#include "chromeos/constants/chromeos_features.h"
#include "chromeos/crosapi/mojom/clipboard_history.mojom.h"
#include "chromeos/ui/clipboard_history/clipboard_history_util.h"
#include "chromeos/ui/vector_icons/vector_icons.h"
#include "components/vector_icons/vector_icons.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/base/clipboard/clipboard_buffer.h"
#include "ui/base/clipboard/clipboard_data.h"
#include "ui/base/clipboard/clipboard_format_type.h"
#include "ui/base/clipboard/custom_data_helper.h"
#include "ui/base/clipboard/scoped_clipboard_writer.h"
#include "ui/base/models/image_model.h"
#include "ui/events/event_constants.h"
#include "ui/events/keycodes/keyboard_codes_posix.h"
#include "ui/events/test/event_generator.h"
#include "ui/gfx/image/image_unittest_util.h"
#include "ui/gfx/skia_util.h"
#include "ui/gfx/vector_icon_types.h"
#include "ui/strings/grit/ui_strings.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/controls/button/label_button.h"
#include "ui/views/controls/menu/menu_item_view.h"
#include "ui/views/controls/menu/submenu_view.h"
#include "ui/views/controls/textfield/textfield.h"
#include "ui/views/controls/textfield/textfield_test_api.h"
namespace ash {
using crosapi::mojom::ClipboardHistoryControllerShowSource;
namespace {
// Matchers --------------------------------------------------------------------
MATCHER_P2(MenuItemsMatch, labels, icons, "") {
if (arg.size() != labels.size() || arg.size() != icons.size()) {
return false;
}
for (size_t index = 0; index < labels.size(); ++index) {
if (arg[index].label != labels[index] ||
!gfx::test::AreImagesEqual(arg[index].icon, icons[index])) {
return false;
}
}
return true;
}
// Helper classes --------------------------------------------------------------
// A mocked clipboard history controller observer.
class MockObserver : public ClipboardHistoryController::Observer {
public:
MockObserver() {
scoped_observation_.Observe(ClipboardHistoryController::Get());
}
// ClipboardHistoryController::Observer:
MOCK_METHOD(void, OnClipboardHistoryItemsUpdated, (), (override));
private:
base::ScopedObservation<ClipboardHistoryController,
ClipboardHistoryController::Observer>
scoped_observation_{this};
};
class MockClipboardImageModelFactory : public ClipboardImageModelFactory {
public:
MockClipboardImageModelFactory() = default;
MockClipboardImageModelFactory(const MockClipboardImageModelFactory&) =
delete;
MockClipboardImageModelFactory& operator=(
const MockClipboardImageModelFactory&) = delete;
~MockClipboardImageModelFactory() override = default;
// ClipboardImageModelFactory:
void Render(const base::UnguessableToken& clipboard_history_item_id,
const std::string& markup,
const gfx::Size& bounding_box_size,
ImageModelCallback callback) override {
// Return a dummy image as the render result.
std::move(callback).Run(
ui::ImageModel::FromImageSkia(gfx::ImageSkia::CreateFrom1xBitmap(
gfx::test::CreateBitmap(/*width=*/2, /*height=*/2))));
}
void CancelRequest(const base::UnguessableToken& request_id) override {}
void Activate() override {}
void Deactivate() override {}
void RenderCurrentPendingRequests() override {}
void OnShutdown() override {}
};
// Describes a menu item consisting of a label and an icon.
struct MenuItemDescriptor {
MenuItemDescriptor(const std::u16string& input_label,
const gfx::Image& input_icon)
: label(input_label), icon(input_icon) {}
const std::u16string label;
const gfx::Image icon;
};
// Helper functions ------------------------------------------------------------
void FlushMessageLoop() {
base::RunLoop run_loop;
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, run_loop.QuitClosure());
run_loop.Run();
}
void ExpectHistoryItemImageMatchesBitmap(const ClipboardHistoryItem& item,
const SkBitmap& expected_bitmap) {
EXPECT_EQ(item.display_format(),
crosapi::mojom::ClipboardHistoryDisplayFormat::kPng);
const auto& image = item.display_image();
ASSERT_TRUE(image.has_value());
ASSERT_TRUE(image.value().IsImage());
ASSERT_FALSE(image.value().IsEmpty());
EXPECT_TRUE(gfx::BitmapsAreEqual(*image.value().GetImage().ToSkBitmap(),
expected_bitmap));
}
std::vector<ClipboardHistoryControllerShowSource>
GetClipboardHistoryShowSources() {
std::vector<ClipboardHistoryControllerShowSource> sources;
for (int i =
static_cast<int>(ClipboardHistoryControllerShowSource::kMinValue);
i <= static_cast<int>(ClipboardHistoryControllerShowSource::kMaxValue);
++i) {
sources.push_back(static_cast<ClipboardHistoryControllerShowSource>(i));
}
return sources;
}
} // namespace
class ClipboardHistoryControllerTest : public AshTestBase {
public:
ClipboardHistoryControllerTest() = default;
ClipboardHistoryControllerTest(const ClipboardHistoryControllerTest&) =
delete;
ClipboardHistoryControllerTest& operator=(
const ClipboardHistoryControllerTest&) = delete;
~ClipboardHistoryControllerTest() override = default;
// AshTestBase:
void SetUp() override {
AshTestBase::SetUp();
mock_image_factory_ = std::make_unique<MockClipboardImageModelFactory>();
GetClipboardHistoryController()->set_confirmed_operation_callback_for_test(
operation_confirmed_future_.GetRepeatingCallback());
}
ClipboardHistoryControllerImpl* GetClipboardHistoryController() {
return Shell::Get()->clipboard_history_controller();
}
void ShowMenu() { PressAndReleaseKey(ui::VKEY_V, ui::EF_COMMAND_DOWN); }
void WaitForOperationConfirmed() {
EXPECT_TRUE(operation_confirmed_future_.Take());
}
void WriteImageToClipboardAndConfirm(const SkBitmap& bitmap) {
{
ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
scw.WriteImage(bitmap);
}
WaitForOperationConfirmed();
}
void WriteTextToClipboardAndConfirm(const std::u16string& str) {
{
ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
scw.WriteText(str);
}
WaitForOperationConfirmed();
}
std::vector<ClipboardHistoryItem> GetHistoryValues() {
base::test::TestFuture<std::vector<ClipboardHistoryItem>> future;
GetClipboardHistoryController()->GetHistoryValues(future.GetCallback());
return future.Take();
}
void TestEnteringLockScreen() {
// Querying clipboard history should return nothing if the screen is locked
// while the request is in progress.
GetClipboardHistoryController()->BlockGetHistoryValuesForTest();
base::test::TestFuture<std::vector<ClipboardHistoryItem>> future;
GetClipboardHistoryController()->GetHistoryValues(future.GetCallback());
EXPECT_FALSE(future.IsReady());
auto* session_controller = Shell::Get()->session_controller();
session_controller->LockScreen();
GetSessionControllerClient()->FlushForTest(); // `LockScreen()` is async.
EXPECT_TRUE(session_controller->IsScreenLocked());
GetClipboardHistoryController()->ResumeGetHistoryValuesForTest();
auto locked_during_query_result = future.Take();
EXPECT_TRUE(locked_during_query_result.empty());
// Querying clipboard history should return nothing if the screen is locked
// before the request is made.
auto locked_before_query_result = GetHistoryValues();
EXPECT_TRUE(locked_before_query_result.empty());
}
protected:
base::test::TestFuture<bool> operation_confirmed_future_;
private:
std::unique_ptr<MockClipboardImageModelFactory> mock_image_factory_;
};
// Tests that Search + V with no history fails to show a menu.
TEST_F(ClipboardHistoryControllerTest, NoHistoryNoMenu) {
base::HistogramTester histogram_tester;
ShowMenu();
EXPECT_FALSE(GetClipboardHistoryController()->IsMenuShowing());
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.NumberOfItemsShown", 0);
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.UserJourneyTime", 0);
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.DisplayFormatShown", 0);
}
// Tests that Search + V shows a menu when there is something to show.
TEST_F(ClipboardHistoryControllerTest, ShowMenu) {
base::HistogramTester histogram_tester;
// Copy something to enable the clipboard history menu.
WriteTextToClipboardAndConfirm(u"test");
ShowMenu();
EXPECT_TRUE(GetClipboardHistoryController()->IsMenuShowing());
// No user journey time should be recorded as the menu is still showing.
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.UserJourneyTime", 0);
histogram_tester.ExpectBucketCount(
"Ash.ClipboardHistory.ContextMenu.NumberOfItemsShown", 1, 1);
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.DisplayFormatShown", 1);
// Hide the menu.
PressAndReleaseKey(ui::VKEY_ESCAPE);
EXPECT_FALSE(GetClipboardHistoryController()->IsMenuShowing());
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.UserJourneyTime", 1);
histogram_tester.ExpectBucketCount(
"Ash.ClipboardHistory.ContextMenu.NumberOfItemsShown", 1, 1);
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.DisplayFormatShown", 1);
// Reshow the menu.
ShowMenu();
EXPECT_TRUE(GetClipboardHistoryController()->IsMenuShowing());
// No new UserJourneyTime histogram should be recorded as the menu is
// still showing.
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.UserJourneyTime", 1);
histogram_tester.ExpectBucketCount(
"Ash.ClipboardHistory.ContextMenu.NumberOfItemsShown", 1, 2);
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.DisplayFormatShown", 2);
// Hide the menu.
PressAndReleaseKey(ui::VKEY_ESCAPE);
EXPECT_FALSE(GetClipboardHistoryController()->IsMenuShowing());
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.UserJourneyTime", 2);
histogram_tester.ExpectBucketCount(
"Ash.ClipboardHistory.ContextMenu.NumberOfItemsShown", 1, 2);
histogram_tester.ExpectTotalCount(
"Ash.ClipboardHistory.ContextMenu.DisplayFormatShown", 2);
}
// Verifies that the clipboard history is disabled in some user modes, which
// means that the clipboard history should not be recorded and meanwhile the
// menu view should not show (https://crbug.com/1100739).
TEST_F(ClipboardHistoryControllerTest, VerifyAvailabilityInUserModes) {
// Write one item into the clipboard history.
WriteTextToClipboardAndConfirm(u"text");
constexpr struct {
user_manager::UserType user_type;
bool is_enabled;
} kTestCases[] = {{user_manager::UserType::kRegular, true},
{user_manager::UserType::kGuest, true},
{user_manager::UserType::kPublicAccount, false},
{user_manager::UserType::kKioskApp, false},
{user_manager::UserType::kChild, true},
{user_manager::UserType::kWebKioskApp, false}};
UserSession session;
session.session_id = 1u;
session.user_info.account_id = AccountId::FromUserEmail("[email protected]");
session.user_info.display_name = "User 1";
session.user_info.display_email = "[email protected]";
for (const auto& test_case : kTestCases) {
// Switch to the target user mode.
session.user_info.type = test_case.user_type;
Shell::Get()->session_controller()->UpdateUserSession(session);
// Write a new item into the clipboard buffer.
{
ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
scw.WriteText(u"test");
}
if (test_case.is_enabled) {
WaitForOperationConfirmed();
} else {
FlushMessageLoop();
// Note: This check might not catch a scenario where a mode expected to be
// disabled actually allows writes to go through, because the operation
// might not have finished yet in that case. The history verification
// below mitigates the chance that such a bug would not be caught.
EXPECT_FALSE(operation_confirmed_future_.IsReady());
}
const std::list<ClipboardHistoryItem>& items =
Shell::Get()->clipboard_history_controller()->history()->GetItems();
if (test_case.is_enabled) {
// Verify that the new item should be included in the clipboard history
// and the menu should be able to show.
EXPECT_EQ(items.size(), 2u);
ShowMenu();
EXPECT_TRUE(
Shell::Get()->clipboard_history_controller()->IsMenuShowing());
PressAndReleaseKey(ui::VKEY_ESCAPE);
EXPECT_FALSE(
Shell::Get()->clipboard_history_controller()->IsMenuShowing());
// Restore the clipboard history by removing the new item.
ClipboardHistory* clipboard_history = const_cast<ClipboardHistory*>(
Shell::Get()->clipboard_history_controller()->history());
clipboard_history->RemoveItemForId(items.begin()->id());
} else {
// Verify that the new item should not be written into the clipboard
// history. The menu cannot show although the clipboard history is
// non-empty.
EXPECT_EQ(items.size(), 1u);
ShowMenu();
EXPECT_FALSE(
Shell::Get()->clipboard_history_controller()->IsMenuShowing());
}
}
}
// Verifies that the clipboard history menu is disabled when the screen for
// user adding shows.
TEST_F(ClipboardHistoryControllerTest, DisableInUserAddingScreen) {
WriteTextToClipboardAndConfirm(u"text");
// Emulate that the user adding screen displays.
Shell::Get()->session_controller()->ShowMultiProfileLogin();
// Try to show the clipboard history menu; verify that the menu does not show.
ShowMenu();
EXPECT_FALSE(Shell::Get()->clipboard_history_controller()->IsMenuShowing());
}
// Tests that pressing Search while holding the V key does not show the
// Launcher.
TEST_F(ClipboardHistoryControllerTest, VThenSearchDoesNotShowLauncher) {
GetEventGenerator()->PressKey(ui::VKEY_V, ui::EF_NONE);
GetEventGenerator()->PressKey(ui::VKEY_COMMAND, ui::EF_NONE);
// Release V, which could trigger a key released accelerator.
GetEventGenerator()->ReleaseKey(ui::VKEY_V, ui::EF_COMMAND_DOWN);
EXPECT_FALSE(Shell::Get()->app_list_controller()->IsVisible(
/*display_id=*/std::nullopt));
// Release Search, which could trigger the Launcher.
GetEventGenerator()->ReleaseKey(ui::VKEY_COMMAND, ui::EF_NONE);
EXPECT_FALSE(Shell::Get()->app_list_controller()->IsVisible(
/*display_id=*/std::nullopt));
}
// Tests that clearing a single item from the clipboard clears clipboard
// history.
TEST_F(ClipboardHistoryControllerTest, ClearClipboardClearsHistory) {
// Write a single item to the clipboard.
WriteTextToClipboardAndConfirm(u"test");
// Clear the clipboard.
ui::Clipboard::GetForCurrentThread()->Clear(ui::ClipboardBuffer::kCopyPaste);
FlushMessageLoop();
// History should also be cleared.
const std::list<ClipboardHistoryItem>& items =
Shell::Get()->clipboard_history_controller()->history()->GetItems();
EXPECT_TRUE(items.empty());
ShowMenu();
EXPECT_FALSE(GetClipboardHistoryController()->IsMenuShowing());
}
// Tests that clearing the clipboard closes the clipboard history menu.
TEST_F(ClipboardHistoryControllerTest,
ClearingClipboardClosesClipboardHistory) {
// Write a single item to the clipboard.
WriteTextToClipboardAndConfirm(u"test");
ASSERT_TRUE(Shell::Get()->cursor_manager()->IsCursorVisible());
ShowMenu();
EXPECT_TRUE(GetClipboardHistoryController()->IsMenuShowing());
// The cursor is visible after showing the clipboard history menu through
// the accelerator.
EXPECT_TRUE(Shell::Get()->cursor_manager()->IsCursorVisible());
ui::Clipboard::GetForCurrentThread()->Clear(ui::ClipboardBuffer::kCopyPaste);
FlushMessageLoop();
EXPECT_FALSE(GetClipboardHistoryController()->IsMenuShowing());
}
TEST_F(ClipboardHistoryControllerTest, EncodeImage) {
// Write a bitmap to the clipboard.
SkBitmap test_bitmap = gfx::test::CreateBitmap(3, 2);
WriteImageToClipboardAndConfirm(test_bitmap);
// The bitmap should be encoded to a PNG. Manually pry into the contents of
// the result to confirm that the newly-encoded PNG is included.
auto result = GetHistoryValues();
ASSERT_EQ(result.size(), 1u);
ExpectHistoryItemImageMatchesBitmap(result[0], test_bitmap);
}
TEST_F(ClipboardHistoryControllerTest, EncodeMultipleImages) {
// Write a bunch of bitmaps to the clipboard.
const std::vector<SkBitmap> test_bitmaps{
gfx::test::CreateBitmap(2, 1),
gfx::test::CreateBitmap(3, 2),
gfx::test::CreateBitmap(4, 3),
};
for (const auto& test_bitmap : test_bitmaps) {
WriteImageToClipboardAndConfirm(test_bitmap);
}
auto result = GetHistoryValues();
auto num_results = result.size();
ASSERT_EQ(num_results, test_bitmaps.size());
// The bitmaps should be encoded to PNGs. Manually pry into the contents of
// the result to confirm that the newly-encoded PNGs are included. History
// values should be sorted by recency.
for (uint i = 0; i < num_results; ++i) {
ExpectHistoryItemImageMatchesBitmap(result[i],
test_bitmaps[num_results - 1 - i]);
}
}
TEST_F(ClipboardHistoryControllerTest, WriteBitmapWhileEncodingImage) {
// Write a bitmap to the clipboard.
const std::vector<SkBitmap> test_bitmaps{
gfx::test::CreateBitmap(3, 2),
gfx::test::CreateBitmap(4, 3),
};
WriteImageToClipboardAndConfirm(test_bitmaps[0]);
// Write another bitmap to the clipboard while encoding the first bitmap.
GetClipboardHistoryController()
->set_new_bitmap_to_write_while_encoding_for_test(test_bitmaps[1]);
// Make sure the second bitmap is written to the clipboard before history
// values are returned.
GetClipboardHistoryController()->BlockGetHistoryValuesForTest();
base::test::TestFuture<std::vector<ClipboardHistoryItem>> future;
GetClipboardHistoryController()->GetHistoryValues(future.GetCallback());
EXPECT_FALSE(future.IsReady());
WaitForOperationConfirmed();
GetClipboardHistoryController()->ResumeGetHistoryValuesForTest();
auto result = future.Take();
auto num_results = result.size();
ASSERT_EQ(num_results, test_bitmaps.size());
// Both bitmaps should be encoded to PNGs. Manually pry into the contents of
// the result to confirm that the newly-encoded PNGs are included. History
// values should be sorted by recency.
for (uint i = 0; i < num_results; ++i) {
ExpectHistoryItemImageMatchesBitmap(result[i],
test_bitmaps[num_results - 1 - i]);
}
}
TEST_F(ClipboardHistoryControllerTest, LockedScreenText) {
// Write text to the clipboard and verify that it can be retrieved.
WriteTextToClipboardAndConfirm(u"test");
auto history_list = GetHistoryValues();
ASSERT_EQ(history_list.size(), 1u);
ASSERT_EQ(history_list[0].display_text(), u"test");
TestEnteringLockScreen();
}
TEST_F(ClipboardHistoryControllerTest, LockedScreenImage) {
// Write a bitmap to the clipboard and verify that it can be returned.
SkBitmap test_bitmap = gfx::test::CreateBitmap(3, 2);
WriteImageToClipboardAndConfirm(test_bitmap);
auto result = GetHistoryValues();
ASSERT_EQ(result.size(), 1u);
ExpectHistoryItemImageMatchesBitmap(result[0], test_bitmap);
TestEnteringLockScreen();
}
using ClipboardHistoryControllerObserverTest = ClipboardHistoryControllerTest;
// Verifies that clipboard history controller notifies observers of clipboard
// history item updates as expected when adding or removing items.
TEST_F(ClipboardHistoryControllerObserverTest, AddAndRemoveItem) {
MockObserver mock_observer;
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated).Times(3);
WriteTextToClipboardAndConfirm(u"A");
WriteTextToClipboardAndConfirm(u"B");
ClipboardHistoryController::Get()->DeleteClipboardItemById(
GetHistoryValues()[0].id().ToString());
GetClipboardHistoryController()->FireItemUpdateNotificationTimerForTest();
}
// Verifies that when the clipboard history is cleared, the controller notifies
// observers of clipboard history item updates as expected when removing items.
TEST_F(ClipboardHistoryControllerObserverTest, ClearHistory) {
MockObserver mock_observer;
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated).Times(3);
WriteTextToClipboardAndConfirm(u"A");
WriteTextToClipboardAndConfirm(u"B");
// Clear the system clipboard, which causes clipboard history to clear.
ui::Clipboard::GetForCurrentThread()->Clear(ui::ClipboardBuffer::kCopyPaste);
GetClipboardHistoryController()->FireItemUpdateNotificationTimerForTest();
}
// Verifies that clipboard history controller notifies observers once when
// clipboard history item addition causes overflow.
TEST_F(ClipboardHistoryControllerObserverTest, Overflow) {
// Add five items to reach the clipboard history size limit.
MockObserver mock_observer;
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated).Times(5);
WriteTextToClipboardAndConfirm(u"A");
WriteTextToClipboardAndConfirm(u"B");
WriteTextToClipboardAndConfirm(u"C");
WriteTextToClipboardAndConfirm(u"D");
WriteTextToClipboardAndConfirm(u"E");
EXPECT_EQ(GetHistoryValues().size(),
static_cast<size_t>(clipboard_history_util::kMaxClipboardItems));
testing::Mock::VerifyAndClearExpectations(&mock_observer);
// Notify `mock_observer` once when item addition causes overflow.
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated);
WriteTextToClipboardAndConfirm(u"F");
EXPECT_EQ(GetHistoryValues().size(),
static_cast<size_t>(clipboard_history_util::kMaxClipboardItems));
}
TEST_F(ClipboardHistoryControllerObserverTest,
ChangeSessionStateWithEmptyHistory) {
// Clipboard history is empty. Therefore, the clipboard history controller
// should not notify observers when the session state changes.
MockObserver mock_observer;
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated).Times(0);
TestSessionControllerClient* test_session_client =
GetSessionControllerClient();
test_session_client->SetSessionState(session_manager::SessionState::LOCKED);
test_session_client->FlushForTest();
test_session_client->SetSessionState(session_manager::SessionState::ACTIVE);
test_session_client->FlushForTest();
testing::Mock::VerifyAndClearExpectations(&mock_observer);
// Notify `mock_observer` when a new clipboard history item arrives.
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated);
WriteTextToClipboardAndConfirm(u"A");
}
// TODO(crbug.com/40274291): Re-enable this test
#if BUILDFLAG(IS_CHROMEOS)
#define MAYBE_ChangeSessionStateWithNonEmptyHistory \
DISABLED_ChangeSessionStateWithNonEmptyHistory
#else
#define MAYBE_ChangeSessionStateWithNonEmptyHistory \
ChangeSessionStateWithNonEmptyHistory
#endif
TEST_F(ClipboardHistoryControllerObserverTest,
MAYBE_ChangeSessionStateWithNonEmptyHistory) {
// Notify `mock_observer` once when adding a clipboard history item.
MockObserver mock_observer;
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated);
WriteTextToClipboardAndConfirm(u"A");
testing::Mock::VerifyAndClearExpectations(&mock_observer);
// Notify `mock_observer` once when clipboard history becomes disabled.
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated);
TestSessionControllerClient* test_session_client =
GetSessionControllerClient();
test_session_client->SetSessionState(session_manager::SessionState::LOCKED);
test_session_client->FlushForTest();
testing::Mock::VerifyAndClearExpectations(&mock_observer);
// Do not notify `mock_observer` when switching to another session state where
// clipboard history is still disabled.
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated).Times(0);
test_session_client->SetSessionState(
session_manager::SessionState::LOGGED_IN_NOT_ACTIVE);
test_session_client->FlushForTest();
testing::Mock::VerifyAndClearExpectations(&mock_observer);
// Notify `mock_observer` once when clipboard history becomes enabled.
EXPECT_CALL(mock_observer, OnClipboardHistoryItemsUpdated);
test_session_client->SetSessionState(session_manager::SessionState::ACTIVE);
test_session_client->FlushForTest();
}
class ClipboardHistoryControllerWithTextfieldTest
: public ClipboardHistoryControllerTest {
public:
// ClipboardHistoryControllerTest:
void SetUp() override {
ClipboardHistoryControllerTest::SetUp();
textfield_widget_ = CreateFramelessTestWidget();
textfield_widget_->SetBounds(gfx::Rect(0, 0, 100, 100));
textfield_ = textfield_widget_->SetContentsView(
std::make_unique<views::Textfield>());
textfield_->GetViewAccessibility().SetName(u"Textfield");
textfield_->SetFocusBehavior(views::View::FocusBehavior::ALWAYS);
// Focus the textfield and confirm initial state.
textfield_->RequestFocus();
ASSERT_TRUE(textfield_->HasFocus());
ASSERT_TRUE(textfield_->GetText().empty());
}
void ShowTextfieldContextMenu(const views::View& textfield) {
GetEventGenerator()->MoveMouseTo(
textfield.GetBoundsInScreen().CenterPoint());
GetEventGenerator()->ClickRightButton();
}
std::unique_ptr<views::Widget> textfield_widget_;
raw_ptr<views::Textfield> textfield_;
};
TEST_F(ClipboardHistoryControllerWithTextfieldTest, PasteClipboardItemById) {
// Write four items to the clipboard.
WriteTextToClipboardAndConfirm(u"A");
WriteTextToClipboardAndConfirm(u"B");
WriteTextToClipboardAndConfirm(u"C");
WriteTextToClipboardAndConfirm(u"D");
WriteTextToClipboardAndConfirm(u"E");
const std::vector<ClipboardHistoryItem> items = GetHistoryValues();
ASSERT_EQ(items.size(), 5u);
// Set a zero duration to make test code simpler.
GetClipboardHistoryController()->set_buffer_restoration_delay_for_test(
base::TimeDelta());
struct {
size_t paste_data_index;
crosapi::mojom::ClipboardHistoryControllerShowSource paste_source;
int event_flags;
ClipboardHistoryControllerImpl::ClipboardHistoryPasteType paste_type;
} test_cases[] = {
{/*paste_data_index=*/0,
/*paste_source=*/
crosapi::mojom::ClipboardHistoryControllerShowSource::kVirtualKeyboard,
/*event_flags=*/ui::EF_NONE,
/*paste_type=*/
ClipboardHistoryControllerImpl::ClipboardHistoryPasteType::
kRichTextVirtualKeyboard},
{/*paste_data_index=*/1,
/*paste_source=*/
crosapi::mojom::ClipboardHistoryControllerShowSource::
kTextfieldContextMenu,
/*event_flags=*/ui::EF_MOUSE_BUTTON,
/*paste_type=*/
ClipboardHistoryControllerImpl::ClipboardHistoryPasteType::
kRichTextMouse},
{/*paste_data_index=*/2,
/*paste_source=*/
crosapi::mojom::ClipboardHistoryControllerShowSource::
kRenderViewContextMenu,
/*event_flags=*/ui::EF_SHIFT_DOWN | ui::EF_FROM_TOUCH,
/*paste_type=*/
ClipboardHistoryControllerImpl::ClipboardHistoryPasteType::
kPlainTextTouch},
{/*paste_data_index=*/3,
/*paste_source=*/
crosapi::mojom::ClipboardHistoryControllerShowSource::
kRenderViewContextSubmenu,
/*event_flags=*/ui::EF_MOUSE_BUTTON,
/*paste_type=*/
ClipboardHistoryControllerImpl::ClipboardHistoryPasteType::
kRichTextMouse},
{/*paste_data_index=*/4,
/*paste_source=*/
crosapi::mojom::ClipboardHistoryControllerShowSource::
kTextfieldContextSubmenu,
/*event_flags=*/ui::EF_MOUSE_BUTTON,
/*paste_type=*/
ClipboardHistoryControllerImpl::ClipboardHistoryPasteType::
kRichTextMouse}};
for (auto& [paste_data_index, paste_source, event_flags, paste_type] :
test_cases) {
base::HistogramTester histogram_tester;
textfield_->SetText(std::u16string());
ClipboardHistoryController::Get()->PasteClipboardItemById(
items[paste_data_index].id().ToString(), event_flags, paste_source);
base::RunLoop().RunUntilIdle();
// Verify the contents of `textfield_` and histograms.
EXPECT_EQ(textfield_->GetText(), items[paste_data_index].display_text());
histogram_tester.ExpectBucketCount("Ash.ClipboardHistory.PasteType",
paste_type,
/*expected_count=*/1);
histogram_tester.ExpectBucketCount("Ash.ClipboardHistory.PasteSource",
paste_source,
/*expected_count=*/1);
histogram_tester.ExpectBucketCount(
"Ash.ClipboardHistory.ContextMenu.MenuOptionSelected",
paste_data_index + 1, /*expected_count=*/1);
}
}
// Base class for tests that exercise clipboard history controller behavior with
// every possible way of showing the clipboard history menu. The
// `kClipboardHistoryLongpress` feature is enabled iff the menu is shown via
// Ctrl+V longpress.
class ClipboardHistoryControllerShowSourceTest
: public ClipboardHistoryControllerTest,
public testing::WithParamInterface<ClipboardHistoryControllerShowSource> {
public:
ClipboardHistoryControllerShowSourceTest() {
scoped_feature_list_.InitWithFeatureState(
features::kClipboardHistoryLongpress,
GetSource() ==
ClipboardHistoryControllerShowSource::kControlVLongpress);
}
ClipboardHistoryControllerShowSource GetSource() const { return GetParam(); }
private:
base::test::ScopedFeatureList scoped_feature_list_;
};
INSTANTIATE_TEST_SUITE_P(All,
ClipboardHistoryControllerShowSourceTest,
testing::ValuesIn(GetClipboardHistoryShowSources()));
// Tests that `ShowMenu()` returns whether the menu was shown successfully.
TEST_P(ClipboardHistoryControllerShowSourceTest, ShowMenuReturnsSuccess) {
base::HistogramTester histogram_tester;
// Try to show the menu without populating the clipboard. The menu should not
// show.
EXPECT_FALSE(GetClipboardHistoryController()->ShowMenu(
gfx::Rect(), ui::MenuSourceType::MENU_SOURCE_NONE, GetSource()));
EXPECT_FALSE(GetClipboardHistoryController()->IsMenuShowing());
histogram_tester.ExpectTotalCount("Ash.ClipboardHistory.ContextMenu.ShowMenu",
/*expected_count=*/0);
// Copy something to enable the clipboard history menu.
WriteTextToClipboardAndConfirm(u"test");
// Try to show the menu with the screen locked. The menu should not show.
auto* session_controller = Shell::Get()->session_controller();
session_controller->LockScreen();
GetSessionControllerClient()->FlushForTest();
EXPECT_TRUE(session_controller->IsScreenLocked());
EXPECT_FALSE(GetClipboardHistoryController()->ShowMenu(
gfx::Rect(), ui::MenuSourceType::MENU_SOURCE_NONE, GetSource()));
EXPECT_FALSE(GetClipboardHistoryController()->IsMenuShowing());
histogram_tester.ExpectTotalCount("Ash.ClipboardHistory.ContextMenu.ShowMenu",
/*expected_count=*/0);
session_controller->HideLockScreen();
GetSessionControllerClient()->FlushForTest();
EXPECT_FALSE(session_controller->IsScreenLocked());
// Show the menu.
EXPECT_TRUE(GetClipboardHistoryController()->ShowMenu(
gfx::Rect(), ui::MenuSourceType::MENU_SOURCE_NONE, GetSource()));
EXPECT_TRUE(GetClipboardHistoryController()->IsMenuShowing());
histogram_tester.ExpectUniqueSample(
"Ash.ClipboardHistory.ContextMenu.ShowMenu", GetSource(),
/*expected_bucket_count=*/1);
// Try to show the menu again without closing the active menu. The menu should
// still be showing, but this attempt should fail.
EXPECT_FALSE(GetClipboardHistoryController()->ShowMenu(
gfx::Rect(), ui::MenuSourceType::MENU_SOURCE_NONE, GetSource()));
EXPECT_TRUE(GetClipboardHistoryController()->IsMenuShowing());
histogram_tester.ExpectUniqueSample(
"Ash.ClipboardHistory.ContextMenu.ShowMenu", GetSource(),
/*expected_bucket_count=*/1);
}
// Tests that the client-provided `OnMenuClosingCallback` runs before the menu
// closes.
TEST_P(ClipboardHistoryControllerShowSourceTest, OnMenuClosingCallback) {
base::test::TestFuture<bool> on_menu_closing_future;
base::HistogramTester histogram_tester;
// Copy something to enable the clipboard history menu.
WriteTextToClipboardAndConfirm(u"test");
gfx::Rect test_window_rect(100, 100, 100, 100);
std::unique_ptr<aura::Window> window(CreateTestWindow(test_window_rect));
// Show the menu with an `OnMenuClosingCallback`.
GetClipboardHistoryController()->ShowMenu(
test_window_rect, ui::MenuSourceType::MENU_SOURCE_NONE, GetSource(),
on_menu_closing_future.GetRepeatingCallback());
EXPECT_TRUE(GetClipboardHistoryController()->IsMenuShowing());
EXPECT_FALSE(on_menu_closing_future.IsReady());
// Hide the menu. The callback should indicate that nothing will be pasted.
PressAndReleaseKey(ui::VKEY_ESCAPE);
EXPECT_FALSE(GetClipboardHistoryController()->IsMenuShowing());
EXPECT_FALSE(on_menu_closing_future.Take());
FlushMessageLoop();
histogram_tester.ExpectTotalCount("Ash.ClipboardHistory.PasteSource",
/*expected_count=*/0);
// Show the menu again.
GetClipboardHistoryController()->ShowMenu(
test_window_rect, ui::MenuSourceType::MENU_SOURCE_NONE, GetSource(),
on_menu_closing_future.GetCallback());
EXPECT_TRUE(GetClipboardHistoryController()->IsMenuShowing());
EXPECT_FALSE(on_menu_closing_future.IsReady());
// Toggle the menu closed. The callback should indicate a pending paste.
PressAndReleaseKey(ui::VKEY_V, ui::EF_COMMAND_DOWN);
EXPECT_FALSE(GetClipboardHistoryController()->IsMenuShowing());
EXPECT_TRUE(on_menu_closing_future.Take());
FlushMessageLoop();
histogram_tester.ExpectUniqueSample("Ash.ClipboardHistory.PasteSource",
GetSource(),
/*expected_bucket_count=*/1);
}
// TODO(http://b/278109818): Move clipboard history refresh tests into a
// separate test file.
// A parameterized test base to verify the clipboard history refresh feature on
// every display format.
// Each test param is such a tuple:
// 1. The first value is a boolean indicating whether the clipboard history
// refresh feature is enabled;
// 2. The second value is the display format under test.
class ClipboardHistoryRefreshDisplayFormatTest
: public ClipboardHistoryControllerWithTextfieldTest,
public testing::WithParamInterface<
std::tuple</*enable_clipboard_history_refresh=*/bool,
/*display_format_under_test=*/crosapi::mojom::
ClipboardHistoryDisplayFormat>> {
public:
ClipboardHistoryRefreshDisplayFormatTest() {
scoped_feature_list_.InitWithFeatureStates(
{{chromeos::features::kClipboardHistoryRefresh,
IsClipboardHistoryRefreshEnabled()},
{chromeos::features::kJelly, IsClipboardHistoryRefreshEnabled()}});
}
bool IsClipboardHistoryRefreshEnabled() const {
return std::get<0>(GetParam());
}
// Writes clipboard data. Returns the the descriptors of the expected
// clipboard history submenu items. The returned arrays follow the reverse
// clipboard data writing order. Returns an empty array if the clipboard
// history refresh feature is disabled.
std::vector<MenuItemDescriptor> WriteClipboardDataBasedOnParam() {
const ui::ColorProvider* color_provider = GetPrimaryWindowColorProvider();
CHECK(color_provider);
auto get_icon = [color_provider](const gfx::VectorIcon& icon) {
return gfx::Image(ui::ImageModel::FromVectorIcon(icon,
ui::kColorSysSecondary,
/*icon_size=*/20)
.Rasterize(color_provider));
};
const bool refresh_feature_enabled =
chromeos::features::IsClipboardHistoryRefreshEnabled();
const std::u16string show_clipboard_menu_label =
l10n_util::GetStringUTF16(IDS_APP_SHOW_CLIPBOARD_HISTORY);
switch (GetDisplayFormat()) {
case crosapi::mojom::ClipboardHistoryDisplayFormat::kText:
WriteTextToClipboardAndConfirm(u"A");
WriteTextToClipboardAndConfirm(u"B");
WriteTextToClipboardAndConfirm(u"https://google.com/");
if (refresh_feature_enabled) {
return {{u"https://google.com/", get_icon(vector_icons::kLinkIcon)},
{u"B", get_icon(chromeos::kTextIcon)},
{u"A", get_icon(chromeos::kTextIcon)},
{show_clipboard_menu_label, gfx::Image()}};
}
break;
case crosapi::mojom::ClipboardHistoryDisplayFormat::kPng:
WriteImageToClipboardAndConfirm(
gfx::test::CreateBitmap(/*width=*/3, /*height=*/3));
WriteImageToClipboardAndConfirm(
gfx::test::CreateBitmap(/*width=*/2, /*height=*/2));
if (refresh_feature_enabled) {
return {{u"Image", get_icon(chromeos::kFiletypeImageIcon)},
{u"Image", get_icon(chromeos::kFiletypeImageIcon)},
{show_clipboard_menu_label, gfx::Image()}};
}
break;
case crosapi::mojom::ClipboardHistoryDisplayFormat::kHtml:
WriteHtmlAndConfirm("<table>A</table>");
WriteHtmlAndConfirm("<table>B></table>");
if (refresh_feature_enabled) {
return {{u"HTML Content", get_icon(vector_icons::kCodeIcon)},
{u"HTML Content", get_icon(vector_icons::kCodeIcon)},
{show_clipboard_menu_label, gfx::Image()}};
}
break;
case crosapi::mojom::ClipboardHistoryDisplayFormat::kFile:
// Use dummy file paths. The corresponding files do not have to exist
// because only file extensions are required to calculate icons.
// Copy a single file.
WriteFilePathsAndConfirm({u"dummy_file.webm"});
// Copy multiple files at the same time.
WriteFilePathsAndConfirm({u"dummy_child1.jpg", u"dummy_child2.png"});
if (refresh_feature_enabled) {
return {{u"2 files", get_icon(vector_icons::kContentCopyIcon)},
{u"dummy_file.webm", get_icon(chromeos::kFiletypeVideoIcon)},
{show_clipboard_menu_label, gfx::Image()}};
}
break;
case crosapi::mojom::ClipboardHistoryDisplayFormat::kUnknown:
NOTREACHED();
}
return {};
}
void WriteFilePathsAndConfirm(const std::vector<std::u16string>& file_paths) {
{
base::Pickle pickle;
ui::WriteCustomDataToPickle(
std::unordered_map<std::u16string, std::u16string>(
{{u"fs/sources", base::JoinString(file_paths, u"\n")}}),
&pickle);
ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
scw.WritePickledData(pickle,
ui::ClipboardFormatType::DataTransferCustomType());
}
WaitForOperationConfirmed();
}
void WriteHtmlAndConfirm(const std::string& html) {
{
ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
scw.WriteHTML(base::UTF8ToUTF16(html), /*source_url=*/"");
}
WaitForOperationConfirmed();
}
crosapi::mojom::ClipboardHistoryDisplayFormat GetDisplayFormat() const {
return std::get<1>(GetParam());
}
const ui::ColorProvider* GetPrimaryWindowColorProvider() {
auto* color_provider_source = ColorUtil::GetColorProviderSourceForWindow(
Shell::GetPrimaryRootWindow());
auto* color_provider = color_provider_source->GetColorProvider();
return color_provider;
}
private:
base::test::ScopedFeatureList scoped_feature_list_;
};
INSTANTIATE_TEST_SUITE_P(
All,
ClipboardHistoryRefreshDisplayFormatTest,
testing::Combine(
/*enable_clipboard_history_refresh=*/testing::Bool(),
/*display_format_under_test=*/testing::Values(
crosapi::mojom::ClipboardHistoryDisplayFormat::kText,
crosapi::mojom::ClipboardHistoryDisplayFormat::kPng,
crosapi::mojom::ClipboardHistoryDisplayFormat::kHtml,
crosapi::mojom::ClipboardHistoryDisplayFormat::kFile)));
// Verifies that the clipboard history submenu model of the text services
// context menu in Ash works as expected.
TEST_P(ClipboardHistoryRefreshDisplayFormatTest, TextServicesSubMenu) {
// Show the textfield context menu before writing any clipboard data.
ShowTextfieldContextMenu(*textfield_);
views::TextfieldTestApi api(textfield_);
ui::MenuModel* const root_model = api.context_menu_contents();
ASSERT_TRUE(root_model);
const bool is_refresh_enabled =
chromeos::features::IsClipboardHistoryRefreshEnabled();
const int clipboard_history_command_id = is_refresh_enabled
? IDS_APP_PASTE_FROM_CLIPBOARD
: IDS_APP_SHOW_CLIPBOARD_HISTORY;
// Search the parent model and the command index of
// `clipboard_history_command_id`.
ui::MenuModel* target_command_parent_model = root_model;
size_t target_command_index = 0u;
ui::MenuModel::GetModelAndIndexForCommandId(clipboard_history_command_id,
&target_command_parent_model,
&target_command_index);
EXPECT_EQ(target_command_parent_model, root_model);
EXPECT_GT(target_command_index, 0u);
// The clipboard history menu item should be disabled when there is no
// clipboard history.
EXPECT_FALSE(target_command_parent_model->IsEnabledAt(target_command_index));
// Write clipboard data.
const std::vector<MenuItemDescriptor> expected_submenu_items =
WriteClipboardDataBasedOnParam();
ASSERT_EQ(expected_submenu_items.empty(), !is_refresh_enabled);
// Close the textfield menu then reshow.
GetEventGenerator()->PressAndReleaseKey(ui::KeyboardCode::VKEY_ESCAPE);
ShowTextfieldContextMenu(*textfield_);
// Check `submenu_model` if any. Reuse `target_command_index` since the
// context menu model structure should not change.
target_command_parent_model = api.context_menu_contents();
ui::MenuModel* const submenu_model =
target_command_parent_model->GetSubmenuModelAt(target_command_index);
// The clipboard history menu item should be enabled when there is clipboard
// history.
EXPECT_TRUE(target_command_parent_model->IsEnabledAt(target_command_index));
if (is_refresh_enabled) {
// If the refresh feature is enabled, the clipboard history menu item is a
// submenu item.
EXPECT_EQ(target_command_parent_model->GetTypeAt(target_command_index),
ui::MenuModel::ItemType::TYPE_SUBMENU);
ASSERT_TRUE(submenu_model);
// Get the labels and icons from `submenu_model`. If a menu item does not
// have an icon, add an empty image to `actual_icons`.
const ui::ColorProvider* color_provider = GetPrimaryWindowColorProvider();
std::vector<std::u16string> actual_labels;
std::vector<gfx::Image> actual_icons;
for (size_t index = 0; index < submenu_model->GetItemCount(); ++index) {
actual_labels.emplace_back(submenu_model->GetLabelAt(index));
const ui::ImageModel image_model = submenu_model->GetIconAt(index);
actual_icons.push_back(
image_model.IsEmpty()
? gfx::Image()
: gfx::Image(image_model.Rasterize(color_provider)));
}
// Check the actual labels and icons.
EXPECT_THAT(expected_submenu_items,
MenuItemsMatch(actual_labels, actual_icons));
} else {
// If the refresh feature is disabled, the clipboard history menu item is a
// command item.
EXPECT_FALSE(submenu_model);
EXPECT_EQ(target_command_parent_model->GetTypeAt(target_command_index),
ui::MenuModel::ItemType::TYPE_COMMAND);
}
}
TEST_P(ClipboardHistoryRefreshDisplayFormatTest,
ShowStandaloneMenuFromSubmenu) {
WriteClipboardDataBasedOnParam();
ShowTextfieldContextMenu(*textfield_);
// If the clipboard history refresh feature is enabled, show the submenu.
if (chromeos::features::IsClipboardHistoryRefreshEnabled()) {
// Expect the menu item that hosts the clipboard history submenu exists.
const views::MenuItemView* const submenu_item = WaitForMenuItemWithLabel(
l10n_util::GetStringUTF16(IDS_APP_PASTE_FROM_CLIPBOARD));
ASSERT_TRUE(submenu_item);
// Mouse hover on `submenu_item`. Wait until the submenu shows.
base::HistogramTester submenu_histogram_tester;
GetEventGenerator()->MoveMouseTo(
submenu_item->GetBoundsInScreen().CenterPoint());
views::View* const submenu_view = submenu_item->GetSubmenu();
ViewDrawnWaiter().Wait(submenu_view);
// Verify that the submenu source is recorded as expected when
// `submenu_view` shows.
submenu_histogram_tester.ExpectUniqueSample(
"Ash.ClipboardHistory.ContextMenu.ShowMenu",
crosapi::mojom::ClipboardHistoryControllerShowSource::
kTextfieldContextSubmenu,
1);
}
// Expect that the menu option to launch the clipboard history menu exists.
const views::View* const menu_item = WaitForMenuItemWithLabel(
l10n_util::GetStringUTF16(IDS_APP_SHOW_CLIPBOARD_HISTORY));
ASSERT_TRUE(menu_item);
// Left mouse click at `menu_item`. The standalone clipboard history menu
// should show.
base::HistogramTester histogram_tester;
GetEventGenerator()->MoveMouseTo(
menu_item->GetBoundsInScreen().CenterPoint());
GetEventGenerator()->ClickLeftButton();
EXPECT_TRUE(Shell::Get()->clipboard_history_controller()->IsMenuShowing());
// The source of the standalone clipboard history menu should be recorded.
histogram_tester.ExpectUniqueSample(
"Ash.ClipboardHistory.ContextMenu.ShowMenu",
crosapi::mojom::ClipboardHistoryControllerShowSource::
kTextfieldContextMenu,
1);
}
} // namespace ash