chromium/ash/capture_mode/capture_mode_settings_view.cc

// Copyright 2021 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/capture_mode/capture_mode_settings_view.h"

#include <memory>
#include <string>

#include "ash/capture_mode/capture_mode_bar_view.h"
#include "ash/capture_mode/capture_mode_constants.h"
#include "ash/capture_mode/capture_mode_controller.h"
#include "ash/capture_mode/capture_mode_menu_toggle_button.h"
#include "ash/capture_mode/capture_mode_metrics.h"
#include "ash/capture_mode/capture_mode_session.h"
#include "ash/capture_mode/capture_mode_session_focus_cycler.h"
#include "ash/capture_mode/capture_mode_types.h"
#include "ash/capture_mode/capture_mode_util.h"
#include "ash/constants/ash_features.h"
#include "ash/public/cpp/style/color_provider.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/screen_util.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/icon_button.h"
#include "ash/style/system_shadow.h"
#include "base/files/file_path.h"
#include "base/functional/bind.h"
#include "chromeos/constants/chromeos_features.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/compositor/layer.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/views/background.h"
#include "ui/views/controls/separator.h"
#include "ui/views/highlight_border.h"
#include "ui/views/layout/box_layout.h"

namespace ash {

namespace {

constexpr int kCornerRadius = 10;
constexpr gfx::RoundedCornersF kRoundedCorners{kCornerRadius};

constexpr gfx::Size kSettingsSize{266, 248};

// Returns the bounds of the settings widget in screen coordinates relative to
// the bounds of the `bar_view` based on its given preferred
// `settings_size`, which should be centered with respect to the capture
// bar.
//
// The bounds priority works as follows:
// - If there is enough space above the bar view, we will show the menu at its
//   full height.
// - Otherwise, we will choose between showing above or below the bar,
//   whichever has more space. The available space only includes the work area,
//   as we do not want to show the menu on top of or behind the shelf.
// - If necessary, we will also constrain the height of the menu, up to
//   `capture_mode::kSettingsMenuMinHeight`.
gfx::Rect GetWidgetBounds(CaptureModeBarView* bar_view,
                          const gfx::Size& settings_size) {
  const int width = settings_size.width();
  const int pref_height = settings_size.height();

  const gfx::Rect bar_bounds = bar_view->GetBoundsInScreen();
  const int x = bar_bounds.CenterPoint().x() - width / 2.f;
  int menu_bottom =
      bar_bounds.y() - capture_mode::kSpaceBetweenCaptureBarAndSettingsMenu;
  int y = menu_bottom - pref_height;

  // Showing the menu above the bar at full height is our priority, but this may
  // change if it is too close to the top of the screen.
  if (y < capture_mode::kMinDistanceFromSettingsToScreen) {
    const gfx::Rect work_area =
        screen_util::GetDisplayWorkAreaBoundsInScreenForActiveDeskContainer(
            bar_view->GetWidget()->GetNativeWindow());
    const int available_above = menu_bottom -
                                capture_mode::kMinDistanceFromSettingsToScreen -
                                work_area.y();
    const int available_below =
        work_area.y() + work_area.height() -
        capture_mode::kMinDistanceFromSettingsToScreen -
        capture_mode::kSpaceBetweenCaptureBarAndSettingsMenu - bar_bounds.y() -
        bar_bounds.height();

    // We want to show the menu on the side of the bar that has more space.
    if (available_above >= available_below) {
      y = std::max(
          bar_bounds.y() -
              capture_mode::kSpaceBetweenCaptureBarAndSettingsMenu -
              pref_height,
          work_area.y() + capture_mode::kMinDistanceFromSettingsToScreen);
      menu_bottom =
          bar_bounds.y() - capture_mode::kSpaceBetweenCaptureBarAndSettingsMenu;
    } else {
      y = bar_bounds.bottom() +
          capture_mode::kSpaceBetweenCaptureBarAndSettingsMenu;
      menu_bottom = std::min(
          y + pref_height,
          work_area.bottom() - capture_mode::kMinDistanceFromSettingsToScreen);
    }
  }

  return gfx::Rect(
      x, y, width,
      std::max(capture_mode::kSettingsMenuMinHeight, menu_bottom - y));
}

CaptureModeController::CaptureFolder GetCurrentCaptureFolder() {
  return CaptureModeController::Get()->GetCurrentCaptureFolder();
}

}  // namespace

CaptureModeSettingsView::CaptureModeSettingsView(
    CaptureModeSession* session,
    CaptureModeBehavior* active_behavior)
    : ScrollView(views::ScrollView::ScrollWithLayers::kEnabled),
      capture_mode_session_(session),
      active_behavior_(active_behavior),
      shadow_(SystemShadow::CreateShadowOnNinePatchLayerForView(
          this,
          SystemShadow::Type::kElevation12)) {
  auto* controller = CaptureModeController::Get();

  SetContents(std::make_unique<views::View>());

  if (controller->can_start_new_recording()) {
    const bool audio_capture_managed_by_policy =
        controller->IsAudioCaptureDisabledByPolicy();

    DCHECK(
        !audio_capture_managed_by_policy ||
        active_behavior->SupportsAudioRecordingMode(AudioRecordingMode::kOff))
        << "A client session should not be allowed to begin if audio "
           "recording is diabled by policy.";

    audio_input_menu_group_ =
        contents()->AddChildView(std::make_unique<CaptureModeMenuGroup>(
            this, kCaptureModeMicIcon,
            l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_AUDIO_INPUT),
            audio_capture_managed_by_policy));

    // A list of all the possible audio options.
    struct {
      // The backend audio recording mode for this option.
      AudioRecordingMode audio_recording_mode;
      // The ID of this menu group option.
      int option_id;
      // The ID of the string that will be used for the option's label.
      int string_id;
      // True if the option can be added if audio recording is managed by an
      // admin policy.
      bool add_if_managed_by_policy;
    } kAudioOptions[] = {
        {AudioRecordingMode::kOff, kAudioOff,
         IDS_ASH_SCREEN_CAPTURE_AUDIO_INPUT_OFF,
         /*add_if_managed_by_policy=*/true},
        {AudioRecordingMode::kSystem, kAudioSystem,
         IDS_ASH_SCREEN_CAPTURE_AUDIO_INPUT_SYSTEM,
         /*add_if_managed_by_policy=*/false},
        {AudioRecordingMode::kMicrophone, kAudioMicrophone,
         IDS_ASH_SCREEN_CAPTURE_AUDIO_INPUT_MICROPHONE,
         /*add_if_managed_by_policy=*/false},
        {AudioRecordingMode::kSystemAndMicrophone, kAudioSystemAndMicrophone,
         IDS_ASH_SCREEN_CAPTURE_AUDIO_INPUT_SYSTEM_AND_MICROPHONE,
         /*add_if_managed_by_policy=*/false},
    };

    for (const auto& audio_option : kAudioOptions) {
      if ((!audio_capture_managed_by_policy ||
           audio_option.add_if_managed_by_policy) &&
          active_behavior->SupportsAudioRecordingMode(
              audio_option.audio_recording_mode)) {
        audio_input_menu_group_->AddOption(
            /*option_icon=*/nullptr,
            l10n_util::GetStringUTF16(audio_option.string_id),
            audio_option.option_id);
      }
    }

    separator_1_ =
        contents()->AddChildView(std::make_unique<views::Separator>());
    separator_1_->SetColorId(ui::kColorAshSystemUIMenuSeparator);
    auto* camera_controller = controller->camera_controller();
    const bool camera_managed_by_policy =
        camera_controller->IsCameraDisabledByPolicy();
    // Even if the camera feature is managed by policy, we still want to observe
    // the camera controller, since we need to be notified with camera additions
    // and removals, which affect the visibility of the `camera_menu_group_`.
    camera_controller->AddObserver(this);
    camera_menu_group_ =
        contents()->AddChildView(std::make_unique<CaptureModeMenuGroup>(
            this, kCaptureModeCameraIcon,
            l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_CAMERA),
            camera_managed_by_policy));

    AddCameraOptions(camera_controller->available_cameras(),
                     camera_managed_by_policy);
  }

  if (controller->can_start_new_recording()) {
    separator_2_ =
        contents()->AddChildView(std::make_unique<views::Separator>());
    separator_2_->SetColorId(ui::kColorAshSystemUIMenuSeparator);
    demo_tools_menu_toggle_button_ =
        contents()->AddChildView(std::make_unique<CaptureModeMenuToggleButton>(
            kCaptureModeDemoToolsSettingsMenuEntryPointIcon,
            l10n_util::GetStringUTF16(
                IDS_ASH_SCREEN_CAPTURE_DEMO_TOOLS_SHOW_CLICKS_AND_KEYS),
            CaptureModeController::Get()->enable_demo_tools(),
            base::BindRepeating(
                &CaptureModeSettingsView::OnDemoToolsButtonToggled,
                base::Unretained(this))));
  }

  if (active_behavior->ShouldSaveToSettingsBeIncluded()) {
    separator_3_ =
        contents()->AddChildView(std::make_unique<views::Separator>());
    separator_3_->SetColorId(ui::kColorAshSystemUIMenuSeparator);

    const bool custom_folder_managed_by_policy =
        controller->IsCustomFolderManagedByPolicy();
    save_to_menu_group_ =
        contents()->AddChildView(std::make_unique<CaptureModeMenuGroup>(
            this, kCaptureModeFolderIcon,
            l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_SAVE_TO),
            /*enabled=*/custom_folder_managed_by_policy));
    save_to_menu_group_->AddOption(
        /*option_icon=*/nullptr,
        l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_SAVE_TO_DOWNLOADS),
        kDownloadsFolder);
    save_to_menu_group_->AddMenuItem(
        base::BindRepeating(
            &CaptureModeSettingsView::OnSelectFolderMenuItemPressed,
            base::Unretained(this)),
        l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_SAVE_TO_SELECT_FOLDER),
        /*enabled=*/!custom_folder_managed_by_policy);
  }

  SetBackground(views::CreateThemedSolidBackground(kColorAshShieldAndBase80));
  layer()->SetFillsBoundsOpaquely(false);
  layer()->SetRoundedCornerRadius(kRoundedCorners);
  layer()->SetBackgroundBlur(ColorProvider::kBackgroundBlurSigma);
  layer()->SetBackdropFilterQuality(ColorProvider::kBackgroundBlurQuality);

  // The options should appear vertically stacked on top of each other.
  contents()->SetLayoutManager(std::make_unique<views::BoxLayout>(
      views::BoxLayout::Orientation::kVertical));

  capture_mode_util::SetHighlightBorder(
      this, kCornerRadius,
      views::HighlightBorder::Type::kHighlightBorderOnShadow);

  shadow_->SetRoundedCornerRadius(kCornerRadius);
}

CaptureModeSettingsView::~CaptureModeSettingsView() {
  CaptureModeController::Get()->camera_controller()->RemoveObserver(this);
}

// static
gfx::Rect CaptureModeSettingsView::GetBounds(
    CaptureModeBarView* capture_mode_bar_view,
    CaptureModeSettingsView* settings_view) {
  DCHECK(capture_mode_bar_view);

  const gfx::Size settings_size =
      settings_view ? settings_view->GetPreferredSize() : kSettingsSize;
  return GetWidgetBounds(capture_mode_bar_view, settings_size);
}

void CaptureModeSettingsView::OnCaptureFolderMayHaveChanged() {
  if (!save_to_menu_group_)
    return;
  auto* controller = CaptureModeController::Get();
  const auto custom_path = controller->GetCustomCaptureFolder();
  if (custom_path.empty()) {
    is_custom_folder_available_.reset();
    save_to_menu_group_->RemoveOptionIfAny(kCustomFolder);
    save_to_menu_group_->RefreshOptionsSelections();
    return;
  }

  std::u16string folder_name = custom_path.BaseName().AsUTF16Unsafe();
  // We explicitly name the folders of Google Drive and Play files, since those
  // folders internally may have user-unfriendly names.
  if (controller->IsRootDriveFsPath(custom_path)) {
    folder_name =
        l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_SAVE_TO_GOOGLE_DRIVE);
  } else if (controller->IsAndroidFilesPath(custom_path)) {
    folder_name =
        l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_SAVE_TO_ANDROID_FILES);
  } else if (controller->IsLinuxFilesPath(custom_path)) {
    folder_name =
        l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_SAVE_TO_LINUX_FILES);
  } else if (controller->IsRootOneDriveFilesPath(custom_path)) {
    folder_name =
        l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_SAVE_TO_ONE_DRIVE);
  }

  save_to_menu_group_->AddOrUpdateExistingOption(
      /*option_icon=*/nullptr, folder_name, kCustomFolder);

  controller->CheckFolderAvailability(
      custom_path,
      base::BindOnce(
          &CaptureModeSettingsView::OnCustomFolderAvailabilityChecked,
          weak_ptr_factory_.GetWeakPtr()));
}

void CaptureModeSettingsView::OnDefaultCaptureFolderSelectionChanged() {
  if (save_to_menu_group_)
    save_to_menu_group_->RefreshOptionsSelections();
}

std::vector<CaptureModeSessionFocusCycler::HighlightableView*>
CaptureModeSettingsView::GetHighlightableItems() {
  std::vector<CaptureModeSessionFocusCycler::HighlightableView*>
      highlightable_items;
  DCHECK(audio_input_menu_group_);
  audio_input_menu_group_->AppendHighlightableItems(highlightable_items);
  DCHECK(camera_menu_group_);
  camera_menu_group_->AppendHighlightableItems(highlightable_items);

  if (demo_tools_menu_toggle_button_) {
    highlightable_items.push_back(
        CaptureModeSessionFocusCycler::HighlightHelper::Get(
            demo_tools_menu_toggle_button_->toggle_button()));
  }

  if (save_to_menu_group_) {
    save_to_menu_group_->AppendHighlightableItems(highlightable_items);
  }

  return highlightable_items;
}

void CaptureModeSettingsView::OnOptionSelected(int option_id) const {
  auto* controller = CaptureModeController::Get();
  auto* camera_controller = controller->camera_controller();
  switch (option_id) {
    case kAudioOff:
      controller->SetAudioRecordingMode(AudioRecordingMode::kOff);
      break;
    case kAudioSystem:
      controller->SetAudioRecordingMode(AudioRecordingMode::kSystem);
      break;
    case kAudioMicrophone:
      controller->SetAudioRecordingMode(AudioRecordingMode::kMicrophone);
      break;
    case kAudioSystemAndMicrophone:
      controller->SetAudioRecordingMode(
          AudioRecordingMode::kSystemAndMicrophone);
      break;
    case kDownloadsFolder:
      controller->SetUsesDefaultCaptureFolder(true);
      RecordSwitchToDefaultFolderReason(
          CaptureModeSwitchToDefaultReason::kUserSelectedFromSettingsMenu);
      break;
    case kCustomFolder:
      controller->SetUsesDefaultCaptureFolder(false);
      break;
    case kCameraOff:
      camera_controller->SetSelectedCamera(CameraId(), /*by_user=*/true);
      break;
    default:
      DCHECK(!camera_controller->IsCameraDisabledByPolicy());
      DCHECK_GE(option_id, kCameraDevicesBegin);
      const CameraId* camera_id = FindCameraIdByOptionId(option_id);
      DCHECK(camera_id);
      camera_controller->SetSelectedCamera(*camera_id, /*by_user=*/true);
      break;
  }
}

bool CaptureModeSettingsView::IsOptionChecked(int option_id) const {
  auto* controller = CaptureModeController::Get();
  auto* camera_controller = controller->camera_controller();
  const auto effective_audio_mode =
      controller->GetEffectiveAudioRecordingMode();
  switch (option_id) {
    case kAudioOff:
      return effective_audio_mode == AudioRecordingMode::kOff;
    case kAudioSystem:
      return effective_audio_mode == AudioRecordingMode::kSystem;
    case kAudioMicrophone:
      return effective_audio_mode == AudioRecordingMode::kMicrophone;
    case kAudioSystemAndMicrophone:
      return effective_audio_mode == AudioRecordingMode::kSystemAndMicrophone;
    case kDownloadsFolder:
      return GetCurrentCaptureFolder().is_default_downloads_folder ||
             !is_custom_folder_available_.value_or(false);
    case kCustomFolder:
      return !GetCurrentCaptureFolder().is_default_downloads_folder &&
             is_custom_folder_available_.value_or(false);
    case kCameraOff:
      return !camera_controller->selected_camera().is_valid();
    default:
      DCHECK(!camera_controller->IsCameraDisabledByPolicy());
      DCHECK_GE(option_id, kCameraDevicesBegin);
      const CameraId* camera_id = FindCameraIdByOptionId(option_id);
      DCHECK(camera_id);
      return *camera_id == camera_controller->selected_camera();
  }
}

bool CaptureModeSettingsView::IsOptionEnabled(int option_id) const {
  auto* controller = CaptureModeController::Get();
  const bool audio_capture_managed_by_policy =
      controller->IsAudioCaptureDisabledByPolicy();
  switch (option_id) {
    case kAudioOff:
      return !audio_capture_managed_by_policy &&
             active_behavior_->SupportsAudioRecordingMode(
                 AudioRecordingMode::kOff);
    case kAudioSystem:
    case kAudioMicrophone:
    case kAudioSystemAndMicrophone:
      return !audio_capture_managed_by_policy;
    case kCustomFolder:
      return is_custom_folder_available_.value_or(false);
    case kCameraOff: {
      auto* camera_controller = controller->camera_controller();
      DCHECK(camera_controller);
      return !camera_controller->IsCameraDisabledByPolicy();
    }
    case kDownloadsFolder:
      return !controller->IsCustomFolderManagedByPolicy();
    default:
      return true;
  }
}

void CaptureModeSettingsView::OnAvailableCamerasChanged(
    const CameraInfoList& cameras) {
  auto* controller = CaptureModeController::Get();
  DCHECK(!controller->is_recording_in_progress());
  DCHECK(camera_menu_group_);
  auto* camera_controller = controller->camera_controller();
  DCHECK(camera_controller);
  AddCameraOptions(cameras, camera_controller->IsCameraDisabledByPolicy());

  // If the size of the given `cameras` is equal to the size of the current
  // available cameras, the bounds of the `camera_menu_group_` won't be updated,
  // hence a layout may not be triggered. This can cause the newly added camera
  // options to be not visible. We must guarantee that a layout will always
  // occur by invalidating the layout.
  camera_menu_group_->InvalidateLayout();
  camera_menu_group_->RefreshOptionsSelections();
  capture_mode_session_->MaybeUpdateSettingsBounds();
}

void CaptureModeSettingsView::OnSelectedCameraChanged(
    const CameraId& camera_id) {
  // TODO(conniekxu): Implement this function.
}

void CaptureModeSettingsView::OnSelectFolderMenuItemPressed() {
  capture_mode_session_->OpenFolderSelectionDialog();
}

void CaptureModeSettingsView::OnCustomFolderAvailabilityChecked(
    bool available) {
  DCHECK(save_to_menu_group_);
  is_custom_folder_available_ = available;
  save_to_menu_group_->RefreshOptionsSelections();
  if (!is_custom_folder_available_.value_or(false)) {
    RecordSwitchToDefaultFolderReason(
        CaptureModeSwitchToDefaultReason::kFolderUnavailable);
  }
  if (on_settings_menu_refreshed_callback_for_test_)
    std::move(on_settings_menu_refreshed_callback_for_test_).Run();
}

const CameraId* CaptureModeSettingsView::FindCameraIdByOptionId(
    int option_id) const {
  auto target_it = option_camera_id_map_.find(option_id);
  if (target_it != option_camera_id_map_.end())
    return &(target_it->second);
  return nullptr;
}

void CaptureModeSettingsView::AddCameraOptions(const CameraInfoList& cameras,
                                               bool managed_by_policy) {
  DCHECK(camera_menu_group_);
  camera_menu_group_->DeleteOptions();
  option_camera_id_map_.clear();
  const bool has_cameras = !cameras.empty();
  if (has_cameras) {
    camera_menu_group_->AddOption(
        /*option_icon=*/nullptr,
        l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_CAMERA_OFF),
        kCameraOff);
    if (!managed_by_policy) {
      int camera_option_id_begin = kCameraDevicesBegin;
      for (const CameraInfo& camera_info : cameras) {
        option_camera_id_map_[camera_option_id_begin] = camera_info.camera_id;
        camera_menu_group_->AddOption(
            /*option_icon=*/nullptr,
            base::UTF8ToUTF16(camera_info.display_name),
            camera_option_id_begin++);
      }
    }
  }
  UpdateCameraMenuGroupVisibility(/*visible=*/has_cameras);
}

void CaptureModeSettingsView::UpdateCameraMenuGroupVisibility(bool visible) {
  separator_1_->SetVisible(visible);
  camera_menu_group_->SetVisible(visible);
}

void CaptureModeSettingsView::OnDemoToolsButtonToggled() {
  const bool was_on = CaptureModeController::Get()->enable_demo_tools();
  CaptureModeController::Get()->EnableDemoTools(/*enable=*/!was_on);
}

BEGIN_METADATA(CaptureModeSettingsView)
END_METADATA

}  // namespace ash