// 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/privacy/privacy_indicators_tray_item_view.h"
#include <memory>
#include <string>
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/root_window_controller.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/system/privacy/privacy_indicators_controller.h"
#include "ash/system/tray/tray_item_view.h"
#include "base/check.h"
#include "base/check_op.h"
#include "base/metrics/histogram_functions.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "chromeos/constants/chromeos_features.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/image_model.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/color/color_id.h"
#include "ui/color/color_provider.h"
#include "ui/compositor/animation_throughput_reporter.h"
#include "ui/compositor/compositor.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/layer_type.h"
#include "ui/display/screen.h"
#include "ui/gfx/animation/linear_animation.h"
#include "ui/gfx/animation/tween.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/size.h"
#include "ui/views/animation/animation_builder.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/widget/widget.h"
namespace ash {
namespace {
constexpr auto kPrivacyIndicatorsViewPadding = gfx::Insets::VH(4, 8);
const int kPrivacyIndicatorsViewSpacing = 2;
const int kPrivacyIndicatorsIconSize = 16;
const int kPrivacyIndicatorsViewExpandedShorterSideSize = 24;
const int kPrivacyIndicatorsViewExpandedLongerSideSize = 50;
const int kPrivacyIndicatorsViewExpandedWithScreenShareSize = 68;
const int kPrivacyIndicatorsViewSize = 8;
constexpr auto kRepeatedShowTimerInterval = base::Milliseconds(100);
constexpr auto kDwellInExpandDuration = base::Milliseconds(3000);
constexpr auto kShorterSizeShrinkAnimationDelay =
kDwellInExpandDuration + base::Milliseconds(133);
constexpr auto kSizeChangeAnimationDuration = base::Milliseconds(333);
constexpr auto kExpandAnimationDuration = base::Milliseconds(400);
constexpr auto kIconFadeInDelayDuration = base::Milliseconds(83);
constexpr auto kCameraIconFadeInDuration = base::Milliseconds(233);
constexpr auto kMicAndScreenshareFadeInDuration = base::Milliseconds(116);
void StartAnimation(gfx::LinearAnimation* animation) {
if (!animation)
return;
// Stop any ongoing animation.
animation->End();
animation->Start();
}
void StartRecordAnimationSmoothness(
views::Widget* widget,
std::optional<ui::ThroughputTracker>& tracker) {
// `widget` may not exist in tests.
if (!widget)
return;
tracker.emplace(widget->GetCompositor()->RequestNewThroughputTracker());
tracker->Start(ash::metrics_util::ForSmoothnessV3(
base::BindRepeating([](int smoothness) {
base::UmaHistogramPercentage(
"Ash.PrivacyIndicators.AnimationSmoothness", smoothness);
})));
}
void StartReportLayerAnimationSmoothness(
const std::string& animation_histogram_name,
int smoothness) {
// Only record animation smoothness if `animation_histogram_name` is given.
if (animation_histogram_name.empty())
return;
base::UmaHistogramPercentage(animation_histogram_name, smoothness);
}
void FadeInView(views::View* view,
base::TimeDelta duration,
const std::string& animation_histogram_name) {
// The view must have a layer to perform animation.
DCHECK(view->layer());
// Stop any ongoing animation.
if (view->layer()->GetAnimator()->is_animating())
view->layer()->GetAnimator()->StopAnimating();
ui::AnimationThroughputReporter reporter(
view->layer()->GetAnimator(),
metrics_util::ForSmoothnessV3(base::BindRepeating(
&StartReportLayerAnimationSmoothness, animation_histogram_name)));
views::AnimationBuilder()
.SetPreemptionStrategy(
ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET)
.Once()
.SetDuration(base::TimeDelta())
.SetOpacity(view, 0.0f)
.At(kIconFadeInDelayDuration)
.SetDuration(duration)
.SetOpacity(view, 1.0f);
}
// Returns true if the widget is in the primary display.
bool IsInPrimaryDisplay(views::Widget* widget) {
if (!widget) {
return false;
}
auto* screen = display::Screen::GetScreen();
return screen->GetDisplayNearestWindow(widget->GetNativeWindow()) ==
screen->GetPrimaryDisplay();
}
} // namespace
PrivacyIndicatorsTrayItemView::PrivacyIndicatorsTrayItemView(Shelf* shelf)
: TrayItemView(shelf),
expand_animation_(std::make_unique<gfx::LinearAnimation>(
kExpandAnimationDuration,
gfx::LinearAnimation::kDefaultFrameRate,
this)),
longer_side_shrink_animation_(std::make_unique<gfx::LinearAnimation>(
kSizeChangeAnimationDuration,
gfx::LinearAnimation::kDefaultFrameRate,
this)),
shorter_side_shrink_animation_(std::make_unique<gfx::LinearAnimation>(
kSizeChangeAnimationDuration,
gfx::LinearAnimation::kDefaultFrameRate,
this)),
repeated_shows_timer_(
FROM_HERE,
kRepeatedShowTimerInterval,
this,
&PrivacyIndicatorsTrayItemView::RecordRepeatedShows) {
SetVisible(false);
auto container_view = std::make_unique<views::View>();
layout_manager_ =
container_view->SetLayoutManager(std::make_unique<views::BoxLayout>(
shelf->PrimaryAxisValue(views::BoxLayout::Orientation::kHorizontal,
views::BoxLayout::Orientation::kVertical),
kPrivacyIndicatorsViewPadding, kPrivacyIndicatorsViewSpacing));
layout_manager_->set_main_axis_alignment(
views::BoxLayout::MainAxisAlignment::kCenter);
// Set up a solid color layer to paint the background color, then add a layer
// to each child so that they are visible and can perform layer animation.
SetPaintToLayer(ui::LAYER_SOLID_COLOR);
layer()->SetFillsBoundsOpaquely(false);
layer()->SetRoundedCornerRadius(
gfx::RoundedCornersF{kPrivacyIndicatorsViewExpandedShorterSideSize / 2});
layer()->SetIsFastRoundedCorner(true);
auto add_icon_to_container = [&container_view]() {
auto icon = std::make_unique<views::ImageView>();
icon->SetPaintToLayer();
icon->layer()->SetFillsBoundsOpaquely(false);
icon->SetVisible(false);
return container_view->AddChildView(std::move(icon));
};
camera_icon_ = add_icon_to_container();
microphone_icon_ = add_icon_to_container();
screen_share_icon_ = add_icon_to_container();
AddChildView(std::move(container_view));
UpdateIcons();
TooltipTextChanged();
UpdateVisibility();
Shell::Get()->session_controller()->AddObserver(this);
}
PrivacyIndicatorsTrayItemView::~PrivacyIndicatorsTrayItemView() {
Shell::Get()->session_controller()->RemoveObserver(this);
}
void PrivacyIndicatorsTrayItemView::OnCameraAndMicrophoneAccessStateChanged(
bool is_camera_used,
bool is_microphone_used,
bool is_new_app,
bool was_camera_in_use,
bool was_microphone_in_use) {
UpdateVisibility();
if (!GetVisible())
return;
auto* controller = PrivacyIndicatorsController::Get();
// We only want to perform the animation and show the camera/microphone icons
// in these cases:
// * If this is a new app accessing camera/microphone, the icons will be shown
// according to the access state of that particular app.
// * If this is an old app, but a new sensor is being accessed (was not in
// used before), we will show the icons of the sensors in which that
// particular app is accessing.
if (!is_new_app && !(controller->IsCameraUsed() && !was_camera_in_use) &&
!(controller->IsMicrophoneUsed() && !was_microphone_in_use)) {
return;
}
// We show the icons based on the access state of this current app.
camera_icon_->SetVisible(is_camera_used);
microphone_icon_->SetVisible(is_microphone_used);
TooltipTextChanged();
RecordPrivacyIndicatorsType();
// Perform animation if either one of the icon is visible.
if (camera_icon_->GetVisible() || microphone_icon_->GetVisible()) {
PerformAnimation();
}
}
void PrivacyIndicatorsTrayItemView::UpdateScreenShareStatus(
bool is_screen_sharing) {
if (is_screen_sharing_ == is_screen_sharing)
return;
is_screen_sharing_ = is_screen_sharing;
UpdateVisibility();
if (!GetVisible())
return;
screen_share_icon_->SetVisible(is_screen_sharing_);
TooltipTextChanged();
RecordPrivacyIndicatorsType();
// Perform animation whever screen is start sharing.
if (is_screen_sharing_) {
PerformAnimation();
}
}
void PrivacyIndicatorsTrayItemView::UpdateAlignmentForShelf(Shelf* shelf) {
layout_manager_->SetOrientation(
shelf->PrimaryAxisValue(views::BoxLayout::Orientation::kHorizontal,
views::BoxLayout::Orientation::kVertical));
UpdateBoundsInset();
}
std::u16string PrivacyIndicatorsTrayItemView::GetTooltipText(
const gfx::Point& point) const {
auto* controller = PrivacyIndicatorsController::Get();
auto cam_and_mic_status = std::u16string();
if (controller->IsCameraUsed() && controller->IsMicrophoneUsed()) {
cam_and_mic_status =
l10n_util::GetStringUTF16(IDS_PRIVACY_INDICATORS_STATUS_CAMERA_AND_MIC);
} else if (controller->IsCameraUsed()) {
cam_and_mic_status =
l10n_util::GetStringUTF16(IDS_PRIVACY_INDICATORS_STATUS_CAMERA);
} else if (controller->IsMicrophoneUsed()) {
cam_and_mic_status =
l10n_util::GetStringUTF16(IDS_PRIVACY_INDICATORS_STATUS_MIC);
}
auto screen_share_status =
is_screen_sharing_
? l10n_util::GetStringUTF16(IDS_ASH_STATUS_TRAY_SCREEN_SHARE_TITLE)
: std::u16string();
if (cam_and_mic_status.empty())
return screen_share_status;
if (screen_share_status.empty())
return cam_and_mic_status;
return l10n_util::GetStringFUTF16(IDS_PRIVACY_INDICATORS_VIEW_TOOLTIP,
{cam_and_mic_status, screen_share_status},
/*offsets=*/nullptr);
}
void PrivacyIndicatorsTrayItemView::UpdateVisibility() {
// We only hide the view when nothing is in use.
const bool visible = PrivacyIndicatorsController::Get()->IsCameraUsed() ||
PrivacyIndicatorsController::Get()->IsMicrophoneUsed() ||
is_screen_sharing_;
if (GetVisible() == visible) {
return;
}
SetVisible(visible);
if (!visible) {
if (IsInPrimaryDisplay(GetWidget())) {
base::UmaHistogramLongTimes(
"Ash.PrivacyIndicators.IndicatorShowsDuration",
base::Time::Now() - start_showing_time_);
}
return;
}
// Only record this metric on primary screen.
if (IsInPrimaryDisplay(GetWidget())) {
start_showing_time_ = base::Time::Now();
}
++count_visible_per_session_;
// Keep incrementing the count to track the number of times the view flickers.
// When the delay of `kRepeatedShowTimerInterval` has reached, record that
// count.
++count_repeated_shows_;
repeated_shows_timer_.Reset();
}
void PrivacyIndicatorsTrayItemView::PerformVisibilityAnimation(bool visible) {
// This view will not perform `TrayItemView`'s visibility animation since it
// has its own animation. We need to create our own function to trigger the
// animation rather than overriding this to avoid triggering overlapping
// animations when visibility changes.
}
void PrivacyIndicatorsTrayItemView::HandleLocaleChange() {
TooltipTextChanged();
}
gfx::Size PrivacyIndicatorsTrayItemView::CalculatePreferredSize(
const views::SizeBounds& available_size) const {
int shorter_side;
int longer_side;
switch (animation_state_) {
case AnimationState::kIdle:
return gfx::Size(kPrivacyIndicatorsViewSize, kPrivacyIndicatorsViewSize);
case AnimationState::kExpand:
shorter_side = kPrivacyIndicatorsViewExpandedShorterSideSize;
longer_side =
GetLongerSideLengthInExpandedMode() *
gfx::Tween::CalculateValue(gfx::Tween::ACCEL_20_DECEL_100,
expand_animation_->GetCurrentValue());
break;
case AnimationState::kDwellInExpand:
shorter_side = kPrivacyIndicatorsViewExpandedShorterSideSize;
longer_side = GetLongerSideLengthInExpandedMode();
break;
case AnimationState::kOnlyLongerSideShrink:
shorter_side = kPrivacyIndicatorsViewExpandedShorterSideSize;
longer_side =
CalculateSizeDuringShrinkAnimation(/*for_longer_side=*/true);
break;
case AnimationState::kBothSideShrink:
shorter_side =
CalculateSizeDuringShrinkAnimation(/*for_longer_side=*/false);
longer_side =
CalculateSizeDuringShrinkAnimation(/*for_longer_side=*/true);
break;
}
// `GetWidget()` might be null in unit tests.
auto* shelf = GetWidget() ? Shelf::ForWindow(GetWidget()->GetNativeWindow())
: Shell::GetPrimaryRootWindowController()->shelf();
// The view is rotated 90 degree in side shelf.
return shelf->PrimaryAxisValue(gfx::Size(longer_side, shorter_side),
gfx::Size(shorter_side, longer_side));
}
void PrivacyIndicatorsTrayItemView::OnThemeChanged() {
views::View::OnThemeChanged();
UpdateIcons();
layer()->SetColor(
GetColorProvider()->GetColor(ui::kColorAshPrivacyIndicatorsBackground));
}
void PrivacyIndicatorsTrayItemView::OnBoundsChanged(
const gfx::Rect& previous_bounds) {
UpdateBoundsInset();
}
views::View* PrivacyIndicatorsTrayItemView::GetTooltipHandlerForPoint(
const gfx::Point& point) {
return GetLocalBounds().Contains(point) ? this : nullptr;
}
void PrivacyIndicatorsTrayItemView::AnimationProgressed(
const gfx::Animation* animation) {
if (animation == expand_animation_.get()) {
DCHECK_EQ(animation_state_, AnimationState::kExpand);
} else if (animation == longer_side_shrink_animation_.get() &&
!shorter_side_shrink_animation_->is_animating()) {
animation_state_ = AnimationState::kOnlyLongerSideShrink;
} else {
animation_state_ = AnimationState::kBothSideShrink;
}
PreferredSizeChanged();
}
void PrivacyIndicatorsTrayItemView::AnimationEnded(
const gfx::Animation* animation) {
if (animation_state_ == AnimationState::kExpand) {
// Start kDwellInExpand when `expand_animation_` just finished.
animation_state_ = kDwellInExpand;
PreferredSizeChanged();
longer_side_shrink_delay_timer_.Start(
FROM_HERE, kDwellInExpandDuration,
base::BindOnce(&StartAnimation, longer_side_shrink_animation_.get()));
shorter_side_shrink_delay_timer_.Start(
FROM_HERE, kShorterSizeShrinkAnimationDelay,
base::BindOnce(&StartAnimation, shorter_side_shrink_animation_.get()));
}
// `shorter_side_shrink_animation_` should be the last one that is running, so
// switch the state back to kIdle when it ends.
if (animation == shorter_side_shrink_animation_.get()) {
animation_state_ = AnimationState::kIdle;
// Hide all the icons at the end since we only want to show a green dot.
camera_icon_->SetVisible(false);
microphone_icon_->SetVisible(false);
screen_share_icon_->SetVisible(false);
if (throughput_tracker_) {
// Reset `throughput_tracker_` to reset animation metrics recording.
throughput_tracker_->Stop();
throughput_tracker_.reset();
}
}
UpdateBoundsInset();
}
void PrivacyIndicatorsTrayItemView::AnimationCanceled(
const gfx::Animation* animation) {
// Finish all animations if one is canceled.
EndAllAnimations();
UpdateBoundsInset();
}
void PrivacyIndicatorsTrayItemView::ImmediatelyUpdateVisibility() {
// Normally there is work to do here, but this view implements custom
// visibility animations that do not adhere to the `TrayItemView` animations
// contract. See b/283493232 for details.
}
void PrivacyIndicatorsTrayItemView::PerformAnimation() {
// End all previous animations before starting a new sequence of animations.
EndAllAnimations();
// Start a multi-part animation:
// 1. kExpand: Expands to the fully expanded state, showing all icons.
// 2. kDwellInExpand: Then dwells at this size for `kDwellInExpandDuration`.
// 3. kOnlyLongerSideShrink: After that, collapses the long side first.
// 4. kBothSideShrink: Before the long side shrinks completely, collapses the
// short side to the final size (a green dot).
animation_state_ = AnimationState::kExpand;
expand_animation_->Start();
StartRecordAnimationSmoothness(GetWidget(), throughput_tracker_);
// At the same time, fade in icons.
if (camera_icon_->GetVisible()) {
FadeInView(camera_icon_, kCameraIconFadeInDuration,
"Ash.PrivacyIndicators.CameraIcon.AnimationSmoothness");
}
if (microphone_icon_->GetVisible()) {
FadeInView(microphone_icon_, kMicAndScreenshareFadeInDuration,
"Ash.PrivacyIndicators.MicrophoneIcon.AnimationSmoothness");
}
if (screen_share_icon_->GetVisible()) {
FadeInView(screen_share_icon_, kMicAndScreenshareFadeInDuration,
"Ash.PrivacyIndicators.ScreenshareIcon.AnimationSmoothness");
}
}
void PrivacyIndicatorsTrayItemView::OnSessionStateChanged(
session_manager::SessionState state) {
if (count_visible_per_session_ == 0)
return;
// Only record this metric on primary screen.
if (!IsInPrimaryDisplay(GetWidget())) {
return;
}
base::UmaHistogramCounts100("Ash.PrivacyIndicators.NumberOfShowsPerSession",
count_visible_per_session_);
count_visible_per_session_ = 0;
}
void PrivacyIndicatorsTrayItemView::UpdateIcons() {
const ui::ColorId icon_color_id =
chromeos::features::IsJellyrollEnabled()
? cros_tokens::kCrosSysInverseOnSurface
: static_cast<ui::ColorId>(kColorAshButtonIconColorPrimary);
camera_icon_->SetImage(ui::ImageModel::FromVectorIcon(
kPrivacyIndicatorsCameraIcon, icon_color_id, kPrivacyIndicatorsIconSize));
microphone_icon_->SetImage(ui::ImageModel::FromVectorIcon(
kPrivacyIndicatorsMicrophoneIcon, icon_color_id,
kPrivacyIndicatorsIconSize));
screen_share_icon_->SetImage(ui::ImageModel::FromVectorIcon(
kPrivacyIndicatorsScreenShareIcon, icon_color_id,
kPrivacyIndicatorsIconSize));
}
void PrivacyIndicatorsTrayItemView::UpdateBoundsInset() {
gfx::Rect bounds = GetLocalBounds();
// `GetWidget()` might be null in unit tests.
auto* shelf = GetWidget() ? Shelf::ForWindow(GetWidget()->GetNativeWindow())
: Shell::GetPrimaryRootWindowController()->shelf();
// We set the bounds inset based on the shorter side of the view (the shorter
// size changes based on shelf alignment).
int shorter_side_inset = shelf->PrimaryAxisValue(height(), width()) -
shelf->PrimaryAxisValue(GetPreferredSize().height(),
GetPreferredSize().width());
bounds.Inset(
shelf->PrimaryAxisValue(gfx::Insets::VH(shorter_side_inset / 2, 0),
gfx::Insets::VH(0, shorter_side_inset / 2)));
layer()->SetClipRect(bounds);
}
int PrivacyIndicatorsTrayItemView::CalculateSizeDuringShrinkAnimation(
bool for_longer_side) const {
auto* animation = for_longer_side ? longer_side_shrink_animation_.get()
: shorter_side_shrink_animation_.get();
double animation_value = gfx::Tween::CalculateValue(
gfx::Tween::ACCEL_20_DECEL_100, animation->GetCurrentValue());
int begin_size = for_longer_side
? GetLongerSideLengthInExpandedMode()
: kPrivacyIndicatorsViewExpandedShorterSideSize;
// The size shrink from `begin_size` to kPrivacyIndicatorsViewSize when
// `animation_value` goes from 0 to 1, and here is the calculation for it.
return begin_size -
(begin_size - kPrivacyIndicatorsViewSize) * animation_value;
}
int PrivacyIndicatorsTrayItemView::GetLongerSideLengthInExpandedMode() const {
// If all three icons are visible, the view should be longer.
return PrivacyIndicatorsController::Get()->IsCameraUsed() &&
PrivacyIndicatorsController::Get()->IsMicrophoneUsed() &&
is_screen_sharing_
? kPrivacyIndicatorsViewExpandedWithScreenShareSize
: kPrivacyIndicatorsViewExpandedLongerSideSize;
}
void PrivacyIndicatorsTrayItemView::EndAllAnimations() {
shorter_side_shrink_animation_->End();
longer_side_shrink_animation_->End();
expand_animation_->End();
animation_state_ = AnimationState::kIdle;
if (throughput_tracker_) {
// Reset `throughput_tracker_` to reset animation metrics recording.
throughput_tracker_->Stop();
throughput_tracker_.reset();
}
}
void PrivacyIndicatorsTrayItemView::RecordPrivacyIndicatorsType() {
auto* controller = PrivacyIndicatorsController::Get();
const bool is_camera_used = controller->IsCameraUsed();
const bool is_microphone_used = controller->IsMicrophoneUsed();
int camera_used = is_camera_used ? static_cast<int>(Type::kCamera) : 0;
int microphone_used =
is_microphone_used ? static_cast<int>(Type::kMicrophone) : 0;
int screen_sharing =
is_screen_sharing_ ? static_cast<int>(Type::kScreenSharing) : 0;
base::UmaHistogramEnumeration(
"Ash.PrivacyIndicators.ShowType",
static_cast<Type>(camera_used | microphone_used | screen_sharing));
if (is_camera_used) {
base::UmaHistogramCounts100(
"Ash.PrivacyIndicators.NumberOfAppsAccessingCamera",
controller->apps_using_camera().size());
}
if (is_microphone_used) {
base::UmaHistogramCounts100(
"Ash.PrivacyIndicators.NumberOfAppsAccessingMicrophone",
controller->apps_using_microphone().size());
}
}
void PrivacyIndicatorsTrayItemView::RecordRepeatedShows() {
// Only records in primary display. Note that we also record the metric when
// `count_repeated_shows_` is one even though this is not a bad signal. This
// is because we want to record proper shows so we can analyze the repeated
// shows in context.
if (count_repeated_shows_ == 0 || !IsInPrimaryDisplay(GetWidget())) {
return;
}
base::UmaHistogramCounts100("Ash.PrivacyIndicators.NumberOfRepeatedShows",
count_repeated_shows_);
count_repeated_shows_ = 0;
}
BEGIN_METADATA(PrivacyIndicatorsTrayItemView)
END_METADATA
} // namespace ash