// Copyright 2022 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/video_conference/video_conference_tray.h"
#include <memory>
#include <string>
#include "ash/public/cpp/shelf_types.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shelf/shelf.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/icon_button.h"
#include "ash/system/privacy/screen_security_controller.h"
#include "ash/system/system_notification_controller.h"
#include "ash/system/tray/tray_background_view.h"
#include "ash/system/tray/tray_bubble_view.h"
#include "ash/system/tray/tray_bubble_wrapper.h"
#include "ash/system/tray/tray_constants.h"
#include "ash/system/tray/tray_container.h"
#include "ash/system/tray/tray_utils.h"
#include "ash/system/video_conference/bubble/bubble_view.h"
#include "ash/system/video_conference/bubble/linux_apps_bubble_view.h"
#include "ash/system/video_conference/video_conference_tray_controller.h"
#include "base/functional/bind.h"
#include "base/memory/raw_ptr.h"
#include "base/metrics/histogram_functions.h"
#include "chromeos/crosapi/mojom/video_conference.mojom.h"
#include "components/session_manager/session_manager_types.h"
#include "third_party/abseil-cpp/absl/cleanup/cleanup.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/color/color_id.h"
#include "ui/events/event.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/image/canvas_image_source.h"
#include "ui/gfx/image/image_skia.h"
#include "ui/gfx/image/image_skia_operations.h"
#include "ui/gfx/scoped_canvas.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/controls/button/button_controller.h"
#include "ui/views/controls/highlight_path_generator.h"
#include "ui/views/view_utils.h"
namespace ash {
namespace {
constexpr float kTrayButtonsSpacing = 4;
constexpr float kPrivacyIndicatorRadius = 3;
// The offset value from the bottom right corner of the icon to the place where
// we actually want to draw the privacy indicator.
constexpr float kPrivacyIndicatorOffset = 2;
// Histogram names
constexpr char kToggleButtonHistogramName[] =
"Ash.VideoConferenceTray.ToggleBubbleButton.Click";
constexpr char kCameraMuteHistogramName[] =
"Ash.VideoConferenceTray.CameraMuteButton.Click";
constexpr char kMicrophoneMuteHistogramName[] =
"Ash.VideoConferenceTray.MicrophoneMuteButton.Click";
constexpr char kStopScreenShareHistogramName[] =
"Ash.VideoConferenceTray.StopScreenShareButton.Click";
// Check if there's a non-linux app(s) from the given `apps`.
bool HasNonLinuxMediaApps(const MediaApps& apps) {
for (auto& app : apps) {
if (app->app_type != crosapi::mojom::VideoConferenceAppType::kCrostiniVm &&
app->app_type != crosapi::mojom::VideoConferenceAppType::kPluginVm &&
app->app_type != crosapi::mojom::VideoConferenceAppType::kBorealis) {
return true;
}
}
return false;
}
// A customized toggle button for the VC tray's toggle bubble button.
class ToggleBubbleButton : public IconButton {
METADATA_HEADER(ToggleBubbleButton, IconButton)
public:
ToggleBubbleButton(VideoConferenceTray* tray, PressedCallback callback)
: IconButton(std::move(callback),
IconButton::Type::kMediumFloating,
&kVideoConferenceUpChevronIcon,
IDS_ASH_VIDEO_CONFERENCE_TOGGLE_BUBBLE_BUTTON_TOOLTIP,
/*is_togglable=*/true,
/*has_border=*/true),
tray_(tray) {
SetButtonController(std::make_unique<views::ButtonController>(
/*views::Button*=*/this,
std::make_unique<TrayBackgroundView::TrayButtonControllerDelegate>(
/*views::Button*=*/this,
TrayBackgroundViewCatalogName::kVideoConferenceTray)));
// Reduce the focus ring padding which is installed by default by
// `IconButton`. The default padding results in the focus ring being painted
// outside of the available bounds.
auto* focus_ring = views::FocusRing::Get(this);
focus_ring->SetPathGenerator(
std::make_unique<views::CircleHighlightPathGenerator>(
-gfx::Insets(focus_ring->GetHaloThickness() / 2)));
}
ToggleBubbleButton(const ToggleBubbleButton&) = delete;
ToggleBubbleButton& operator=(const ToggleBubbleButton&) = delete;
~ToggleBubbleButton() override = default;
// IconButton:
void PaintButtonContents(gfx::Canvas* canvas) override {
// Rotate the canvas to rotate the expand indicator according to toggle
// state and shelf alignment. Note that when shelf alignment changes,
// TrayBackgroundView::UpdateLayout() will be triggered and this button will
// be automatically repainted, so we don't need to manually handle it.
gfx::ScopedCanvas scoped(canvas);
canvas->Translate(gfx::Vector2d(size().width() / 2, size().height() / 2));
canvas->sk_canvas()->rotate(tray_->GetRotationValueForToggleBubbleButton());
gfx::ImageSkia image = GetImageToPaint();
canvas->DrawImageInt(image, -image.width() / 2, -image.height() / 2);
}
private:
// Parent view of this button. Owned by the views hierarchy.
const raw_ptr<VideoConferenceTray> tray_;
};
BEGIN_METADATA(ToggleBubbleButton)
END_METADATA
} // namespace
VideoConferenceTrayButton::VideoConferenceTrayButton(
PressedCallback callback,
const gfx::VectorIcon* icon,
const gfx::VectorIcon* toggled_icon,
const gfx::VectorIcon* capturing_icon,
const int accessible_name_id)
: IconButton(std::move(callback),
IconButton::Type::kMedium,
icon,
accessible_name_id,
/*is_togglable=*/true,
/*has_border=*/true),
accessible_name_id_(accessible_name_id),
icon_(icon),
capturing_icon_(capturing_icon) {
SetButtonController(std::make_unique<views::ButtonController>(
/*views::Button*=*/this,
std::make_unique<TrayBackgroundView::TrayButtonControllerDelegate>(
/*views::Button*=*/this,
TrayBackgroundViewCatalogName::kVideoConferenceTray)));
SetBackgroundToggledColor(cros_tokens::kCrosSysSystemNegativeContainer);
SetIconToggledColor(cros_tokens::kCrosSysSystemOnNegativeContainer);
SetBackgroundColor(cros_tokens::kCrosSysSystemOnBase1);
SetToggledVectorIcon(*toggled_icon);
GetViewAccessibility().SetRole(ax::mojom::Role::kToggleButton);
// Reduce the focus ring padding which is installed by default by
// `IconButton`. The default padding results in the focus ring being painted
// outside of the available bounds.
auto* focus_ring = views::FocusRing::Get(this);
focus_ring->SetPathGenerator(
std::make_unique<views::CircleHighlightPathGenerator>(
-gfx::Insets(focus_ring->GetHaloThickness() / 2)));
UpdateTooltip();
}
VideoConferenceTrayButton::~VideoConferenceTrayButton() = default;
void VideoConferenceTrayButton::SetIsCapturing(bool is_capturing) {
if (is_capturing_ == is_capturing) {
return;
}
is_capturing_ = is_capturing;
SetVectorIcon(is_capturing_ ? *capturing_icon_ : *icon_);
UpdateCapturingState();
}
void VideoConferenceTrayButton::UpdateCapturingState() {
// We should only show the privacy indicator when the button is not
// muted/untoggled.
const bool show_privacy_indicator = is_capturing_ && !toggled();
// Always call `UpdateTooltip()` because even if `show_privacy_indicator_`
// doesn't change, `is_capturing_` may have.
absl::Cleanup scoped_tooltip_update = [this] { UpdateTooltip(); };
if (show_privacy_indicator_ == show_privacy_indicator) {
return;
}
show_privacy_indicator_ = show_privacy_indicator;
SchedulePaint();
}
void VideoConferenceTrayButton::PaintButtonContents(gfx::Canvas* canvas) {
IconButton::PaintButtonContents(canvas);
if (!show_privacy_indicator_) {
return;
}
const gfx::RectF bounds(GetContentsBounds());
auto image = GetImageToPaint();
auto indicator_origin_x = (bounds.width() - image.width()) / 2 +
image.width() - kPrivacyIndicatorRadius;
auto indicator_origin_y = (bounds.height() - image.height()) / 2 +
image.height() - kPrivacyIndicatorRadius;
// Draw the green dot privacy indicator.
cc::PaintFlags flags;
flags.setStyle(cc::PaintFlags::kFill_Style);
flags.setAntiAlias(true);
flags.setColor(
GetColorProvider()->GetColor(ui::kColorAshPrivacyIndicatorsBackground));
canvas->DrawCircle(gfx::PointF(indicator_origin_x - kPrivacyIndicatorOffset,
indicator_origin_y),
kPrivacyIndicatorRadius, flags);
}
void VideoConferenceTrayButton::UpdateTooltip() {
int capture_state_id = VIDEO_CONFERENCE_TOGGLE_BUTTON_STATE_OFF;
if (show_privacy_indicator_) {
capture_state_id = VIDEO_CONFERENCE_TOGGLE_BUTTON_STATE_ON_AND_IN_USE;
} else if (!toggled()) {
capture_state_id = VIDEO_CONFERENCE_TOGGLE_BUTTON_STATE_ON;
}
int base_string_id = VIDEO_CONFERENCE_TOGGLE_BUTTON_TOOLTIP;
if (toggle_is_one_way_) {
base_string_id = VIDEO_CONFERENCE_ONE_WAY_TOGGLE_BUTTON_TOOLTIP;
}
SetTooltipText(l10n_util::GetStringFUTF16(
base_string_id, l10n_util::GetStringUTF16(accessible_name_id_),
l10n_util::GetStringUTF16(capture_state_id)));
}
BEGIN_METADATA(VideoConferenceTrayButton)
END_METADATA
VideoConferenceTray::VideoConferenceTray(Shelf* shelf)
: TrayBackgroundView(shelf,
TrayBackgroundViewCatalogName::kVideoConferenceTray) {
SetCallback(base::BindRepeating(&VideoConferenceTray::ToggleBubble,
weak_ptr_factory_.GetWeakPtr()));
tray_container()->SetSpacingBetweenChildren(kTrayButtonsSpacing);
audio_icon_ = tray_container()->AddChildView(std::make_unique<
VideoConferenceTrayButton>(
base::BindRepeating(&VideoConferenceTray::OnAudioButtonClicked,
weak_ptr_factory_.GetWeakPtr()),
/*icon=*/&kPrivacyIndicatorsMicrophoneIcon,
/*toggled_icon=*/&kVideoConferenceMicrophoneMutedIcon,
/*capturing_icon=*/&kVideoConferenceMicrophoneCapturingIcon,
/*accessible_name_id=*/VIDEO_CONFERENCE_TOGGLE_BUTTON_TYPE_MICROPHONE));
audio_icon_->SetVisible(false);
camera_icon_ = tray_container()->AddChildView(
std::make_unique<VideoConferenceTrayButton>(
base::BindRepeating(&VideoConferenceTray::OnCameraButtonClicked,
weak_ptr_factory_.GetWeakPtr()),
&kPrivacyIndicatorsCameraIcon, &kVideoConferenceCameraMutedIcon,
&kVideoConferenceCameraCapturingIcon,
VIDEO_CONFERENCE_TOGGLE_BUTTON_TYPE_CAMERA));
camera_icon_->SetVisible(false);
const bool allow_stop_screen_share =
base::FeatureList::IsEnabled(features::kVcStopAllScreenShare);
if (allow_stop_screen_share) {
screen_share_icon_ = tray_container()->AddChildView(
std::make_unique<VideoConferenceTrayButton>(
base::BindRepeating(
&VideoConferenceTray::OnScreenShareButtonClicked,
weak_ptr_factory_.GetWeakPtr()),
&kVideoConferenceScreenShareIcon, &kVideoConferenceScreenShareIcon,
&kVideoConferenceScreenShareIcon,
VIDEO_CONFERENCE_TOGGLE_BUTTON_TYPE_SCREEN_SHARE));
// Toggling screen share stops screen share, and removes the item.
screen_share_icon_->set_toggle_is_one_way();
screen_share_icon_->SetVisible(false);
}
toggle_bubble_button_ =
tray_container()->AddChildView(std::make_unique<ToggleBubbleButton>(
this, base::BindRepeating(&VideoConferenceTray::ToggleBubble,
weak_ptr_factory_.GetWeakPtr())));
VideoConferenceTrayController::Get()->AddObserver(this);
VideoConferenceTrayController::Get()->GetEffectsManager().AddObserver(this);
Shell::Get()->session_controller()->AddObserver(this);
// Update visibility of the tray and all child icons and indicators. If this
// lives on a secondary display, it's possible a media session already exists
// so force update all state.
UpdateTrayAndIconsState();
DCHECK_EQ(allow_stop_screen_share ? 4u : 3u,
tray_container()->children().size())
<< "Icons must be updated here in case a media session begins prior to "
"connecting a secondary display.";
}
VideoConferenceTray::~VideoConferenceTray() {
Shell::Get()->session_controller()->RemoveObserver(this);
VideoConferenceTrayController::Get()->GetEffectsManager().RemoveObserver(
this);
VideoConferenceTrayController::Get()->RemoveObserver(this);
}
void VideoConferenceTray::CloseBubbleInternal() {
bubble_open_ = false;
toggle_bubble_button_->SetToggled(false);
bubble_.reset();
shelf()->UpdateAutoHideState();
}
TrayBubbleView* VideoConferenceTray::GetBubbleView() {
return bubble_ ? bubble_->bubble_view() : nullptr;
}
views::Widget* VideoConferenceTray::GetBubbleWidget() const {
return bubble_ ? bubble_->bubble_widget() : nullptr;
}
std::u16string VideoConferenceTray::GetAccessibleNameForTray() {
return l10n_util::GetStringUTF16(IDS_ASH_VIDEO_CONFERENCE_ACCESSIBLE_NAME);
}
std::u16string VideoConferenceTray::GetAccessibleNameForBubble() {
return GetAccessibleNameForTray();
}
void VideoConferenceTray::HideBubbleWithView(
const TrayBubbleView* bubble_view) {
if (bubble_ && bubble_->bubble_view() == bubble_view) {
CloseBubble();
}
}
void VideoConferenceTray::HideBubble(const TrayBubbleView* bubble_view) {
if (bubble_ && bubble_->bubble_view() == bubble_view) {
CloseBubble();
}
}
void VideoConferenceTray::ClickedOutsideBubble(const ui::LocatedEvent& event) {
CloseBubble();
}
void VideoConferenceTray::HandleLocaleChange() {
// TODO(b/253646076): Finish this function.
}
void VideoConferenceTray::AnchorUpdated() {
if (bubble_) {
bubble_->bubble_view()->UpdateBubble();
}
}
void VideoConferenceTray::OnAnimationEnded() {
TrayBackgroundView::OnAnimationEnded();
if (!visible_preferred()) {
return;
}
auto* controller = VideoConferenceTrayController::Get();
controller->MaybeRunNudgeRequest();
controller->MaybeShowSpeakOnMuteOptInNudge();
}
bool VideoConferenceTray::ShouldEnterPushedState(const ui::Event& event) {
// Never enter pushed state to avoid displaying unnecessary ink drop in
// `ButtonController::OnMousePressed()`.
return false;
}
void VideoConferenceTray::OnHasMediaAppStateChange() {
SetVisiblePreferred(VideoConferenceTrayController::Get()->ShouldShowTray());
}
void VideoConferenceTray::OnCameraPermissionStateChange() {
camera_icon_->SetVisible(
VideoConferenceTrayController::Get()->GetHasCameraPermissions());
}
void VideoConferenceTray::OnMicrophonePermissionStateChange() {
audio_icon_->SetVisible(
VideoConferenceTrayController::Get()->GetHasMicrophonePermissions());
}
void VideoConferenceTray::OnScreenSharingStateChange(bool is_capturing_screen) {
if (screen_share_icon_) {
screen_share_icon_->SetVisible(is_capturing_screen);
screen_share_icon_->SetIsCapturing(
/*is_capturing=*/is_capturing_screen);
}
}
void VideoConferenceTray::OnDlcDownloadStateChanged(
bool add_warning,
const std::u16string& feature_tile_title) {
auto* bubble_view = GetBubbleView();
if (!bubble_view) {
return;
}
views::AsViewClass<video_conference::BubbleView>(bubble_view)
->OnDLCDownloadStateInError(add_warning, feature_tile_title);
}
void VideoConferenceTray::OnCameraCapturingStateChange(bool is_capturing) {
camera_icon_->SetIsCapturing(is_capturing);
}
void VideoConferenceTray::OnMicrophoneCapturingStateChange(bool is_capturing) {
audio_icon_->SetIsCapturing(is_capturing);
}
void VideoConferenceTray::OnEffectSupportStateChanged(VcEffectId effect_id,
bool is_supported) {
// If the bubble is open, we close it so that when it is re-opened, the
// bubble is updated with the correct effect support state.
if (GetBubbleWidget()) {
CloseBubble();
}
}
SkScalar VideoConferenceTray::GetRotationValueForToggleBubbleButton() {
// If `bubble_` is not null, it means that the bubble is opened.
switch (shelf()->alignment()) {
case ShelfAlignment::kBottom:
case ShelfAlignment::kBottomLocked:
return bubble_ ? 180 : 0;
case ShelfAlignment::kLeft:
return bubble_ ? 270 : 90;
case ShelfAlignment::kRight:
return bubble_ ? 90 : 270;
}
}
void VideoConferenceTray::UpdateTrayAndIconsState() {
auto* controller = VideoConferenceTrayController::Get();
SetVisiblePreferred(controller->ShouldShowTray());
camera_icon_->SetVisible(controller->GetHasCameraPermissions());
camera_icon_->SetIsCapturing(controller->IsCapturingCamera());
camera_icon_->SetToggled(/*toggled=*/controller->GetCameraMuted());
audio_icon_->SetVisible(controller->GetHasMicrophonePermissions());
audio_icon_->SetIsCapturing(controller->IsCapturingMicrophone());
audio_icon_->SetToggled(/*toggled=*/controller->GetMicrophoneMuted());
if (screen_share_icon_) {
bool is_capturing_screen = controller->IsCapturingScreen();
screen_share_icon_->SetVisible(is_capturing_screen);
screen_share_icon_->SetIsCapturing(is_capturing_screen);
}
}
IconButton* VideoConferenceTray::GetToggleBubbleButtonForTest() {
return toggle_bubble_button_;
}
void VideoConferenceTray::OnSessionStateChanged(
session_manager::SessionState state) {
SetVisiblePreferred(VideoConferenceTrayController::Get()->ShouldShowTray());
}
void VideoConferenceTray::ToggleBubble(const ui::Event& event) {
bubble_open_ = !bubble_open_;
base::UmaHistogramBoolean(kToggleButtonHistogramName, bubble_open_);
if (!bubble_open_) {
CloseBubble();
return;
}
VideoConferenceTrayController::Get()->CloseAllVcNudges();
// If we are already in the process of getting the media apps, we don't need
// to get it again.
if (!getting_media_apps_) {
getting_media_apps_ = true;
// Get all the currently running media apps from the controller and use it
// to construct the bubble.
VideoConferenceTrayController::Get()->GetMediaApps(
base::BindOnce(&VideoConferenceTray::ConstructBubbleWithMediaApps,
weak_ptr_factory_.GetWeakPtr()));
}
}
void VideoConferenceTray::OnCameraButtonClicked(const ui::Event& event) {
const bool muted = !camera_icon_->toggled();
VideoConferenceTrayController::Get()->SetCameraMuted(muted);
base::UmaHistogramBoolean(kCameraMuteHistogramName, !muted);
}
void VideoConferenceTray::OnAudioButtonClicked(const ui::Event& event) {
const bool muted = !audio_icon_->toggled();
VideoConferenceTrayController::Get()->SetMicrophoneMuted(muted);
base::UmaHistogramBoolean(kMicrophoneMuteHistogramName, !muted);
}
void VideoConferenceTray::OnScreenShareButtonClicked(const ui::Event& event) {
if (features::IsStopAllScreenShareEnabled()) {
VideoConferenceTrayController::Get()->StopAllScreenShare();
base::UmaHistogramBoolean(kStopScreenShareHistogramName, true);
}
}
void VideoConferenceTray::ConstructBubbleWithMediaApps(MediaApps apps) {
getting_media_apps_ = false;
// Should not show anything if bubble is intended to be close.
if (!bubble_open_) {
return;
}
std::unique_ptr<TrayBubbleView> bubble_view;
auto init_params = CreateInitParamsForTrayBubble(/*tray=*/this);
init_params.preferred_width = kWideTrayMenuWidth;
init_params.corner_radius = kUpdatedBubbleCornerRadius;
// If all of the apps are Linux apps, we will just use `LinuxAppsBubbleView`
// specifically for this situation.
if (!HasNonLinuxMediaApps(apps)) {
init_params.corner_radius = 18;
bubble_view = std::make_unique<video_conference::LinuxAppsBubbleView>(
init_params, apps);
bubble_view->SetPreferredWidth(bubble_view->GetPreferredSize().width());
} else {
bubble_view = std::make_unique<video_conference::BubbleView>(
/*init_params=*/init_params, /*media_apps=*/apps,
/*controller=*/VideoConferenceTrayController::Get());
}
bubble_ = std::make_unique<TrayBubbleWrapper>(this);
bubble_->ShowBubble(std::move(bubble_view));
toggle_bubble_button_->SetToggled(true);
}
void VideoConferenceTray::SetBackgroundReplaceUiVisible(bool visible) {
auto* bubble_view = GetBubbleView();
if (bubble_view) {
views::AsViewClass<video_conference::BubbleView>(bubble_view)
->SetBackgroundReplaceUiVisible(visible);
}
}
BEGIN_METADATA(VideoConferenceTray)
END_METADATA
} // namespace ash