// 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