chromium/chromeos/ash/components/audio/audio_selection_notification_handler.cc

// 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_IN_MIGRATION();
  }

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