// 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 "ash/system/bluetooth/bluetooth_device_list_item_view.h"
#include <cstdint>
#include <memory>
#include <utility>
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_id.h"
#include "ash/system/bluetooth/bluetooth_device_list_item_battery_view.h"
#include "ash/system/bluetooth/bluetooth_device_list_item_multiple_battery_view.h"
#include "ash/system/bluetooth/fake_bluetooth_detailed_view.h"
#include "ash/test/ash_test_base.h"
#include "base/containers/flat_map.h"
#include "base/memory/raw_ptr.h"
#include "base/strings/strcat.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/scoped_feature_list.h"
#include "chromeos/ash/services/bluetooth_config/public/mojom/cros_bluetooth_config.mojom.h"
#include "chromeos/constants/chromeos_features.h"
#include "chromeos/strings/grit/chromeos_strings.h"
#include "chromeos/ui/vector_icons/vector_icons.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/gfx/color_palette.h"
#include "ui/gfx/image/image.h"
#include "ui/gfx/image/image_skia.h"
#include "ui/gfx/image/image_unittest_util.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/widget.h"
namespace ash {
namespace {
using bluetooth_config::mojom::BatteryProperties;
using bluetooth_config::mojom::BatteryPropertiesPtr;
using bluetooth_config::mojom::BluetoothDeviceProperties;
using bluetooth_config::mojom::DeviceBatteryInfo;
using bluetooth_config::mojom::DeviceBatteryInfoPtr;
using bluetooth_config::mojom::DeviceConnectionState;
using bluetooth_config::mojom::DeviceType;
using bluetooth_config::mojom::PairedBluetoothDeviceProperties;
using bluetooth_config::mojom::PairedBluetoothDevicePropertiesPtr;
const char kDeviceId[] = "/device/id";
const std::string kDeviceNickname = "clicky keys";
const std::u16string kDevicePublicName = u"Mechanical Keyboard";
constexpr uint8_t kBatteryPercentage = 27;
constexpr uint8_t kLeftBudBatteryPercentage = 27;
constexpr uint8_t kCaseBatteryPercentage = 54;
constexpr uint8_t kRightBudBatteryPercentage = 81;
constexpr int kTestDeviceIndex = 3;
constexpr int kTestDeviceCount = 5;
PairedBluetoothDevicePropertiesPtr CreatePairedDeviceProperties() {
PairedBluetoothDevicePropertiesPtr paired_device_properties =
PairedBluetoothDeviceProperties::New();
paired_device_properties->device_properties =
BluetoothDeviceProperties::New();
paired_device_properties->device_properties->id = kDeviceId;
paired_device_properties->device_properties->public_name = kDevicePublicName;
return paired_device_properties;
}
DeviceBatteryInfoPtr CreateDefaultBatteryInfo(uint8_t battery_percentage) {
DeviceBatteryInfoPtr battery_info = DeviceBatteryInfo::New();
battery_info->default_properties = BatteryProperties::New();
battery_info->default_properties->battery_percentage = battery_percentage;
return battery_info;
}
DeviceBatteryInfoPtr CreateMultipleBatteryInfo(
std::optional<uint8_t> left_bud_battery_percentage,
std::optional<uint8_t> case_battery_percentage,
std::optional<uint8_t> right_bud_battery_percentage) {
EXPECT_TRUE(left_bud_battery_percentage || case_battery_percentage ||
right_bud_battery_percentage);
DeviceBatteryInfoPtr battery_info = DeviceBatteryInfo::New();
if (left_bud_battery_percentage) {
battery_info->left_bud_info = BatteryProperties::New();
battery_info->left_bud_info->battery_percentage =
left_bud_battery_percentage.value();
}
if (case_battery_percentage) {
battery_info->case_info = BatteryProperties::New();
battery_info->case_info->battery_percentage =
case_battery_percentage.value();
}
if (right_bud_battery_percentage) {
battery_info->right_bud_info = BatteryProperties::New();
battery_info->right_bud_info->battery_percentage =
right_bud_battery_percentage.value();
}
return battery_info;
}
} // namespace
class BluetoothDeviceListItemViewTest : public AshTestBase {
public:
void SetUp() override {
AshTestBase::SetUp();
fake_bluetooth_detailed_view_ =
std::make_unique<FakeBluetoothDetailedView>(/*delegate=*/nullptr);
std::unique_ptr<BluetoothDeviceListItemView> bluetooth_device_list_item =
std::make_unique<BluetoothDeviceListItemView>(
fake_bluetooth_detailed_view_.get());
bluetooth_device_list_item_ = bluetooth_device_list_item.get();
bluetooth_device_list_item_->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, CreatePairedDeviceProperties());
widget_ = CreateFramelessTestWidget();
widget_->SetFullscreen(true);
widget_->SetContentsView(bluetooth_device_list_item.release());
base::RunLoop().RunUntilIdle();
}
void TearDown() override {
widget_.reset();
AshTestBase::TearDown();
}
BluetoothDeviceListItemView* bluetooth_device_list_item() {
return bluetooth_device_list_item_;
}
const BluetoothDeviceListItemView* last_clicked_device_list_item() {
return fake_bluetooth_detailed_view_->last_clicked_device_list_item();
}
protected:
std::unique_ptr<views::Widget> widget_;
std::unique_ptr<FakeBluetoothDetailedView> fake_bluetooth_detailed_view_;
raw_ptr<BluetoothDeviceListItemView, DanglingUntriaged>
bluetooth_device_list_item_;
};
TEST_F(BluetoothDeviceListItemViewTest, HasCorrectLabel) {
PairedBluetoothDevicePropertiesPtr paired_device_properties =
CreatePairedDeviceProperties();
ASSERT_TRUE(bluetooth_device_list_item()->text_label());
EXPECT_EQ(kDevicePublicName,
bluetooth_device_list_item()->text_label()->GetText());
paired_device_properties->nickname = kDeviceNickname;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
EXPECT_EQ(base::ASCIIToUTF16(kDeviceNickname),
bluetooth_device_list_item()->text_label()->GetText());
}
TEST_F(BluetoothDeviceListItemViewTest, HasCorrectSubLabel) {
PairedBluetoothDevicePropertiesPtr paired_device_properties =
CreatePairedDeviceProperties();
EXPECT_FALSE(bluetooth_device_list_item()->sub_text_label());
paired_device_properties->device_properties->connection_state =
DeviceConnectionState::kConnecting;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
ASSERT_TRUE(bluetooth_device_list_item()->sub_text_label());
EXPECT_EQ(
l10n_util::GetStringUTF16(IDS_ASH_STATUS_TRAY_NETWORK_STATUS_CONNECTING),
bluetooth_device_list_item()->sub_text_label()->GetText());
paired_device_properties->device_properties->connection_state =
DeviceConnectionState::kConnected;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
// There should not be any content in the sub-row unless battery information
// is available.
EXPECT_EQ(0u, bluetooth_device_list_item()->sub_row()->children().size());
paired_device_properties->device_properties->battery_info =
CreateDefaultBatteryInfo(kBatteryPercentage);
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
EXPECT_EQ(1u, bluetooth_device_list_item()->sub_row()->children().size());
EXPECT_TRUE(views::IsViewClass<BluetoothDeviceListItemBatteryView>(
bluetooth_device_list_item()->sub_row()->children().at(0)));
paired_device_properties->device_properties->battery_info = nullptr;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
// The sub-row should be cleared if the battery information is no longer
// available.
EXPECT_EQ(0u, bluetooth_device_list_item()->sub_row()->children().size());
}
TEST_F(BluetoothDeviceListItemViewTest, HasExpectedA11yText) {
const base::flat_map<DeviceType, int> device_type_to_text_id{{
{DeviceType::kComputer, IDS_BLUETOOTH_A11Y_DEVICE_TYPE_COMPUTER},
{DeviceType::kGameController,
IDS_BLUETOOTH_A11Y_DEVICE_TYPE_GAME_CONTROLLER},
{DeviceType::kHeadset, IDS_BLUETOOTH_A11Y_DEVICE_TYPE_HEADSET},
{DeviceType::kKeyboard, IDS_BLUETOOTH_A11Y_DEVICE_TYPE_KEYBOARD},
{DeviceType::kKeyboardMouseCombo,
IDS_BLUETOOTH_A11Y_DEVICE_TYPE_KEYBOARD_MOUSE_COMBO},
{DeviceType::kMouse, IDS_BLUETOOTH_A11Y_DEVICE_TYPE_MOUSE},
{DeviceType::kPhone, IDS_BLUETOOTH_A11Y_DEVICE_TYPE_PHONE},
{DeviceType::kTablet, IDS_BLUETOOTH_A11Y_DEVICE_TYPE_TABLET},
{DeviceType::kUnknown, IDS_BLUETOOTH_A11Y_DEVICE_TYPE_UNKNOWN},
{DeviceType::kVideoCamera, IDS_BLUETOOTH_A11Y_DEVICE_TYPE_VIDEO_CAMERA},
}};
const base::flat_map<DeviceConnectionState, int> connection_state_to_text_id{{
{DeviceConnectionState::kConnected,
IDS_BLUETOOTH_A11Y_DEVICE_CONNECTION_STATE_CONNECTED},
{DeviceConnectionState::kConnecting,
IDS_BLUETOOTH_A11Y_DEVICE_CONNECTION_STATE_CONNECTING},
{DeviceConnectionState::kNotConnected,
IDS_BLUETOOTH_A11Y_DEVICE_CONNECTION_STATE_NOT_CONNECTED},
}};
// This vector contains all of the possible permutations of battery
// information a device might have (e.g. no information, single battery, some
// subset of multiple batteries).
std::vector<DeviceBatteryInfoPtr> battery_info_permutations;
battery_info_permutations.push_back(DeviceBatteryInfo::New());
battery_info_permutations.push_back(
CreateDefaultBatteryInfo(kBatteryPercentage));
battery_info_permutations.push_back(
CreateMultipleBatteryInfo(kLeftBudBatteryPercentage,
/*case_battery_percentage=*/std::nullopt,
/*right_bud_battery_percentage=*/std::nullopt));
battery_info_permutations.push_back(CreateMultipleBatteryInfo(
/*left_bud_battery_percentage=*/std::nullopt, kCaseBatteryPercentage,
/*right_bud_battery_percentage=*/std::nullopt));
battery_info_permutations.push_back(CreateMultipleBatteryInfo(
/*left_bud_battery_percentage=*/std::nullopt,
/*case_battery_percentage=*/std::nullopt, kRightBudBatteryPercentage));
battery_info_permutations.push_back(CreateMultipleBatteryInfo(
kLeftBudBatteryPercentage, kCaseBatteryPercentage,
/*right_bud_battery_percentage=*/std::nullopt));
battery_info_permutations.push_back(CreateMultipleBatteryInfo(
kLeftBudBatteryPercentage, /*case_battery_percentage=*/std::nullopt,
kRightBudBatteryPercentage));
battery_info_permutations.push_back(CreateMultipleBatteryInfo(
/*left_bud_battery_percentage=*/std::nullopt, kCaseBatteryPercentage,
kRightBudBatteryPercentage));
battery_info_permutations.push_back(CreateMultipleBatteryInfo(
kLeftBudBatteryPercentage, kCaseBatteryPercentage,
kRightBudBatteryPercentage));
// Include a case where both the default battery properties and the true
// wireless multiple batteries are available to make sure we prioritize them
// correctly.
DeviceBatteryInfoPtr mixed_battery_info = CreateMultipleBatteryInfo(
kLeftBudBatteryPercentage, kCaseBatteryPercentage,
kRightBudBatteryPercentage);
mixed_battery_info->default_properties = BatteryProperties::New();
mixed_battery_info->default_properties->battery_percentage =
kBatteryPercentage;
battery_info_permutations.push_back(std::move(mixed_battery_info));
PairedBluetoothDevicePropertiesPtr paired_device_properties =
CreatePairedDeviceProperties();
paired_device_properties->device_properties->public_name = kDevicePublicName;
for (const auto& device_type_it : device_type_to_text_id) {
paired_device_properties->device_properties->device_type =
device_type_it.first;
for (const auto& connection_state_it : connection_state_to_text_id) {
paired_device_properties->device_properties->connection_state =
connection_state_it.first;
for (const auto& battery_info_it : battery_info_permutations) {
paired_device_properties->device_properties->battery_info =
mojo::Clone(battery_info_it);
bluetooth_device_list_item()->UpdateDeviceProperties(
kTestDeviceIndex, kTestDeviceCount, paired_device_properties);
std::u16string expected_a11y_text = base::StrCat(
{l10n_util::GetStringFUTF16(
IDS_BLUETOOTH_A11Y_DEVICE_NAME,
base::NumberToString16(kTestDeviceIndex + 1),
base::NumberToString16(kTestDeviceCount), kDevicePublicName),
u" ", l10n_util::GetStringUTF16(connection_state_it.second), u" ",
l10n_util::GetStringUTF16(device_type_it.second)});
auto add_battery_text_if_exists =
[&expected_a11y_text](
const BatteryPropertiesPtr& battery_properties, int text_id) {
if (battery_properties) {
expected_a11y_text = base::StrCat(
{expected_a11y_text, u" ",
l10n_util::GetStringFUTF16(
text_id,
base::NumberToString16(
battery_properties->battery_percentage))});
}
};
if (!battery_info_it->left_bud_info && !battery_info_it->case_info &&
!battery_info_it->right_bud_info) {
add_battery_text_if_exists(battery_info_it->default_properties,
IDS_BLUETOOTH_A11Y_DEVICE_BATTERY_INFO);
} else {
add_battery_text_if_exists(
battery_info_it->left_bud_info,
IDS_BLUETOOTH_A11Y_DEVICE_NAMED_BATTERY_INFO_LEFT_BUD);
add_battery_text_if_exists(
battery_info_it->case_info,
IDS_BLUETOOTH_A11Y_DEVICE_NAMED_BATTERY_INFO_CASE);
add_battery_text_if_exists(
battery_info_it->right_bud_info,
IDS_BLUETOOTH_A11Y_DEVICE_NAMED_BATTERY_INFO_RIGHT_BUD);
}
EXPECT_EQ(expected_a11y_text, bluetooth_device_list_item()
->GetViewAccessibility()
.GetCachedName());
}
}
}
}
// We only have access to the ImageSkia instance generated using the vector icon
// for a device, and are thus unable to directly check the vector icon. Instead,
// we generate an image from the ImageSkia instance and an image from the vector
// icon and compare the results.
TEST_F(BluetoothDeviceListItemViewTest, HasCorrectIcon) {
const base::flat_map<DeviceType, const gfx::VectorIcon*>
device_type_to_icon_map = {{
{DeviceType::kComputer, &ash::kSystemMenuComputerIcon},
{DeviceType::kPhone, &ash::kSystemMenuPhoneIcon},
{DeviceType::kHeadset, &ash::kSystemMenuHeadsetIcon},
{DeviceType::kVideoCamera, &ash::kSystemMenuVideocamIcon},
{DeviceType::kGameController, &ash::kSystemMenuGamepadIcon},
{DeviceType::kKeyboard, &ash::kSystemMenuKeyboardIcon},
{DeviceType::kKeyboardMouseCombo, &ash::kSystemMenuKeyboardIcon},
{DeviceType::kMouse, &ash::kSystemMenuMouseIcon},
{DeviceType::kTablet, &ash::kSystemMenuTabletIcon},
{DeviceType::kUnknown, &ash::kSystemMenuBluetoothIcon},
}};
const SkColor icon_color =
bluetooth_device_list_item()->GetColorProvider()->GetColor(
kColorAshIconColorPrimary);
for (const auto& it : device_type_to_icon_map) {
PairedBluetoothDevicePropertiesPtr paired_device_properties =
CreatePairedDeviceProperties();
paired_device_properties->device_properties->device_type = it.first;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
const gfx::Image expected_image(
gfx::CreateVectorIcon(*it.second, icon_color));
ASSERT_TRUE(views::IsViewClass<views::ImageView>(
bluetooth_device_list_item()->left_view()));
const gfx::Image actual_image(static_cast<views::ImageView*>(
bluetooth_device_list_item()->left_view())
->GetImage());
EXPECT_TRUE(gfx::test::AreImagesEqual(expected_image, actual_image));
}
}
TEST_F(BluetoothDeviceListItemViewTest,
HasEnterpriseIconWhenDeviceIsBlockedByPolicy) {
PairedBluetoothDevicePropertiesPtr paired_device_properties =
CreatePairedDeviceProperties();
paired_device_properties->device_properties->is_blocked_by_policy = false;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
EXPECT_FALSE(bluetooth_device_list_item()->right_view());
paired_device_properties->device_properties->is_blocked_by_policy = true;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
ASSERT_TRUE(bluetooth_device_list_item()->right_view());
EXPECT_TRUE(bluetooth_device_list_item()->right_view()->GetVisible());
const gfx::Image expected_image(gfx::CreateVectorIcon(
chromeos::kEnterpriseIcon, /*dip_size=*/20,
widget_->GetColorProvider()->GetColor(cros_tokens::kCrosSysOnSurface)));
ASSERT_TRUE(views::IsViewClass<views::ImageView>(
bluetooth_device_list_item()->right_view()));
const gfx::Image actual_image(
static_cast<views::ImageView*>(bluetooth_device_list_item()->right_view())
->GetImage());
EXPECT_TRUE(gfx::test::AreImagesEqual(expected_image, actual_image));
paired_device_properties->device_properties->is_blocked_by_policy = false;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
ASSERT_FALSE(bluetooth_device_list_item()->right_view());
}
TEST_F(BluetoothDeviceListItemViewTest, NotifiesListenerWhenClicked) {
EXPECT_FALSE(last_clicked_device_list_item());
LeftClickOn(bluetooth_device_list_item());
EXPECT_EQ(last_clicked_device_list_item(), bluetooth_device_list_item());
}
TEST_F(BluetoothDeviceListItemViewTest, MultipleBatteries) {
PairedBluetoothDevicePropertiesPtr paired_device_properties =
CreatePairedDeviceProperties();
paired_device_properties->device_properties->connection_state =
DeviceConnectionState::kConnected;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
// There should not be any content in the sub-row unless battery information
// is available.
EXPECT_EQ(0u, bluetooth_device_list_item()->sub_row()->children().size());
paired_device_properties->device_properties->battery_info =
CreateMultipleBatteryInfo(kLeftBudBatteryPercentage,
kCaseBatteryPercentage,
kRightBudBatteryPercentage);
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
EXPECT_EQ(1u, bluetooth_device_list_item()->sub_row()->children().size());
EXPECT_TRUE(views::IsViewClass<BluetoothDeviceListItemMultipleBatteryView>(
bluetooth_device_list_item()->sub_row()->children().at(0)));
paired_device_properties->device_properties->battery_info = nullptr;
bluetooth_device_list_item()->UpdateDeviceProperties(
/*device_index=*/0, /*device_count=*/0, paired_device_properties);
// The sub-row should be cleared if the battery information is no longer
// available.
EXPECT_EQ(0u, bluetooth_device_list_item()->sub_row()->children().size());
}
} // namespace ash