chromium/ash/system/phonehub/phone_hub_tray.cc

// 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 "ash/system/phonehub/phone_hub_tray.h"

#include <string>
#include <utility>

#include "ash/accessibility/accessibility_controller.h"
#include "ash/constants/ash_features.h"
#include "ash/constants/tray_background_view_catalog.h"
#include "ash/focus_cycler.h"
#include "ash/multi_device_setup/multi_device_notification_presenter.h"
#include "ash/public/cpp/system/anchored_nudge_manager.h"
#include "ash/public/cpp/system_tray_client.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/root_window_controller.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/ash_color_provider.h"
#include "ash/system/eche/eche_icon_loading_indicator_view.h"
#include "ash/system/eche/eche_tray.h"
#include "ash/system/model/system_tray_model.h"
#include "ash/system/phonehub/onboarding_nudge_controller.h"
#include "ash/system/phonehub/phone_hub_content_view.h"
#include "ash/system/phonehub/phone_hub_metrics.h"
#include "ash/system/phonehub/quick_actions_view.h"
#include "ash/system/phonehub/task_continuation_view.h"
#include "ash/system/phonehub/ui_constants.h"
#include "ash/system/status_area_widget.h"
#include "ash/system/tray/system_menu_button.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_popup_utils.h"
#include "ash/system/tray/tray_utils.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/notreached.h"
#include "base/power_monitor/power_monitor.h"
#include "base/task/sequenced_task_runner.h"
#include "base/time/default_clock.h"
#include "chromeos/ash/components/multidevice/logging/logging.h"
#include "chromeos/ash/components/phonehub/icon_decoder.h"
#include "chromeos/ash/components/phonehub/phone_hub_manager.h"
#include "chromeos/ash/components/phonehub/phone_model.h"
#include "components/vector_icons/vector_icons.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/base/resource/resource_bundle.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/color/color_id.h"
#include "ui/display/manager/display_manager.h"
#include "ui/events/event.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/views/border.h"
#include "ui/views/controls/button/button_controller.h"
#include "ui/views/controls/image_view.h"

namespace ash {

namespace {

// Command ID for Phone Hub context menu
constexpr int kHidePhoneHubIconCommandId = 1;

// Padding for tray icons (dp; the button that shows the phone_hub menu).
constexpr int kTrayIconMainAxisInset = 6;
constexpr int kTrayIconCrossAxisInset = 0;
constexpr int kEcheIconMinSize = 24;
constexpr int kIconSpacing = 12;

constexpr int kHidePhoneHubContexMenuIconSize = 20;

constexpr auto kBubblePadding =
    gfx::Insets::TLBR(0, 0, kBubbleBottomPaddingDip, 0);

bool IsInUserSession() {
  SessionControllerImpl* session_controller =
      Shell::Get()->session_controller();
  return session_controller->GetSessionState() ==
             session_manager::SessionState::ACTIVE &&
         !session_controller->IsRunningInAppMode();
}

}  // namespace

PhoneHubTray::PhoneHubTray(Shelf* shelf)
    : TrayBackgroundView(shelf, TrayBackgroundViewCatalogName::kPhoneHub),
      ui_controller_(new PhoneHubUiController()),
      last_unlocked_timestamp_(base::Time::NowFromSystemTime()) {
  // By default, if the individual buttons did not handle the event consider it
  // as a phone hub icon event.
  SetCallback(base::BindRepeating(&PhoneHubTray::PhoneHubIconActivated,
                                  base::Unretained(this)));

  observed_phone_hub_ui_controller_.Observe(ui_controller_.get());
  observed_session_.Observe(Shell::Get()->session_controller());

  tray_container()->SetMargin(kTrayIconMainAxisInset, kTrayIconCrossAxisInset);
  // TODO(nayebi): Think about constructing the eche_icon outside of this class,
  // either as an input argument or being set through a setter.
  if (features::IsEcheSWAEnabled()) {
    auto eche_icon = std::make_unique<views::ImageButton>(base::BindRepeating(
        &PhoneHubTray::EcheIconActivated, weak_factory_.GetWeakPtr()));
    eche_icon->SetButtonController(std::make_unique<views::ButtonController>(
        /*views::Button*=*/eche_icon.get(),
        std::make_unique<TrayBackgroundView::TrayButtonControllerDelegate>(
            /*views::Button*=*/eche_icon.get(),
            TrayBackgroundViewCatalogName::kPhoneHub)));
    eche_icon->SetImageVerticalAlignment(
        views::ImageButton::VerticalAlignment::ALIGN_MIDDLE);
    eche_icon->SetImageHorizontalAlignment(
        views::ImageButton::HorizontalAlignment::ALIGN_CENTER);
    eche_icon->SetMinimumImageSize(
        gfx::Size(kEcheIconMinSize, kEcheIconMinSize));
    eche_icon->SetVisible(false);
    eche_loading_indicator_ = eche_icon->AddChildView(
        std::make_unique<EcheIconLoadingIndicatorView>(eche_icon.get()));
    eche_loading_indicator_->SetVisible(false);
    eche_icon_ = tray_container()->AddChildView(std::move(eche_icon));
    tray_container()->SetSpacingBetweenChildren(kIconSpacing);
  }
  auto icon = std::make_unique<views::ImageButton>(base::BindRepeating(
      &PhoneHubTray::PhoneHubIconActivated, weak_factory_.GetWeakPtr()));
  icon->SetButtonController(std::make_unique<views::ButtonController>(
      /*views::Button*=*/icon.get(),
      std::make_unique<TrayBackgroundView::TrayButtonControllerDelegate>(
          /*views::Button*=*/icon.get(),
          TrayBackgroundViewCatalogName::kPhoneHub)));
  icon->SetFocusBehavior(FocusBehavior::NEVER);
  icon->SetTooltipText(
      l10n_util::GetStringUTF16(IDS_ASH_PHONE_HUB_TRAY_ACCESSIBLE_NAME));
  icon->SetImageVerticalAlignment(
      views::ImageButton::VerticalAlignment::ALIGN_MIDDLE);
  icon->SetImageHorizontalAlignment(
      views::ImageButton::HorizontalAlignment::ALIGN_CENTER);
  icon_ = tray_container()->AddChildView(std::move(icon));

  UpdateTrayItemColor(is_active());

  onboarding_nudge_controller_ =
      features::IsPhoneHubOnboardingNotifierRevampEnabled()
          ? std::make_unique<OnboardingNudgeController>(
                /*phone_hub_tray=*/this,
                /*animation_stop_callback=*/
                base::BindRepeating(&PhoneHubTray::StopPulseAnimation,
                                    weak_factory_.GetWeakPtr()),
                /*start_animation_callback=*/
                base::BindRepeating(&PhoneHubTray::StartPulseAnimation,
                                    weak_factory_.GetWeakPtr()),
                base::DefaultClock::GetInstance())
          : nullptr;

  Shell::Get()->display_manager()->AddDisplayManagerObserver(this);
}

PhoneHubTray::~PhoneHubTray() {
  if (bubble_)
    bubble_->bubble_view()->ResetDelegate();
  if (phone_hub_manager_) {
    phone_hub_manager_->GetAppStreamManager()->RemoveObserver(this);
  }
  if (phone_hub_manager_ && IsInPhoneHubNudgeExperimentGroup() &&
      onboarding_nudge_controller_) {
    phone_hub_manager_->GetFeatureStatusProvider()->RemoveObserver(
        onboarding_nudge_controller_.get());
  }
  Shell::Get()->display_manager()->RemoveDisplayManagerObserver(this);
}

void PhoneHubTray::SetPhoneHubManager(
    phonehub::PhoneHubManager* phone_hub_manager) {
  ui_controller_->SetPhoneHubManager(phone_hub_manager);
  if (phone_hub_manager_) {
    phone_hub_manager_->GetAppStreamManager()->RemoveObserver(this);
  }
  if (phone_hub_manager) {
    phone_hub_manager->GetAppStreamManager()->AddObserver(this);
  }
  phone_hub_manager_ = phone_hub_manager;
  if (phone_hub_manager_ && IsInPhoneHubNudgeExperimentGroup() &&
      onboarding_nudge_controller_) {
    phone_hub_manager_->GetFeatureStatusProvider()->AddObserver(
        onboarding_nudge_controller_.get());
  }
}

void PhoneHubTray::ClickedOutsideBubble(const ui::LocatedEvent& event) {
  CloseBubble();
}

void PhoneHubTray::UpdateTrayItemColor(bool is_active) {
  icon_->SetImageModel(
      views::ImageButton::STATE_NORMAL,
      ui::ImageModel::FromVectorIcon(
          kPhoneHubPhoneIcon,
          is_active ? cros_tokens::kCrosSysSystemOnPrimaryContainer
                    : cros_tokens::kCrosSysOnSurface));
}

std::u16string PhoneHubTray::GetAccessibleNameForTray() {
  return l10n_util::GetStringUTF16(IDS_ASH_PHONE_HUB_TRAY_ACCESSIBLE_NAME);
}

void PhoneHubTray::HandleLocaleChange() {
  icon_->SetTooltipText(
      l10n_util::GetStringUTF16(IDS_ASH_PHONE_HUB_TRAY_ACCESSIBLE_NAME));
}

void PhoneHubTray::HideBubbleWithView(const TrayBubbleView* bubble_view) {
  if (bubble_->bubble_view() == bubble_view)
    CloseBubble();
}

std::u16string PhoneHubTray::GetAccessibleNameForBubble() {
  return GetAccessibleNameForTray();
}

bool PhoneHubTray::ShouldEnableExtraKeyboardAccessibility() {
  return Shell::Get()->accessibility_controller()->spoken_feedback().enabled();
}

void PhoneHubTray::HideBubble(const TrayBubbleView* bubble_view) {
  HideBubbleWithView(bubble_view);
}

void PhoneHubTray::OnPhoneHubUiStateChanged() {
  UpdateVisibility();
  UpdateHeaderVisibility();

  if (!bubble_)
    return;
  TrayBubbleView* bubble_view = bubble_->bubble_view();

  DCHECK(ui_controller_.get());
  std::unique_ptr<PhoneHubContentView> content_view =
      ui_controller_->CreateContentView(this);
  if (!content_view.get()) {
    CloseBubble();
    return;
  }

  if (content_view_) {
    // If we are already showing the same content_view, no need to remove and
    // update the tray.
    // TODO(crbug.com/1185316) : Find way to update views without work around
    // when same view is removed and added.
    if (content_view->GetID() == content_view_->GetID())
      return;

    bubble_view->RemoveChildView(content_view_);
    delete content_view_;
  }
  content_view_ = bubble_view->AddChildView(std::move(content_view));

  // Updates bubble to handle possible size change with a different child view.
  bubble_view->UpdateBubble();
}

void PhoneHubTray::OnSessionStateChanged(session_manager::SessionState state) {
  TemporarilyDisableAnimation();
  if (state == session_manager::SessionState::ACTIVE) {
    last_unlocked_timestamp_ = base::Time::NowFromSystemTime();
    UpdateVisibility();
  }
}

void PhoneHubTray::OnActiveUserSessionChanged(const AccountId& account_id) {
  TemporarilyDisableAnimation();
}

void PhoneHubTray::AnchorUpdated() {
  if (bubble_)
    bubble_->bubble_view()->UpdateBubble();
}

void PhoneHubTray::OnVisibilityAnimationFinished(
    bool should_log_visible_pod_count,
    bool aborted) {
  TrayBackgroundView::OnVisibilityAnimationFinished(
      should_log_visible_pod_count, aborted);
  if (IsInPhoneHubNudgeExperimentGroup() &&
      ui_controller_->ui_state() ==
          PhoneHubUiController::UiState::kOnboardingWithoutPhone) {
    onboarding_nudge_controller_->ShowNudgeIfNeeded();
  }
}

void PhoneHubTray::OnDidApplyDisplayChanges() {
  if (!bubble_ || !bubble_->GetBubbleView())
    return;
  bubble_->GetBubbleView()->ChangeAnchorRect(
      shelf()->GetSystemTrayAnchorRect());
}

void PhoneHubTray::Initialize() {
  TrayBackgroundView::Initialize();
  // For secondary displays to have Phone Hub visible, manager must
  // be set.
  phonehub::PhoneHubManager* phone_hub_tray_manager =
      Shell::Get()->system_tray_model()->phone_hub_manager();
  if (phone_hub_tray_manager) {
    SetPhoneHubManager(phone_hub_tray_manager);
  }
  UpdateVisibility();
}

void PhoneHubTray::ShowBubble() {
  if (bubble_)
    return;

  ui_controller_->HandleBubbleOpened();

  auto bubble_view =
      std::make_unique<TrayBubbleView>(CreateInitParamsForTrayBubble(
          /*tray=*/this, /*anchor_to_shelf_corner=*/true));
  bubble_view->SetBorder(views::CreateEmptyBorder(kBubblePadding));

  // Creates header view on top for displaying phone status and settings icon.
  auto phone_status = ui_controller_->CreateStatusHeaderView(this);
  phone_status_view_dont_use_ = phone_status.get();
  DCHECK(phone_status_view_dont_use_);
  bubble_view->AddChildView(std::move(phone_status));

  // Other contents, i.e. the connected view and the interstitial views,
  // will be positioned underneath the phone status view and updated based
  // on the current mode.
  auto content_view = ui_controller_->CreateContentView(this);
  content_view_ = content_view.get();

  if (!content_view_) {
    CloseBubble();
    return;
  }

  bubble_view->AddChildView(std::move(content_view));

  bubble_ = std::make_unique<TrayBubbleWrapper>(this);
  bubble_->ShowBubble(std::move(bubble_view));
  UpdateHeaderVisibility();

  SetIsActive(true);

  phone_hub_metrics::LogScreenOnBubbleOpen(
      content_view_->GetScreenForMetrics());
}

TrayBubbleView* PhoneHubTray::GetBubbleView() {
  return bubble_ ? bubble_->bubble_view() : nullptr;
}

views::Widget* PhoneHubTray::GetBubbleWidget() const {
  return bubble_ ? bubble_->GetBubbleWidget() : nullptr;
}

bool PhoneHubTray::CanOpenConnectedDeviceSettings() {
  return TrayPopupUtils::CanOpenWebUISettings();
}

void PhoneHubTray::OpenConnectedDevicesSettings() {
  DCHECK(content_view_);
  phone_hub_metrics::LogScreenOnSettingsButtonClicked(
      content_view_->GetScreenForMetrics());

  DCHECK(CanOpenConnectedDeviceSettings());
  Shell::Get()->system_tray_model()->client()->ShowConnectedDevicesSettings();
}

void PhoneHubTray::HideStatusHeaderView() {
  if (!GetPhoneStatusView())
    return;

  GetPhoneStatusView()->SetVisible(false);
  bubble_->bubble_view()->UpdateBubble();
}

bool PhoneHubTray::IsPhoneHubIconClickedWhenNudgeVisible() {
  return is_icon_clicked_when_nudge_visible_;
}

void PhoneHubTray::OnAppStreamUpdate(
    const phonehub::proto::AppStreamUpdate app_stream_update) {
  auto* app = &app_stream_update.foreground_app();
  // TODO(nayebi): Try to extract this decoding process into a shared code
  // inside the icon decoder.
  // Decode the icon
  std::unique_ptr<std::vector<phonehub::IconDecoder::DecodingData>>
      decoding_data_list =
          std::make_unique<std::vector<phonehub::IconDecoder::DecodingData>>();
  std::hash<std::string> str_hash;
  std::string key = app->package_name() + base::NumberToString(app->user_id());
  phonehub::IconDecoder::DecodingData decoding_data =
      phonehub::IconDecoder::DecodingData(str_hash(key), app->icon());
  // load with default image
  decoding_data.result =
      gfx::Image(CreateVectorIcon(kPhoneHubPhoneIcon, gfx::kGoogleGrey700));
  decoding_data_list->push_back(decoding_data);
  phone_hub_manager_->GetIconDecoder()->BatchDecode(
      std::move(decoding_data_list),
      base::BindOnce(&PhoneHubTray::OnIconsDecoded, weak_factory_.GetWeakPtr(),
                     app->visible_name()));
}

void PhoneHubTray::OnIconsDecoded(
    std::string visible_name,
    std::unique_ptr<std::vector<phonehub::IconDecoder::DecodingData>>
        decoding_data_list) {
  if (decoding_data_list->empty())
    return;
  EcheTray* eche_tray = Shell::GetPrimaryRootWindowController()
                            ->GetStatusAreaWidget()
                            ->eche_tray();
  if (!eche_tray)
    return;

  eche_tray->SetIcon(decoding_data_list->front().result,
                     base::UTF8ToUTF16(visible_name));
}

void PhoneHubTray::SetEcheIconActivationCallback(
    base::RepeatingCallback<void()> callback) {
  eche_icon_callback_ = std::move(callback);
}

void PhoneHubTray::CloseBubbleInternal() {
  if (!bubble_)
    return;

  auto* bubble_view = bubble_->GetBubbleView();
  if (bubble_view)
    bubble_view->ResetDelegate();

  if (content_view_) {
    phone_hub_metrics::LogScreenOnBubbleClose(
        content_view_->GetScreenForMetrics());

    content_view_->OnBubbleClose();
    content_view_ = nullptr;
  }

  if (phone_status_view_dont_use_) {
    phone_status_view_dont_use_ = nullptr;
  }

  if (features::IsEcheSWAEnabled() && features::IsEcheLauncherEnabled() &&
      phone_hub_manager_->GetAppStreamLauncherDataModel()) {
    phone_hub_manager_->GetAppStreamLauncherDataModel()
        ->SetShouldShowMiniLauncher(false);
  }

  bubble_.reset();
  // Reset the value when bubble is closed so that next time when setup dialog
  // is opened from Phone Hub bubble it will not be logged to wrong bucket.
  is_icon_clicked_when_nudge_visible_ = false;
  SetIsActive(false);
  shelf()->UpdateAutoHideState();
}

void PhoneHubTray::UpdateVisibility() {
  DCHECK(ui_controller_.get());
  auto ui_state = ui_controller_->ui_state();
  // If the icon becomes visible for onboarding after 5 minutes of log in, we do
  // not show the icon until next log in/unlock.
  if (features::IsPhoneHubMonochromeNotificationIconsEnabled() &&
      ui_state == PhoneHubUiController::UiState::kOnboardingWithoutPhone &&
      !IsInsideUnlockWindow()) {
    return;
  }
  icon_->set_context_menu_controller(
      ui_state == PhoneHubUiController::UiState::kOnboardingWithPhone ||
              ui_state == PhoneHubUiController::UiState::kOnboardingWithoutPhone
          ? this
          : nullptr);

  SetVisiblePreferred(ui_state != PhoneHubUiController::UiState::kHidden &&
                      IsInUserSession());
}

std::unique_ptr<ui::SimpleMenuModel> PhoneHubTray::CreateContextMenuModel() {
  auto context_menu_model = std::make_unique<ui::SimpleMenuModel>(this);

  context_menu_model->AddItemWithIcon(
      kHidePhoneHubIconCommandId,
      l10n_util::GetStringUTF16(IDS_ASH_PHONE_HUB_TRAY_ICON_DISMISS_TEXT),
      ui::ImageModel::FromVectorIcon(vector_icons::kVisibilityOffIcon,
                                     ui::kColorAshSystemUIMenuIcon,
                                     kHidePhoneHubContexMenuIconSize));

  return context_menu_model;
}

void PhoneHubTray::ExecuteCommand(int command_id, int event_flags) {
  if (command_id == kHidePhoneHubIconCommandId) {
    phone_hub_manager_->GetOnboardingUiTracker()->DismissSetupUi();
    return;
  }
  NOTREACHED();
}

void PhoneHubTray::UpdateHeaderVisibility() {
  if (!features::IsEcheLauncherEnabled())
    return;
  if (!GetPhoneStatusView())
    return;

  DCHECK(ui_controller_.get());
  auto ui_state = ui_controller_->ui_state();
  GetPhoneStatusView()->SetVisible(
      ui_state != PhoneHubUiController::UiState::kMiniLauncher);
}

void PhoneHubTray::TemporarilyDisableAnimation() {
  base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
      FROM_HERE, DisableShowAnimation().Release(), base::Seconds(5));
}

void PhoneHubTray::EcheIconActivated(const ui::Event& event) {
  eche_icon_callback_.Run();
}

void PhoneHubTray::PhoneHubIconActivated(const ui::Event& event) {
  // Simply toggle between visible/invisibvle
  if (bubble_ && bubble_->bubble_view()->GetVisible()) {
    CloseBubble();
    return;
  }

  if (features::IsPhoneHubOnboardingNotifierRevampEnabled() &&
      AnchoredNudgeManager::Get()->IsNudgeShown(
          OnboardingNudgeController::kPhoneHubNudgeId)) {
    is_icon_clicked_when_nudge_visible_ = true;
    onboarding_nudge_controller_->HideNudge();
    onboarding_nudge_controller_->MaybeRecordNudgeAction();
  }

  ShowBubble();

  if (message_center::MessageCenter::Get()->FindPopupNotificationById(
          MultiDeviceNotificationPresenter::kSetupNotificationId) &&
      ui_controller_->ui_state() ==
          PhoneHubUiController::UiState::kOnboardingWithoutPhone &&
      !is_icon_clicked_when_setup_notification_visible_) {
    Shell::Get()
        ->multidevice_notification_presenter()
        ->UpdateIsSetupNotificationInteracted(true);
    phone_hub_metrics::LogMultiDeviceSetupNotificationInteraction();
    // Set to true to prevent duplicate logging data if the icon is clicked
    // multiple times when notification is visible.
    is_icon_clicked_when_setup_notification_visible_ = true;
  }
}

views::View* PhoneHubTray::GetPhoneStatusView() {
  if (!bubble_ || !bubble_->GetBubbleView()) {
    phone_status_view_dont_use_ = nullptr;
  }
  return phone_status_view_dont_use_;
}

bool PhoneHubTray::IsInsideUnlockWindow() {
  return (base::Time::NowFromSystemTime() - last_unlocked_timestamp_) <=
         features::kMultiDeviceSetupNotificationTimeLimit.Get();
}

bool PhoneHubTray::IsInPhoneHubNudgeExperimentGroup() {
  return features::IsPhoneHubOnboardingNotifierRevampEnabled() &&
         features::kPhoneHubOnboardingNotifierUseNudge.Get();
}

BEGIN_METADATA(PhoneHubTray)
END_METADATA

}  // namespace ash