chromium/ash/system/video_conference/bubble/set_camera_background_view.cc

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#ifdef UNSAFE_BUFFERS_BUILD
// TODO(crbug.com/40285824): Remove this and convert code to safer constructs.
#pragma allow_unsafe_buffers
#endif

#include "ash/system/video_conference/bubble/set_camera_background_view.h"

#include "ash/public/cpp/image_util.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/system/camera/camera_effects_controller.h"
#include "ash/system/video_conference/bubble/bubble_view.h"
#include "ash/system/video_conference/bubble/bubble_view_ids.h"
#include "ash/system/video_conference/resources/grit/vc_resources.h"
#include "ash/system/video_conference/video_conference_tray_controller.h"
#include "ash/system/video_conference/video_conference_utils.h"
#include "ash/wallpaper/wallpaper_utils/sea_pen_metadata_utils.h"
#include "ash/webui/common/mojom/sea_pen.mojom.h"
#include "base/functional/callback_helpers.h"
#include "base/memory/weak_ptr.h"
#include "base/metrics/histogram_functions.h"
#include "skia/ext/image_operations.h"
#include "third_party/skia/include/core/SkPath.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/image_model.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/codec/jpeg_codec.h"
#include "ui/gfx/image/image_skia_operations.h"
#include "ui/gfx/image/image_skia_rep.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/controls/animated_image_view.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/button/label_button.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/fill_layout.h"

namespace ash::video_conference {

namespace {

using BackgroundImageInfo = CameraEffectsController::BackgroundImageInfo;

constexpr char kBackgroundImageButtonHistogramName[] =
    "Ash.VideoConferenceTray.BackgroundImageButton.Click";

constexpr char kCreateWithAiButtonHistogramName[] =
    "Ash.VideoConferenceTray.CreateWithAiButton.Click";

// This extra border is added to `CreateImageButton` to make it consistent with
// other buttons in the video conference bubble.
constexpr gfx::Insets kImageLabelContainerInsideBorderInsets =
    gfx::Insets::VH(8, 0);

// Distance between the wand icon and the "Create with AI" label
constexpr int kCreateImageButtonBetweenChildSpacing = 12;
// Vertical Distance between Recently used image and Create with AI button
constexpr int kSetCameraBackgroundViewBetweenChildSpacing = 16;
constexpr int kSetCameraBackgroundViewRadius = 18;
constexpr int kButtonHeight = 20;

constexpr int kMaxRecentBackgroundToDislay = 4;
constexpr int kRecentlyUsedImagesFullLength = 368;
constexpr int kRecentlyUsedImagesHeight = 76;
constexpr int kRecentlyUsedImagesSpacing = 10;

constexpr int kRecentlyUsedImageButtonId[] = {
    BubbleViewID::kBackgroundImage0,
    BubbleViewID::kBackgroundImage1,
    BubbleViewID::kBackgroundImage2,
    BubbleViewID::kBackgroundImage3,
};

// Helper for getting the size of each recently used images.
gfx::Size CalculateWantedImageSize(const int index, int image_count) {
  CHECK_LT(index, image_count);

  const int spacing = (image_count - 1) * kRecentlyUsedImagesSpacing;

  // If there is only 1 image, we only want that image to take half of the whole
  // area, not the full area.
  const int expected_width =
      image_count == 1
          ? (kRecentlyUsedImagesFullLength - kRecentlyUsedImagesSpacing) / 2
          : (kRecentlyUsedImagesFullLength - spacing) / image_count;

  return gfx::Size(expected_width, kRecentlyUsedImagesHeight);
}

CameraEffectsController* GetCameraEffectsController() {
  return Shell::Get()->camera_effects_controller();
}

bool IsVcBackgroundAllowedByEnterprise() {
  auto* controller = Shell::Get()->session_controller();
  return std::get<1>(
      controller->IsEligibleForSeaPen(controller->GetActiveAccountId()));
}

// Returns a gradient lottie animation defined in the resource file for the
// `Create with AI` button.
std::unique_ptr<lottie::Animation> GetGradientAnimation(
    const ui::ColorProvider* color_provider) {
  std::optional<std::vector<uint8_t>> lottie_data =
      ui::ResourceBundle::GetSharedInstance().GetLottieData(
          IDR_VC_CREATE_WITH_AI_BUTTON_ANIMATION);
  CHECK(lottie_data.has_value());
  CHECK(color_provider);

  return std::make_unique<lottie::Animation>(
      cc::SkottieWrapper::UnsafeCreateSerializable(lottie_data.value()),
      video_conference_utils::CreateColorMapForGradientAnimation(
          color_provider));
}

// Image button for the recently used images as camera background.
class RecentlyUsedImageButton : public views::ImageButton {
  METADATA_HEADER(RecentlyUsedImageButton, views::ImageButton)

 public:
  RecentlyUsedImageButton(
      const gfx::ImageSkia& image,
      const std::string& metadata,
      int id,
      const base::RepeatingCallback<void()>& image_button_callback)
      : ImageButton(image_button_callback),
        check_icon_(&kBackgroundSelectionIcon,
                    cros_tokens::kCrosSysFocusRingOnPrimaryContainer) {
    SetID(id);
    background_image_ = image;

    SetImageModel(ButtonState::STATE_NORMAL,
                  ui::ImageModel::FromImageSkia(background_image_));

    // TODO(b/332573200): only construct this button when the metadata is
    // decodable.
    SetAccessibilityLabelFromMetadata(metadata);

    SetFlipCanvasOnPaintForRTLUI(false);
  }

  void SetSelected(bool selected) {
    if (selected_ == selected) {
      return;
    }
    selected_ = selected;
    SchedulePaint();
  }

 private:
  // ImageButton:
  void PaintButtonContents(gfx::Canvas* canvas) override {
    // If current image is selected, we need to draw the background image with
    // required boundary and then put the check mark on the top-left corner.
    if (selected_) {
      canvas->DrawImageInPath(background_image_, 0, 0, GetClipPath(),
                              cc::PaintFlags());
      canvas->DrawImageInt(check_icon_.GetImageSkia(GetColorProvider()), 0, 0);
    } else {
      // Otherwise, draw the normal background image.
      canvas->DrawImageInt(background_image_, 0, 0);
    }
  }

  // Extract medata then decode it.
  void SetAccessibilityLabelFromMetadata(const std::string& metadata) {
    // Used for testing.
    if (metadata.empty()) {
      SetAccessibilityLabelFromRecentSeaPenImageInfo(nullptr);
      return;
    }

    const std::string extracted_metadata =
        ExtractDcDescriptionContents(metadata);

    DecodeJsonMetadata(
        extracted_metadata.empty() ? metadata : extracted_metadata,
        base::BindOnce(&RecentlyUsedImageButton::
                           SetAccessibilityLabelFromRecentSeaPenImageInfo,
                       weak_factory_.GetWeakPtr()));
  }

  // Called when decoding metadata complete.
  void SetAccessibilityLabelFromRecentSeaPenImageInfo(
      personalization_app::mojom::RecentSeaPenImageInfoPtr info) {
    GetViewAccessibility().SetRole(ax::mojom::Role::kListItem);
    GetViewAccessibility().SetDescription(l10n_util::GetStringUTF16(
        IDS_ASH_VIDEO_CONFERENCE_BUBBLE_BACKGROUND_BLUR_IMAGE_LIST_ITEM_DESCRIPTION));

    std::u16string query;
    const auto& text = GetQueryString(info);
    if (text.empty() || !base::UTF8ToUTF16(text.c_str(), text.size(), &query)) {
      query.clear();
    }
    GetViewAccessibility().SetName(
        query, query.empty() ? ax::mojom::NameFrom::kAttributeExplicitlyEmpty
                             : ax::mojom::NameFrom::kAttribute);
  }

  SkPath GetClipPath() {
    const auto width = this->width();
    const auto height = this->height();
    const auto radius = kSetCameraBackgroundViewRadius;

    return SkPath()
        // Start just before the curve of the top-right corner.
        .moveTo(width - radius, 0.f)
        // Move to left before the curve.
        .lineTo(38, 0)
        // Draw first part of the top-left corner.
        .rCubicTo(-5.52f, 0, -10, 4.48f, -10, 10)
        // Move down a bit.
        .rLineTo(-0.f, 2.f)
        // Draw second part of the top-left corner.
        .rCubicTo(0, 8.84f, -7.16f, 16, -16, 16)
        // Move left a bit.
        .rLineTo(-2.f, 0.f)
        // Draw the third part of the top-left corner.
        .cubicTo(4.48f, 28, 0, 32.48f, 0, 38)
        // Move to the bottom-left corner.
        .lineTo(-0.f, height - radius)
        // Draw bottom-left curve.
        .rCubicTo(0, 8.84f, 7.16f, 16, 16, 16)
        // Move to the bottom-right corner.
        .lineTo(width - radius, height)
        // Draw bottom-right curve.
        .rCubicTo(8.84f, 0, 16, -7.16f, 16, -16)
        // Move to the top-right corner.
        .lineTo(width, 16)
        // Draw top-right curve.
        .rCubicTo(0, -8.84f, -7.16f, -16, -16, -16)
        .close();
  }

  bool selected_ = false;
  const ui::ThemedVectorIcon check_icon_;

  base::WeakPtrFactory<RecentlyUsedImageButton> weak_factory_{this};
};

BEGIN_METADATA(RecentlyUsedImageButton)
END_METADATA

// The RecentlyUsedBackgroundView contains a list of recently used background
// images as buttons.
class RecentlyUsedBackgroundView : public views::View {
  METADATA_HEADER(RecentlyUsedBackgroundView, views::View)

 public:
  explicit RecentlyUsedBackgroundView(BubbleView* bubble_view)
      : bubble_view_(bubble_view) {
    auto* layout = SetLayoutManager(std::make_unique<views::BoxLayout>(
        views::BoxLayout::Orientation::kHorizontal,
        /*inside_border_insets=*/gfx::Insets(),
        /*between_child_spacing=*/kRecentlyUsedImagesSpacing));
    layout->set_cross_axis_alignment(
        views::BoxLayout::CrossAxisAlignment::kStretch);

    GetCameraEffectsController()->GetRecentlyUsedBackgroundImages(
        kMaxRecentBackgroundToDislay,
        base::BindOnce(&RecentlyUsedBackgroundView::
                           GetRecentlyUsedBackgroundImagesComplete,
                       weak_factory_.GetWeakPtr()));
  }

  void GetRecentlyUsedBackgroundImagesComplete(
      const std::vector<BackgroundImageInfo>& images_info) {
    for (std::size_t i = 0; i < images_info.size(); ++i) {
      auto image = image_util::ResizeAndCropImage(
          images_info[i].image,
          CalculateWantedImageSize(i, images_info.size()));

      image = gfx::ImageSkiaOperations::CreateImageWithRoundRectClip(
          kSetCameraBackgroundViewRadius, image);

      auto recently_used_image_button =
          std::make_unique<RecentlyUsedImageButton>(
              image, images_info[i].metadata, kRecentlyUsedImageButtonId[i],
              base::BindRepeating(
                  &RecentlyUsedBackgroundView::OnImageButtonClicked,
                  weak_factory_.GetWeakPtr(), i, images_info[i].basename));
      // If background replace is applied, then set first image as selected.
      if (i == 0 &&
          GetCameraEffectsController()->GetCameraEffects()->replace_enabled) {
        recently_used_image_button->SetSelected(true);
      }
      AddChildView(std::move(recently_used_image_button));
    }

    // Because this is async, we need to update the ui when all images are
    // loaded.
    if (bubble_view_) {
      bubble_view_->ChildPreferredSizeChanged(this);
    }
  }

  // Called when index-th image button is clicked on.
  void OnImageButtonClicked(std::size_t index, const base::FilePath& filename) {
    // Select index-th image button and deselect the rest of the image buttons.
    for (std::size_t i = 0; i < children().size(); i++) {
      RecentlyUsedImageButton* button =
          views::AsViewClass<RecentlyUsedImageButton>(children()[i]);
      button->SetSelected(i == index);
    }

    GetCameraEffectsController()->SetBackgroundImage(filename,
                                                     base::DoNothing());

    base::UmaHistogramBoolean(kBackgroundImageButtonHistogramName, true);
  }

 private:
  raw_ptr<BubbleView> bubble_view_;

  base::WeakPtrFactory<RecentlyUsedBackgroundView> weak_factory_{this};
};

BEGIN_METADATA(RecentlyUsedBackgroundView)
END_METADATA

// Button for "Create with AI".
class CreateImageButton : public views::Button {
  METADATA_HEADER(CreateImageButton, views::Button)

 public:
  explicit CreateImageButton(VideoConferenceTrayController* controller)
      : views::Button(base::BindRepeating(&CreateImageButton::OnButtonClicked,
                                          base::Unretained(this))),
        controller_(controller) {
    SetID(BubbleViewID::kCreateWithAiButton);
    GetViewAccessibility().SetName(
        l10n_util::GetStringUTF16(IDS_ASH_VIDEO_CONFERENCE_CREAT_WITH_AI_NAME));
    SetLayoutManager(std::make_unique<views::FillLayout>());
    SetBackground(views::CreateThemedRoundedRectBackground(
        cros_tokens::kCrosSysSystemOnBase, kSetCameraBackgroundViewRadius));

    lottie_animation_view_ =
        AddChildView(std::make_unique<views::AnimatedImageView>());

    AddChildView(
        views::Builder<views::BoxLayoutView>()
            .SetBetweenChildSpacing(kCreateImageButtonBetweenChildSpacing)
            .SetOrientation(views::BoxLayout::Orientation::kHorizontal)
            .SetMainAxisAlignment(views::LayoutAlignment::kCenter)
            .SetInsideBorderInsets(kImageLabelContainerInsideBorderInsets)
            .SetMainAxisAlignment(views::LayoutAlignment::kCenter)
            .AddChildren(
                views::Builder<views::ImageView>().SetImage(
                    ui::ImageModel::FromVectorIcon(
                        kAiWandIcon, ui::kColorMenuIcon, kButtonHeight)),
                views::Builder<views::Label>().SetText(
                    l10n_util::GetStringUTF16(
                        IDS_ASH_VIDEO_CONFERENCE_CREAT_WITH_AI_NAME)))
            .Build());
  }

  CreateImageButton(const CreateImageButton&) = delete;
  CreateImageButton& operator=(const CreateImageButton&) = delete;
  ~CreateImageButton() override = default;

  bool IsAnimationPlaying() {
    return lottie_animation_view_->state() ==
           views::AnimatedImageView::State::kPlaying;
  }

 private:
  // views::Button:
  // Reset the animated image on theme changed to get correct color for the
  // animation if the `lottie_animation_view_` should be shown and is visible.
  void OnThemeChanged() override {
    views::Button::OnThemeChanged();

    // Reset the animated image if there is a need to show animation.
    if (controller_->ShouldShowCreateWithAiButtonAnimation()) {
      // This need to be recorded before SetAnimatedImage because
      // SetAnimatedImage stops the animation.
      const bool is_animation_playing = IsAnimationPlaying();

      lottie_animation_view_->SetAnimatedImage(
          GetGradientAnimation(GetColorProvider()));

      // Play the animation only if it is current visible.
      if (is_animation_playing) {
        PlayAnimation();
      }
    }
  }

  void VisibilityChanged(View* starting_from, bool is_visible) override {
    // Skip visibility change that caused by the buble. We only care the
    // visibility change that is directly caused by `SetCameraBackgroundView`.
    if (starting_from != parent() ||
        !controller_->ShouldShowCreateWithAiButtonAnimation()) {
      return;
    }

    if (is_visible) {
      PlayAnimation();
    } else {
      StopAnimation();
    }
  }

  void OnButtonClicked(const ui::Event& event) {
    if (IsAnimationPlaying()) {
      StopAnimation();
    }
    controller_->DismissCreateWithAiButtonAnimationForever();

    base::UmaHistogramBoolean(kCreateWithAiButtonHistogramName, true);

    // This line will dismiss the VcTray, thus should be called at the end.
    controller_->CreateBackgroundImage();
  }

  void PlayAnimation() {
    if (!lottie_animation_view_->animated_image()) {
      lottie_animation_view_->SetAnimatedImage(
          GetGradientAnimation(GetColorProvider()));
    }
    lottie_animation_view_->SetVisible(true);
    lottie_animation_view_->Play();
    stop_animation_timer_.Start(FROM_HERE, kGradientAnimationDuration, this,
                                &CreateImageButton::StopAnimation);
  }

  void StopAnimation() {
    stop_animation_timer_.Stop();
    lottie_animation_view_->Stop();
    lottie_animation_view_->SetVisible(false);
  }

  // Unowned by `CreateImageButton`.
  const raw_ptr<VideoConferenceTrayController> controller_;

  // Owned by the View's hierarchy. Used to play the animation on the button.
  raw_ptr<views::AnimatedImageView> lottie_animation_view_ = nullptr;

  // Started when `lottie_animation_view_` starts playing the animation. It's
  // used to stop the animation after the animation duration.
  base::OneShotTimer stop_animation_timer_;
};

BEGIN_METADATA(CreateImageButton)
END_METADATA

}  // namespace

SetCameraBackgroundView::SetCameraBackgroundView(
    BubbleView* bubble_view,
    VideoConferenceTrayController* controller)
    : controller_(controller) {
  SetID(BubbleViewID::kSetCameraBackgroundView);
  SetVisible(
      GetCameraEffectsController()->GetCameraEffects()->replace_enabled &&
      IsVcBackgroundAllowedByEnterprise());

  // `SetCameraBackgroundView` has 2+ children, we want to stack them
  // vertically.
  SetLayoutManager(std::make_unique<views::BoxLayout>(
                       views::BoxLayout::Orientation::kVertical,
                       /*inside_border_insets=*/gfx::Insets(),
                       /*between_child_spacing=*/
                       kSetCameraBackgroundViewBetweenChildSpacing))
      ->set_cross_axis_alignment(
          views::BoxLayout::CrossAxisAlignment::kStretch);

  recently_used_background_view_ =
      AddChildView(std::make_unique<RecentlyUsedBackgroundView>(bubble_view));
  create_with_image_button_ =
      AddChildView(std::make_unique<CreateImageButton>(controller));
}

void SetCameraBackgroundView::SetBackgroundReplaceUiVisible(bool visible) {
  // We don't want to show the SetCameraBackgroundView if there is no recently
  // used background; instead, the webui is shown.
  if (visible && recently_used_background_view_->children().empty()) {
    // We need to double check that there is no background images.
    GetCameraEffectsController()->GetRecentlyUsedBackgroundImages(
        1, base::BindOnce(
               &SetCameraBackgroundView::OnGetRecentlyUsedBackgroundImages,
               weak_factory_.GetWeakPtr()));
  }

  SetVisible(visible);

  // Unselect all recently image buttons if this view is invisible.
  if (!visible) {
    for (auto& button : recently_used_background_view_->children()) {
      views::AsViewClass<RecentlyUsedImageButton>(button)->SetSelected(false);
    }
  }
}

SetCameraBackgroundView::~SetCameraBackgroundView() = default;

bool SetCameraBackgroundView::
    IsAnimationPlayingForCreateWithAiButtonForTesting() {
  return views::AsViewClass<CreateImageButton>(create_with_image_button_)
      ->IsAnimationPlaying();
}

void SetCameraBackgroundView::OnGetRecentlyUsedBackgroundImages(
    const std::vector<BackgroundImageInfo>& background_images) {
  // Directly open the VcBackgroundApp if no background image exists.
  if (background_images.empty()) {
    controller_->CreateBackgroundImage();
  }
}

BEGIN_METADATA(SetCameraBackgroundView)
END_METADATA

}  // namespace ash::video_conference