| // Copyright 2024 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chromeos/ash/components/audio/audio_selection_notification_handler.h" |
| |
| #include <cstring> |
| #include <optional> |
| |
| #include "ash/strings/grit/ash_strings.h" |
| #include "base/functional/bind.h" |
| #include "base/notreached.h" |
| #include "base/strings/string_split.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "chromeos/ash/components/audio/audio_device.h" |
| #include "chromeos/ash/components/audio/cras_audio_handler.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/message_center/message_center.h" |
| #include "ui/message_center/public/cpp/notification.h" |
| #include "ui/message_center/public/cpp/notification_delegate.h" |
| #include "ui/message_center/vector_icons.h" |
| |
| namespace ash { |
| |
| namespace { |
| |
| // Separator used to split the audio device name. |
| constexpr char kAudioDeviceNameSeparator[] = ":"; |
| |
| // Extracts the audio device source name of an audio device. e.g. the source |
| // name for "Razer USB Sound Card: USB Audio:2,0: Mic" would be "Razer USB Sound |
| // Card". The source name for "Airpods" would be "Airpods". |
| std::string ExtractDeviceSourceName(const AudioDevice& device) { |
| std::vector<std::string> parts = |
| base::SplitString(device.display_name, kAudioDeviceNameSeparator, |
| base::TRIM_WHITESPACE, base::SPLIT_WANT_ALL); |
| return parts.front(); |
| } |
| |
| // Checks if a given device is in a device list |
| bool IsDeviceInList(const AudioDevice& target_device, |
| const AudioDeviceList& device_list) { |
| for (const AudioDevice& device : device_list) { |
| if (target_device.stable_device_id == device.stable_device_id) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| } // namespace |
| |
| AudioSelectionNotificationHandler::AudioSelectionNotificationHandler() = |
| default; |
| AudioSelectionNotificationHandler::~AudioSelectionNotificationHandler() = |
| default; |
| |
| AudioSelectionNotificationHandler::NotificationTemplate::NotificationTemplate( |
| NotificationType type, |
| std::optional<std::string> input_device_name, |
| std::optional<std::string> output_device_name) |
| : type(type), |
| input_device_name(input_device_name), |
| output_device_name(output_device_name) {} |
| |
| AudioSelectionNotificationHandler::NotificationTemplate:: |
| ~NotificationTemplate() = default; |
| |
| bool AudioSelectionNotificationHandler::AudioNodesBelongToSameSource( |
| const AudioDevice& input_device, |
| const AudioDevice& output_device) { |
| CHECK(input_device.is_input); |
| CHECK(!output_device.is_input); |
| |
| // Handle internal audio device. |
| if ((input_device.type == AudioDeviceType::kInternalMic || |
| input_device.type == AudioDeviceType::kFrontMic || |
| input_device.type == AudioDeviceType::kRearMic) && |
| output_device.type == AudioDeviceType::kInternalSpeaker) { |
| return true; |
| } |
| |
| // Handle special cases where input and output device are the same source but |
| // different device types. kMic and kHeadphone are the types for 3.5mm jack |
| // headphone's input and output. Similarly, kBluetoothNbMic and kBluetooth are |
| // the types for a bluetooth device. |
| if ((input_device.type == AudioDeviceType::kMic && |
| output_device.type == AudioDeviceType::kHeadphone) || |
| (input_device.type == AudioDeviceType::kBluetoothNbMic && |
| output_device.type == AudioDeviceType::kBluetooth)) { |
| return true; |
| } |
| |
| // For other devices, different device types indicate different device |
| // sources. |
| if (input_device.type != output_device.type) { |
| return false; |
| } |
| |
| // With same device type, checks their device source name. |
| return ExtractDeviceSourceName(input_device) |
| .compare(ExtractDeviceSourceName(output_device)) == 0; |
| } |
| |
| void AudioSelectionNotificationHandler::ShowAudioSelectionNotification( |
| const AudioDeviceList& hotplug_input_devices, |
| const AudioDeviceList& hotplug_output_devices, |
| const std::optional<std::string>& active_input_device_name, |
| const std::optional<std::string>& active_output_device_name, |
| SwitchToDeviceCallback switch_to_device_callback, |
| OpenSettingsAudioPageCallback open_settings_audio_page_callback) { |
| // At least input or output has hotplug device. |
| CHECK(!hotplug_input_devices.empty() || !hotplug_output_devices.empty()); |
| |
| // If show_notification callback is already in the queue, stop it and append |
| // new hot plugged devices to the existing list, so that the notification can |
| // handle them together. Otherwise, reset the existing hot plugged list. |
| if (show_notification_debounce_timer_.IsRunning()) { |
| show_notification_debounce_timer_.Stop(); |
| for (const AudioDevice& device : hotplug_input_devices) { |
| hotplug_input_devices_.push_back(device); |
| } |
| for (const AudioDevice& device : hotplug_output_devices) { |
| hotplug_output_devices_.push_back(device); |
| } |
| } else { |
| hotplug_input_devices_.clear(); |
| hotplug_input_devices_ = hotplug_input_devices; |
| hotplug_output_devices_.clear(); |
| hotplug_output_devices_ = hotplug_output_devices; |
| } |
| |
| show_notification_debounce_timer_.Start( |
| FROM_HERE, kDebounceTime, |
| base::BindRepeating(&AudioSelectionNotificationHandler::ShowNotification, |
| weak_ptr_factory_.GetWeakPtr(), |
| active_input_device_name, active_output_device_name, |
| switch_to_device_callback, |
| open_settings_audio_page_callback)); |
| } |
| |
| void AudioSelectionNotificationHandler::ShowNotification( |
| const std::optional<std::string>& active_input_device_name, |
| const std::optional<std::string>& active_output_device_name, |
| SwitchToDeviceCallback switch_to_device_callback, |
| OpenSettingsAudioPageCallback open_settings_audio_page_callback) { |
| AudioDeviceList devices_to_activate; |
| std::u16string title_message_id; |
| std::u16string body_message_id; |
| std::vector<message_center::ButtonInfo> buttons_info; |
| AudioDeviceMetricsHandler::AudioSelectionNotificationEvents |
| notification_event; |
| |
| NotificationTemplate notification_template = GetNotificationTemplate( |
| hotplug_input_devices_, hotplug_output_devices_, active_input_device_name, |
| active_output_device_name); |
| |
| // Use different notification titles and messages based on notification types. |
| switch (notification_template.type) { |
| case NotificationType::kSingleSourceWithInputOnly: |
| title_message_id = |
| l10n_util::GetStringUTF16(IDS_ASH_AUDIO_SELECTION_SWITCH_INPUT_TITLE); |
| body_message_id = l10n_util::GetStringFUTF16( |
| IDS_ASH_AUDIO_SELECTION_SWITCH_INPUT_OR_OUTPUT_BODY, |
| base::UTF8ToUTF16(hotplug_input_devices_.front().display_name)); |
| devices_to_activate.push_back(hotplug_input_devices_.front()); |
| buttons_info.emplace_back( |
| l10n_util::GetStringUTF16(IDS_ASH_AUDIO_SELECTION_BUTTON_SWITCH)); |
| notification_event = |
| AudioDeviceMetricsHandler::AudioSelectionNotificationEvents:: |
| kNotificationWithInputOnlyDeviceShowsUp; |
| break; |
| case NotificationType::kSingleSourceWithOutputOnly: |
| title_message_id = l10n_util::GetStringUTF16( |
| IDS_ASH_AUDIO_SELECTION_SWITCH_OUTPUT_TITLE); |
| body_message_id = l10n_util::GetStringFUTF16( |
| IDS_ASH_AUDIO_SELECTION_SWITCH_INPUT_OR_OUTPUT_BODY, |
| base::UTF8ToUTF16(hotplug_output_devices_.front().display_name)); |
| devices_to_activate.push_back(hotplug_output_devices_.front()); |
| buttons_info.emplace_back( |
| l10n_util::GetStringUTF16(IDS_ASH_AUDIO_SELECTION_BUTTON_SWITCH)); |
| notification_event = |
| AudioDeviceMetricsHandler::AudioSelectionNotificationEvents:: |
| kNotificationWithOutputOnlyDeviceShowsUp; |
| break; |
| case NotificationType::kSingleSourceWithInputAndOutput: |
| title_message_id = l10n_util::GetStringUTF16( |
| IDS_ASH_AUDIO_SELECTION_SWITCH_SOURCE_TITLE); |
| body_message_id = l10n_util::GetStringFUTF16( |
| IDS_ASH_AUDIO_SELECTION_SWITCH_INPUT_AND_OUTPUT_BODY, |
| base::UTF8ToUTF16( |
| ExtractDeviceSourceName(hotplug_output_devices_.front()))); |
| devices_to_activate.push_back(hotplug_input_devices_.front()); |
| devices_to_activate.push_back(hotplug_output_devices_.front()); |
| buttons_info.emplace_back( |
| l10n_util::GetStringUTF16(IDS_ASH_AUDIO_SELECTION_BUTTON_SWITCH)); |
| notification_event = |
| AudioDeviceMetricsHandler::AudioSelectionNotificationEvents:: |
| kNotificationWithBothInputAndOutputDevicesShowsUp; |
| break; |
| case NotificationType::kMultipleSources: |
| title_message_id = l10n_util::GetStringUTF16( |
| IDS_ASH_AUDIO_SELECTION_MULTIPLE_DEVICES_TITLE); |
| body_message_id = |
| active_input_device_name.has_value() && |
| active_output_device_name.has_value() |
| ? l10n_util::GetStringFUTF16( |
| IDS_ASH_AUDIO_SELECTION_MULTIPLE_DEVICES_BODY, |
| base::UTF8ToUTF16(active_input_device_name.value()), |
| base::UTF8ToUTF16(active_output_device_name.value())) |
| : l10n_util::GetStringUTF16( |
| IDS_ASH_AUDIO_SELECTION_MULTIPLE_DEVICES_BODY_WITH_NAME_UNAVAILABLE); |
| buttons_info.emplace_back( |
| l10n_util::GetStringUTF16(IDS_ASH_AUDIO_SELECTION_BUTTON_SETTINGS)); |
| notification_event = |
| AudioDeviceMetricsHandler::AudioSelectionNotificationEvents:: |
| kNotificationWithMultipleSourcesDevicesShowsUp; |
| break; |
| } |
| |
| audio_device_metrics_handler_.RecordNotificationEvents(notification_event); |
| |
| message_center::RichNotificationData optional_fields; |
| optional_fields.buttons = buttons_info; |
| |
| // If notification type is kMultipleSources, show Settings button and pass |
| // HandleSettingsButtonClicked function. |
| auto notification_delegate = |
| notification_template.type == NotificationType::kMultipleSources |
| ? base::BindRepeating( |
| &AudioSelectionNotificationHandler::HandleSettingsButtonClicked, |
| weak_ptr_factory_.GetWeakPtr(), |
| open_settings_audio_page_callback) |
| : base::BindRepeating( |
| &AudioSelectionNotificationHandler::HandleSwitchButtonClicked, |
| weak_ptr_factory_.GetWeakPtr(), devices_to_activate, |
| switch_to_device_callback, notification_template.type); |
| |
| message_center::Notification notification{ |
| /*type=*/message_center::NOTIFICATION_TYPE_SIMPLE, |
| /*id=*/kAudioSelectionNotificationId, |
| /*title=*/title_message_id, |
| /*message=*/body_message_id, ui::ImageModel(), |
| /*display_source=*/ |
| l10n_util::GetStringUTF16(IDS_ASH_AUDIO_SELECTION_SOURCE), |
| /*origin_url=*/GURL(), |
| /*notifier_id=*/ |
| message_center::NotifierId(message_center::NotifierType::SYSTEM_COMPONENT, |
| kAudioSelectionNotifierId, |
| NotificationCatalogName::kAudioSelection), |
| optional_fields, |
| /*delegate=*/ |
| base::MakeRefCounted<message_center::HandleNotificationClickDelegate>( |
| notification_delegate)}; |
| auto* message_center = message_center::MessageCenter::Get(); |
| message_center->RemoveNotification(notification.id(), |
| /*by_user=*/false); |
| message_center->AddNotification( |
| std::make_unique<message_center::Notification>(notification)); |
| } |
| |
| void AudioSelectionNotificationHandler::HandleSwitchButtonClicked( |
| const AudioDeviceList& devices_to_activate, |
| SwitchToDeviceCallback switch_to_device_callback, |
| NotificationType notification_type, |
| std::optional<int> button_index) { |
| if (!button_index.has_value()) { |
| // Do not do anything when notification body is clicked. If the button is |
| // clicked, the button_index will have a value. |
| return; |
| } |
| |
| switch (notification_type) { |
| case NotificationType::kSingleSourceWithInputOnly: |
| audio_device_metrics_handler_.RecordNotificationEvents( |
| AudioDeviceMetricsHandler::AudioSelectionNotificationEvents:: |
| kNotificationWithInputOnlyDeviceClicked); |
| break; |
| case NotificationType::kSingleSourceWithOutputOnly: |
| audio_device_metrics_handler_.RecordNotificationEvents( |
| AudioDeviceMetricsHandler::AudioSelectionNotificationEvents:: |
| kNotificationWithOutputOnlyDeviceClicked); |
| break; |
| case NotificationType::kSingleSourceWithInputAndOutput: |
| audio_device_metrics_handler_.RecordNotificationEvents( |
| AudioDeviceMetricsHandler::AudioSelectionNotificationEvents:: |
| kNotificationWithBothInputAndOutputDevicesClicked); |
| break; |
| case NotificationType::kMultipleSources: |
| // Do not record in this case. When the notification type is |
| // kMultipleSources, notification with settings button should display. |
| NOTREACHED(); |
| } |
| |
| // Activate audio devices. |
| for (const AudioDevice& device : devices_to_activate) { |
| switch_to_device_callback.Run(device, /*notify=*/true, |
| DeviceActivateType::kActivateByUser); |
| } |
| |
| // Remove notification and hotplug_input/output_devices_. |
| auto* message_center = message_center::MessageCenter::Get(); |
| message_center->RemoveNotification(kAudioSelectionNotificationId, |
| /*by_user=*/true); |
| hotplug_input_devices_.clear(); |
| hotplug_output_devices_.clear(); |
| } |
| |
| void AudioSelectionNotificationHandler::HandleSettingsButtonClicked( |
| base::RepeatingCallback<void()> open_settigns_callback, |
| std::optional<int> button_index) { |
| if (!button_index.has_value()) { |
| // Do not do anything when notification body is clicked. If the button is |
| // clicked, the button_index will have a value. |
| return; |
| } |
| |
| audio_device_metrics_handler_.RecordNotificationEvents( |
| AudioDeviceMetricsHandler::AudioSelectionNotificationEvents:: |
| kNotificationWithMultipleSourcesDevicesClicked); |
| |
| // Open OS Settings audio page. |
| open_settigns_callback.Run(); |
| |
| // Remove notification. |
| auto* message_center = message_center::MessageCenter::Get(); |
| message_center->RemoveNotification(kAudioSelectionNotificationId, |
| /*by_user=*/true); |
| } |
| |
| AudioSelectionNotificationHandler::NotificationTemplate |
| AudioSelectionNotificationHandler::GetNotificationTemplate( |
| const AudioDeviceList& hotplug_input_devices, |
| const AudioDeviceList& hotplug_output_devices, |
| const std::optional<std::string>& active_input_device_name, |
| const std::optional<std::string>& active_output_device_name) { |
| // There must be either hotplug input devices, or hotplug output devices, or |
| // both. Otherwise, this function shouldn't be called. |
| CHECK(!hotplug_input_devices.empty() || !hotplug_output_devices.empty()); |
| |
| // Hot plugged devices that are used to determine notification type must be |
| // simple usage audio devices. |
| for (const AudioDevice& device : hotplug_input_devices) { |
| CHECK(device.is_for_simple_usage()); |
| } |
| for (const AudioDevice& device : hotplug_output_devices) { |
| CHECK(device.is_for_simple_usage()); |
| } |
| |
| // If there are more than one input devices, or more than one output devices, |
| // they must come from multiple audio sources. |
| if (hotplug_input_devices.size() > 1 || hotplug_output_devices.size() > 1) { |
| return { |
| AudioSelectionNotificationHandler::NotificationType::kMultipleSources, |
| active_input_device_name, active_output_device_name}; |
| } |
| |
| CHECK(hotplug_input_devices.size() <= 1 && |
| hotplug_output_devices.size() <= 1); |
| |
| // If there is exactly one input and one output device, check if they belong |
| // to the same source. |
| if (hotplug_input_devices.size() == 1 && hotplug_output_devices.size() == 1) { |
| if (AudioSelectionNotificationHandler::AudioNodesBelongToSameSource( |
| hotplug_input_devices.front(), hotplug_output_devices.front())) { |
| return {AudioSelectionNotificationHandler::NotificationType:: |
| kSingleSourceWithInputAndOutput, |
| hotplug_input_devices.front().display_name, |
| hotplug_output_devices.front().display_name}; |
| } else { |
| return { |
| AudioSelectionNotificationHandler::NotificationType::kMultipleSources, |
| active_input_device_name, active_output_device_name}; |
| } |
| } |
| |
| CHECK(hotplug_input_devices.size() + hotplug_output_devices.size() == 1); |
| |
| // If there is exactly one input device and no output device, it's |
| // kSingleSourceWithInputOnly notification type. |
| if (hotplug_input_devices.size() == 1) { |
| return {AudioSelectionNotificationHandler::NotificationType:: |
| kSingleSourceWithInputOnly, |
| hotplug_input_devices.front().display_name, std::nullopt}; |
| } |
| |
| // If there is exactly one output device and no input device, it's |
| // kSingleSourceWithOutputOnly notification type. |
| return {AudioSelectionNotificationHandler::NotificationType:: |
| kSingleSourceWithOutputOnly, |
| std::nullopt, hotplug_output_devices.front().display_name}; |
| } |
| |
| void AudioSelectionNotificationHandler:: |
| RemoveNotificationIfHotpluggedDeviceActivated( |
| const AudioDeviceList& activated_devices) { |
| const AudioDeviceList& hotplug_devices = activated_devices.front().is_input |
| ? hotplug_input_devices_ |
| : hotplug_output_devices_; |
| for (const AudioDevice& device : activated_devices) { |
| if (IsDeviceInList(device, hotplug_devices)) { |
| // Remove notification and hotplug_input/output_devices_. |
| auto* message_center = message_center::MessageCenter::Get(); |
| message_center->RemoveNotification(kAudioSelectionNotificationId, |
| /*by_user=*/false); |
| |
| hotplug_input_devices_.clear(); |
| hotplug_output_devices_.clear(); |
| return; |
| } |
| } |
| } |
| |
| void AudioSelectionNotificationHandler:: |
| RemoveNotificationIfHotpluggedDeviceDisconnected( |
| bool is_input, |
| const AudioDeviceList& current_devices) { |
| const AudioDeviceList& hotplug_devices = |
| is_input ? hotplug_input_devices_ : hotplug_output_devices_; |
| |
| // If hotplugged devices that trigger the notification does not exist in |
| // current devices, remove the notification. |
| for (const AudioDevice& device : hotplug_devices) { |
| if (!IsDeviceInList(device, current_devices)) { |
| // Remove notification and hotplug_input/output_devices_. |
| auto* message_center = message_center::MessageCenter::Get(); |
| message_center->RemoveNotification(kAudioSelectionNotificationId, |
| /*by_user=*/false); |
| |
| hotplug_input_devices_.clear(); |
| hotplug_output_devices_.clear(); |
| return; |
| } |
| } |
| } |
| |
| } // namespace ash |