chromium/chrome/browser/ash/camera_mic/vm_camera_mic_manager.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 "chrome/browser/ash/camera_mic/vm_camera_mic_manager.h"

#include <string>
#include <tuple>
#include <utility>

#include "ash/constants/ash_constants.h"
#include "ash/constants/ash_features.h"
#include "ash/constants/notifier_catalogs.h"
#include "ash/public/cpp/notification_utils.h"
#include "ash/public/cpp/vm_camera_mic_constants.h"
#include "ash/system/privacy/privacy_indicators_controller.h"
#include "ash/webui/settings/public/constants/routes.mojom.h"
#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/memory/raw_ptr.h"
#include "base/no_destructor.h"
#include "base/notreached.h"
#include "base/system/sys_info.h"
#include "base/task/thread_pool.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "chrome/app/vector_icons/vector_icons.h"
#include "chrome/browser/ash/borealis/borealis_util.h"
#include "chrome/browser/ash/plugin_vm/plugin_vm_util.h"
#include "chrome/browser/ash/video_conference/video_conference_ash_feature_client.h"
#include "chrome/browser/notifications/notification_display_service.h"
#include "chrome/browser/ui/chrome_pages.h"
#include "chrome/browser/ui/settings_window_manager_chromeos.h"
#include "chrome/browser/ui/webui/ash/settings/app_management/app_management_uma.h"
#include "chrome/grit/generated_resources.h"
#include "components/vector_icons/vector_icons.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "media/capture/video/chromeos/mojom/cros_camera_service.mojom-shared.h"
#include "media/capture/video/chromeos/public/cros_features.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/message_center/public/cpp/message_center_constants.h"
#include "ui/message_center/public/cpp/notification.h"
#include "ui/message_center/public/cpp/notification_delegate.h"
#include "ui/message_center/public/cpp/notification_types.h"

namespace ash {

namespace {

const char kNotificationIdPrefix[] = "vm_camera_mic_manager";

}  // namespace

constexpr VmCameraMicManager::NotificationType
    VmCameraMicManager::kNoNotification;
constexpr VmCameraMicManager::NotificationType
    VmCameraMicManager::kMicNotification;
constexpr VmCameraMicManager::NotificationType
    VmCameraMicManager::kCameraNotification;
constexpr VmCameraMicManager::NotificationType
    VmCameraMicManager::kCameraAndMicNotification;
constexpr base::TimeDelta VmCameraMicManager::kDebounceTime;

// VmInfo stores the camera/mic information for a VM. It also controls the
// notifications for the VM. We either do not display a notification at all, or
// display a single notification, which can be a "camera", "mic", or a "camera
// and mic" notification.
//
// Some apps will quickly turn on and off devices multiple times (e.g. skype in
// Parallels does this about 5 times when starting a meeting). To avoid flashing
// multiple notifications, we implement a debounce algorithm here. The debounce
// algorithm needs to handle the following situations:
//
// * when a VM opens the camera and mic subsequently with a small delay
//   in-between, we should only show the "camera and mic" notification, instead
//   of showing the "camera" notification first and then switching to the
//   "camera and mic" one.
// * when a VM turns on a device and then immediately turns it off (e.g. taking
//   a photo), we should make sure the notification is shown (for a short period
//   of time). So, the debounce algorithm should not naively accumulate device
//   changes and then only act on the final accumulated state.
//
//
// How the debounce algorithm works
// ================================
//
// Basically, when a new device update comes, the algorithm starts a debounce
// period for `kDebounceTime`, during which we record the changes, and we update
// the notification one or more times afterwards.
//
// The type of notification is represented by `NotificationType`, which is a
// bitset of two bits. For a "camera" notification, only the camera bit is set
// (i.e. `10`). A "camera and mic" notification sets both bits (i.e. `11`). If
// no notification should be shown, we set both bits to 0 (i.e. `00`).
//
// Our algorithm maintains 3 `NotificationType` variables (see
// `notifications_`):
//
// * active: this is what is currently displaying. When we say setting `active`
//           to some value, we also mean updating the displaying notification.
// * target: this is updated immediately whenever device updates come in, so it
//           represents the latest state. If a device is turned on and off
//           immediately, obviously the effect is erased from target. This is
//           why we need another variable `stage`.
// * stage: this is updated immediately whenever a device is turned *on*.
//          Turning off a device does not affect this directly.
//
// This algorithm for updating `target` and `stage` is implemented in
// `OnDeviceUpdated()`, which also starts/stops the debounce timer if necessary.
//
// When `active == stage == target`, we are "stable" --- we don't need to do
// anything (until the next device update). And
// `SyncNotificationAndIndicators()` is normally what brings us to stable. It is
// called when the timer expired. This is what it does:
//
// * If `active != stage`, we set `active = stage`. Timer is reset if we are
//   still not stable.
// * Otherwise, `active == stage != target`. we set `active = stage = target`.
//   We reach the stable state now.
//
// Here is an example where the mic is turned on, the camera is turned on and
// then off immediately, and mic is turned off at the end after some time. We
// denote the state of the system with 6 bits: <active>-<stage>-<target>.
//
// 1: 00-00-00  # Stable, nothing is on.
// 2: 00-01-01  # Mic turning on, debounce timer is started.
// 3: 00-11-11  # Camera turning on, still in debounce period.
// 4: 00-11-01  # Camera turning off, still in debounce period.
// 5: 11-11-01  # Timer expired. `SyncNotificationAndIndicators()` sets
//              # `active=stage` (shows "camera and mic" notification). Reset
//              # the timer.
// 6: 01-01-01  # Timer expired. `SyncNotificationAndIndicators()` sets
//              # `active=stage=target` (shows mic notification).  We are stable
//              # now.
// 7: 01-01-00  # Mic turning off, debounce timer is started.
// 8: 00-00-00  # Timer expired. Same as 6, but no notification is shown now.
//              # Reach stable again.
class VmCameraMicManager::VmInfo : public message_center::NotificationObserver {
 public:
  VmInfo(Profile* profile,
         VmType vm_type,
         int name_id,
         base::RepeatingClosure on_notification_changed)
      : profile_(profile),
        vm_type_(vm_type),
        name_id_(name_id),
        notification_changed_callback_(on_notification_changed),
        debounce_timer_(
            FROM_HERE,
            kDebounceTime,
            base::BindRepeating(&VmInfo::SyncNotificationAndIndicators,
                                // Unretained because the timer
                                // cannot outlive the parent.
                                base::Unretained(this))) {}
  ~VmInfo() = default;

  VmType vm_type() const { return vm_type_; }
  int name_id() const { return name_id_; }
  NotificationType notification_type() const { return notifications_.active; }

  void SetMicActive(bool active) {
    OnDeviceUpdated(DeviceType::kMic, active);

    if (features::IsVideoConferenceEnabled()) {
      VideoConferenceAshFeatureClient::Get()->OnVmDeviceUpdated(
          vm_type_, DeviceType::kMic, active);
    }
  }

  void SetCameraAccessing(bool accessing) {
    camera_accessing_ = accessing;
    OnCameraUpdated();
    if (features::IsVideoConferenceEnabled()) {
      VideoConferenceAshFeatureClient::Get()->OnVmDeviceUpdated(
          vm_type_, DeviceType::kCamera, accessing);
    }
  }
  void SetCameraPrivacyIsOn(bool on) {
    camera_privacy_is_on_ = on;
    OnCameraUpdated();
  }

 private:
  void OnCameraUpdated() {
    OnDeviceUpdated(DeviceType::kCamera,
                    camera_accessing_ && !camera_privacy_is_on_);
  }

  // See document at the beginning of class.
  void OnDeviceUpdated(DeviceType device, bool value) {
    size_t device_index = static_cast<size_t>(device);

    notifications_.target.set(device_index, value);
    if (value) {
      notifications_.stage.set(device_index, value);
    }

    VLOG(1) << "update stage/target vm_type=" << static_cast<int>(vm_type_)
            << " state: " << notifications_.active << "-"
            << notifications_.stage << "-" << notifications_.target;

    SyncTimer();
  }

  void SyncTimer() {
    const bool stable = notifications_.active == notifications_.stage &&
                        notifications_.active == notifications_.target;
    const bool should_run_timer = !stable;
    const bool is_running = debounce_timer_.IsRunning();

    if (should_run_timer && !is_running) {
      debounce_timer_.Reset();
    } else if (!should_run_timer && is_running) {
      debounce_timer_.Stop();
    }
  }

  void UpdateActiveNotificationAndIndicators(
      NotificationType new_notification) {
    DCHECK_NE(notifications_.active, new_notification);

    auto app_name = l10n_util::GetStringUTF16(name_id_);
    auto delegate = base::MakeRefCounted<PrivacyIndicatorsNotificationDelegate>(
        /*launch_settings=*/base::BindRepeating(
            &VmCameraMicManager::VmInfo::OpenSettings,
            weak_ptr_factory_.GetWeakPtr()));

    // Privacy indicators is only enabled when Video Conference is disabled.
    bool privacy_indicators_enabled = !features::IsVideoConferenceEnabled();

    if (notifications_.active != kNoNotification) {
      if (privacy_indicators_enabled) {
        PrivacyIndicatorsController::Get()->UpdatePrivacyIndicators(
            /*app_id=*/GetNotificationId(vm_type_, notifications_.active),
            app_name, /*is_camera_used=*/false, /*is_microphone_used=*/false,
            delegate, PrivacyIndicatorsSource::kLinuxVm);
      } else {
        CloseNotification(notifications_.active);
      }
    }

    if (new_notification != kNoNotification) {
      // Privacy indicator is only enabled when Video Conference is disabled.
      if (privacy_indicators_enabled) {
        PrivacyIndicatorsController::Get()->UpdatePrivacyIndicators(
            /*app_id=*/GetNotificationId(vm_type_, new_notification), app_name,
            /*is_camera_used=*/
            new_notification[static_cast<size_t>(DeviceType::kCamera)],
            /*is_microphone_used=*/
            new_notification[static_cast<size_t>(DeviceType::kMic)], delegate,
            PrivacyIndicatorsSource::kLinuxVm);
      } else {
        OpenNotification(new_notification);
      }
    }

    notifications_.active = new_notification;
    notification_changed_callback_.Run();
  }

  // See document at the beginning of class.
  void SyncNotificationAndIndicators() {
    if (notifications_.active != notifications_.stage) {
      UpdateActiveNotificationAndIndicators(notifications_.stage);
      SyncTimer();

      VLOG(1) << "sync from stage. vm_type=" << static_cast<int>(vm_type_)
              << " state: " << notifications_.active << "-"
              << notifications_.stage << "-" << notifications_.target;
      return;
    }

    // Only target notification is different.
    DCHECK_NE(notifications_.active, notifications_.target);
    notifications_.stage = notifications_.target;
    UpdateActiveNotificationAndIndicators(notifications_.target);
    VLOG(1) << "sync from target. vm_type=" << static_cast<int>(vm_type_)
            << " state: " << notifications_.active << "-"
            << notifications_.stage << "-" << notifications_.target;
    // No need to call `SyncTimer()` because we have reached the stable state
    // here.
  }

  void OpenNotification(NotificationType type) const {
    CHECK(features::IsVideoConferenceEnabled());
    CHECK_NE(type, kNoNotification);

    const gfx::VectorIcon* source_icon = nullptr;
    int message_id;
    if (type[static_cast<size_t>(DeviceType::kCamera)]) {
      source_icon = &::vector_icons::kVideocamIcon;
      if (type[static_cast<size_t>(DeviceType::kMic)]) {
        message_id = IDS_APP_USING_CAMERA_MIC_NOTIFICATION_MESSAGE;
      } else {
        message_id = IDS_APP_USING_CAMERA_NOTIFICATION_MESSAGE;
      }
    } else {
      DCHECK_EQ(type, kMicNotification);
      source_icon = &::vector_icons::kMicIcon;
      message_id = IDS_APP_USING_MIC_NOTIFICATION_MESSAGE;
    }

    message_center::RichNotificationData rich_notification_data;
    rich_notification_data.vector_small_image = source_icon;
    rich_notification_data.pinned = true;
    rich_notification_data.buttons.emplace_back(
        l10n_util::GetStringUTF16(IDS_INTERNAL_APP_SETTINGS));
    rich_notification_data.fullscreen_visibility =
        message_center::FullscreenVisibility::OVER_USER;

    message_center::Notification notification(
        message_center::NOTIFICATION_TYPE_SIMPLE,
        GetNotificationId(vm_type_, type),
        /*title=*/
        l10n_util::GetStringFUTF16(message_id,
                                   l10n_util::GetStringUTF16(name_id_)),
        /*message=*/std::u16string(),
        /*icon=*/ui::ImageModel(),
        /*display_source=*/
        l10n_util::GetStringUTF16(IDS_CHROME_OS_NOTIFICATION_SOURCE),
        /*origin_url=*/GURL(),
        message_center::NotifierId(
            message_center::NotifierType::SYSTEM_COMPONENT,
            kVmCameraMicNotifierId, NotificationCatalogName::kVMCameraMic),
        rich_notification_data,
        base::MakeRefCounted<message_center::ThunkNotificationDelegate>(
            weak_ptr_factory_.GetMutableWeakPtr()));

    NotificationDisplayService::GetForProfile(profile_)->Display(
        NotificationHandler::Type::TRANSIENT, notification,
        /*metadata=*/nullptr);
  }

  void CloseNotification(NotificationType type) const {
    CHECK(features::IsVideoConferenceEnabled());
    CHECK_NE(type, kNoNotification);

    NotificationDisplayService::GetForProfile(profile_)->Close(
        NotificationHandler::Type::TRANSIENT,
        GetNotificationId(vm_type_, type));
  }

  // message_center::NotificationObserver:
  //
  // This open the settings page if the button is clicked on the notification.
  void Click(const std::optional<int>& button_index,
             const std::optional<std::u16string>& reply) override {
    OpenSettings();
  }

  // Opens the settings page.
  void OpenSettings() const {
    switch (vm_type_) {
      case VmType::kCrostiniVm:
        chrome::SettingsWindowManager::GetInstance()->ShowOSSettings(
            profile_, chromeos::settings::mojom::kCrostiniDetailsSubpagePath);
        break;
      case VmType::kPluginVm:
        chrome::ShowAppManagementPage(
            profile_, plugin_vm::kPluginVmShelfAppId,
            settings::AppManagementEntryPoint::kNotificationPluginVm);
        break;
      case VmType::kBorealis:
        chrome::ShowAppManagementPage(
            profile_, borealis::kClientAppId,
            settings::AppManagementEntryPoint::kAppManagementMainViewBorealis);
        break;
    }
  }

  const raw_ptr<Profile, LeakedDanglingUntriaged> profile_;
  const VmType vm_type_;
  const int name_id_;
  base::RepeatingClosure notification_changed_callback_;

  bool camera_accessing_ = false;
  // We don't actually need to store this separately for each VM, but this
  // makes code simpler.
  bool camera_privacy_is_on_ = false;

  // See document at the beginning of class.
  struct {
    NotificationType active;
    NotificationType stage;
    NotificationType target;
  } notifications_;

  base::RetainingOneShotTimer debounce_timer_;

  base::WeakPtrFactory<VmInfo> weak_ptr_factory_{this};
};

VmCameraMicManager* VmCameraMicManager::Get() {
  static base::NoDestructor<VmCameraMicManager> manager;
  return manager.get();
}

VmCameraMicManager::VmCameraMicManager() = default;

void VmCameraMicManager::OnPrimaryUserSessionStarted(Profile* primary_profile) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

  primary_profile_ = primary_profile;

  auto emplace_vm_info = [this](VmType vm, int name_id) {
    vm_info_map_.emplace(
        std::piecewise_construct, std::forward_as_tuple(vm),
        std::forward_as_tuple(
            primary_profile_, vm, name_id,
            base::BindRepeating(&VmCameraMicManager::NotifyActiveChanged,
                                base::Unretained(this))));
  };

  emplace_vm_info(VmType::kCrostiniVm, IDS_CROSTINI_LINUX);
  emplace_vm_info(VmType::kPluginVm, IDS_PLUGIN_VM_APP_NAME);
  emplace_vm_info(VmType::kBorealis, IDS_BOREALIS_APP_NAME);

  // Only do the subscription in real ChromeOS environment.
  if (base::SysInfo::IsRunningOnChromeOS()) {
    base::ThreadPool::PostTaskAndReplyWithResult(
        FROM_HERE, {base::MayBlock()},
        base::BindOnce(media::ShouldUseCrosCameraService),
        base::BindOnce(&VmCameraMicManager::MaybeSubscribeToCameraService,
                       base::Unretained(this)));

    CrasAudioHandler::Get()->AddAudioObserver(this);
    // Fetch the current value.
    content::GetUIThreadTaskRunner({})->PostTask(
        FROM_HERE,
        base::BindOnce(
            &VmCameraMicManager::OnNumberOfInputStreamsWithPermissionChanged,
            base::Unretained(this)));
  }
}

// The class is supposed to be used as a singleton with `base::NoDestructor`, so
// we do not do clean up (e.g. deregister as observers) here.
VmCameraMicManager::~VmCameraMicManager() = default;

void VmCameraMicManager::MaybeSubscribeToCameraService(
    bool should_use_cros_camera_service) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

  if (!should_use_cros_camera_service) {
    return;
  }

  auto* camera = media::CameraHalDispatcherImpl::GetInstance();
  // OnActiveClientChange() will be called automatically after the
  // subscription, so there is no need to get the current status here.
  camera->AddActiveClientObserver(this);
  auto privacy_switch_state = cros::mojom::CameraPrivacySwitchState::UNKNOWN;
  auto device_id_to_privacy_switch_state =
      camera->AddCameraPrivacySwitchObserver(this);
  // TODO(b/255249223): Handle multiple cameras with privacy controls properly.
  for (const auto& it : device_id_to_privacy_switch_state) {
    cros::mojom::CameraPrivacySwitchState state = it.second;
    if (state == cros::mojom::CameraPrivacySwitchState::ON) {
      privacy_switch_state = state;
      break;
    } else if (state == cros::mojom::CameraPrivacySwitchState::OFF) {
      privacy_switch_state = state;
    }
  }
  OnCameraHWPrivacySwitchStateChanged(
      /*device_id=*/std::string(), privacy_switch_state);
}

void VmCameraMicManager::UpdateVmInfo(VmType vm,
                                      void (VmInfo::*updator)(bool),
                                      bool value) {
  auto it = vm_info_map_.find(vm);
  CHECK(it != vm_info_map_.end());
  auto& vm_info = it->second;

  (vm_info.*updator)(value);
}

bool VmCameraMicManager::IsDeviceActive(DeviceType device) const {
  for (const auto& vm_info : vm_info_map_) {
    const NotificationType& notification_type =
        vm_info.second.notification_type();
    if (notification_type[static_cast<size_t>(device)]) {
      return true;
    }
  }
  return false;
}

bool VmCameraMicManager::IsNotificationActive(
    NotificationType notification) const {
  for (const auto& vm_info : vm_info_map_) {
    if (vm_info.second.notification_type() == notification) {
      return true;
    }
  }
  return false;
}

void VmCameraMicManager::OnActiveClientChange(
    cros::mojom::CameraClientType type,
    bool is_new_active_client,
    const base::flat_set<std::string>& active_device_ids) {
  // Crostini does not support camera yet.
  bool client_active_state_changed =
      is_new_active_client || active_device_ids.empty();

  if (client_active_state_changed &&
      type == cros::mojom::CameraClientType::PLUGINVM) {
    content::GetUIThreadTaskRunner({})->PostTask(
        FROM_HERE, base::BindOnce(&VmCameraMicManager::SetCameraAccessing,
                                  base::Unretained(this), VmType::kPluginVm,
                                  !active_device_ids.empty()));
  }
}

void VmCameraMicManager::SetCameraAccessing(VmType vm, bool accessing) {
  UpdateVmInfo(vm, &VmInfo::SetCameraAccessing, accessing);
}

void VmCameraMicManager::OnCameraHWPrivacySwitchStateChanged(
    const std::string& device_id,
    cros::mojom::CameraPrivacySwitchState state) {
  using cros::mojom::CameraPrivacySwitchState;
  bool is_on;
  switch (state) {
    case CameraPrivacySwitchState::UNKNOWN:
    case CameraPrivacySwitchState::OFF:
      is_on = false;
      break;
    case CameraPrivacySwitchState::ON:
      is_on = true;
      break;
  }

  content::GetUIThreadTaskRunner({})->PostTask(
      FROM_HERE, base::BindOnce(&VmCameraMicManager::SetCameraPrivacyIsOn,
                                base::Unretained(this), is_on));
}

void VmCameraMicManager::SetCameraPrivacyIsOn(bool is_on) {
  DCHECK(!vm_info_map_.empty());
  for (auto& vm_and_info : vm_info_map_) {
    UpdateVmInfo(/*vm=*/vm_and_info.first, &VmInfo::SetCameraPrivacyIsOn,
                 is_on);
  }
}

void VmCameraMicManager::AddObserver(Observer* observer) {
  observers_.AddObserver(observer);
}

void VmCameraMicManager::RemoveObserver(Observer* observer) {
  observers_.RemoveObserver(observer);
}

void VmCameraMicManager::NotifyActiveChanged() {
  for (Observer& observer : observers_) {
    observer.OnVmCameraMicActiveChanged(this);
  }
}

std::string VmCameraMicManager::GetNotificationId(VmType vm,
                                                  NotificationType type) {
  std::string id = kNotificationIdPrefix;

  switch (vm) {
    case VmType::kCrostiniVm:
      id.append("-crostini");
      break;
    case VmType::kPluginVm:
      id.append("-pluginvm");
      break;
    case VmType::kBorealis:
      id.append("-borealis");
      break;
  }

  id.append(type.to_string());

  return id;
}

void VmCameraMicManager::OnNumberOfInputStreamsWithPermissionChanged() {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

  const auto& clients_and_numbers =
      CrasAudioHandler::Get()->GetNumberOfInputStreamsWithPermission();

  auto update = [&](CrasAudioHandler::ClientType cras_client_type, VmType vm) {
    auto it = clients_and_numbers.find(cras_client_type);
    bool active = (it != clients_and_numbers.end() && it->second != 0);

    SetMicActive(vm, active);
  };

  update(CrasAudioHandler::ClientType::VM_TERMINA, VmType::kCrostiniVm);
  update(CrasAudioHandler::ClientType::VM_PLUGIN, VmType::kPluginVm);
  update(CrasAudioHandler::ClientType::VM_BOREALIS, VmType::kBorealis);
}

void VmCameraMicManager::SetMicActive(VmType vm, bool active) {
  UpdateVmInfo(vm, &VmInfo::SetMicActive, active);
}

}  // namespace ash