chromium/chrome/browser/ui/ash/clipboard/clipboard_image_model_request.cc

// 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/ui/ash/clipboard/clipboard_image_model_request.h"

#include <memory>

#include "ash/public/cpp/clipboard_history_controller.h"
#include "ash/public/cpp/scoped_clipboard_history_pause.h"
#include "base/base64.h"
#include "base/metrics/histogram.h"
#include "base/metrics/histogram_macros.h"
#include "base/task/sequenced_task_runner.h"
#include "chrome/browser/profiles/profile.h"
#include "content/public/browser/navigation_controller.h"
#include "content/public/browser/render_view_host.h"
#include "content/public/browser/render_widget_host.h"
#include "content/public/browser/render_widget_host_view.h"
#include "content/public/browser/web_contents.h"
#include "ui/aura/window.h"
#include "ui/base/clipboard/clipboard_data.h"
#include "ui/base/clipboard/clipboard_non_backed.h"
#include "ui/base/data_transfer_policy/data_transfer_endpoint.h"
#include "ui/gfx/image/image_skia.h"
#include "ui/gfx/image/image_skia_operations.h"
#include "ui/gfx/image/image_skia_rep.h"
#include "ui/views/controls/webview/webview.h"
#include "ui/views/widget/widget.h"
#include "url/gurl.h"

namespace {

// The maximum size that the web contents can be. It caps the memory consumption
// incurred by web contents rendering.
constexpr gfx::Size kMaxWebContentsSize(2000, 2000);

// The initial size of the NativeView to force painting in an inactive shown
// widget for auto-resize mode.
constexpr gfx::Size kAutoResizeModeInitialSize(1, 1);

ClipboardImageModelRequest::TestParams* g_test_params = nullptr;

}  // namespace

// ClipboardImageModelFactory::Params: -----------------------------------------

ClipboardImageModelRequest::Params::Params(const base::UnguessableToken& id,
                                           const std::string& html_markup,
                                           const gfx::Size& bounding_box_size,
                                           ImageModelCallback callback)
    : id(id),
      html_markup(html_markup),
      bounding_box_size(bounding_box_size),
      callback(std::move(callback)) {}

ClipboardImageModelRequest::Params::Params(Params&&) = default;

ClipboardImageModelRequest::Params&
ClipboardImageModelRequest::Params::operator=(Params&&) = default;

ClipboardImageModelRequest::Params::~Params() = default;

// ClipboardImageModelFactory::TestParams: -------------------------------------

ClipboardImageModelRequest::TestParams::TestParams(
    RequestStopCallback callback,
    const std::optional<bool>& enforce_auto_resize)
    : callback(callback), enforce_auto_resize(enforce_auto_resize) {}

ClipboardImageModelRequest::TestParams::~TestParams() = default;

// ClipboardImageModelRequest------------- -------------------------------------

ClipboardImageModelRequest::ScopedClipboardModifier::ScopedClipboardModifier(
    const std::string& html_markup) {
  auto* clipboard = ui::ClipboardNonBacked::GetForCurrentThread();
  ui::DataTransferEndpoint data_dst(ui::EndpointType::kClipboardHistory);
  const auto* current_data = clipboard->GetClipboardData(&data_dst);

  // No need to replace the clipboard contents if the markup is the same.
  if (current_data && (html_markup == current_data->markup_data()))
    return;

  // Put |html_markup| on the clipboard temporarily so it can be pasted into
  // the WebContents. This is preferable to directly loading |html_markup_| in a
  // data URL because pasting the data into WebContents sanitizes the markup.
  // TODO(crbug.com/40729185): Sanitize copied HTML prior to storing it
  // in the clipboard buffer. Then |html_markup_| can be loaded from a data URL
  // and will not need to be pasted in this manner.
  auto new_data = std::make_unique<ui::ClipboardData>();
  new_data->set_markup_data(html_markup);

  scoped_clipboard_history_pause_ =
      ash::ClipboardHistoryController::Get()->CreateScopedPause();
  replaced_clipboard_data_ = clipboard->WriteClipboardData(std::move(new_data));
}

ClipboardImageModelRequest::ScopedClipboardModifier::
    ~ScopedClipboardModifier() {
  if (!replaced_clipboard_data_)
    return;

  ui::ClipboardNonBacked::GetForCurrentThread()->WriteClipboardData(
      std::move(replaced_clipboard_data_));
}

ClipboardImageModelRequest::ClipboardImageModelRequest(
    Profile* profile,
    base::RepeatingClosure on_request_finished_callback)
    : widget_(std::make_unique<views::Widget>()),
      web_view_(new views::WebView(profile)),
      on_request_finished_callback_(std::move(on_request_finished_callback)),
      request_creation_time_(base::TimeTicks::Now()) {
  CHECK(profile);

  views::Widget::InitParams widget_params(
      views::Widget::InitParams::WIDGET_OWNS_NATIVE_WIDGET,
      views::Widget::InitParams::TYPE_WINDOW_FRAMELESS);
  widget_params.name = "ClipboardImageModelRequest";
  widget_->Init(std::move(widget_params));
  widget_->SetContentsView(web_view_);

  Observe(web_view_->GetWebContents());
  web_contents()->SetDelegate(this);
}

ClipboardImageModelRequest::~ClipboardImageModelRequest() {
  UMA_HISTOGRAM_TIMES("Ash.ClipboardHistory.ImageModelRequest.Lifetime",
                      base::TimeTicks::Now() - request_creation_time_);
}

void ClipboardImageModelRequest::Start(Params&& params) {
  DCHECK(!deliver_image_model_callback_);
  DCHECK(params.callback);
  DCHECK_EQ(base::UnguessableToken(), request_id_);

  request_id_ = std::move(params.id);
  html_markup_ = params.html_markup;
  deliver_image_model_callback_ = std::move(params.callback);

  timeout_timer_.Start(FROM_HERE, base::Seconds(10), this,
                       &ClipboardImageModelRequest::OnTimeout);
  request_start_time_ = base::TimeTicks::Now();

  // Begin the document with the proper charset, this should prevent strange
  // looking characters from showing up in the render in some cases.
  std::string html_document(
      "<!DOCTYPE html>"
      "<html>"
      " <head><meta charset=\"UTF-8\"></meta></head>"
      " <body contenteditable='true' style=\"overflow: hidden\"> "
      "  <script>"
      // Focus the Contenteditable body to ensure WebContents::Paste() reaches
      // the body.
      "   document.body.focus();"
      "  </script>"
      " </body>"
      "</html");

  std::string encoded_html = base::Base64Encode(html_document);
  constexpr char kDataURIPrefix[] = "data:text/html;base64,";
  web_contents()->GetController().LoadURLWithParams(
      content::NavigationController::LoadURLParams(
          GURL(kDataURIPrefix + encoded_html)));
  widget_->ShowInactive();

  // Adapt to the render widget host view whose device scale factor is not one.
  bounding_box_size_ = gfx::ScaleToCeiledSize(
      params.bounding_box_size,
      web_contents()->GetRenderWidgetHostView()->GetDeviceScaleFactor());
}

void ClipboardImageModelRequest::Stop(RequestStopReason stop_reason) {
  UMA_HISTOGRAM_ENUMERATION("Ash.ClipboardHistory.ImageModelRequest.StopReason",
                            stop_reason);
  DCHECK(!request_start_time_.is_null());
  UMA_HISTOGRAM_TIMES("Ash.ClipboardHistory.ImageModelRequest.Runtime",
                      base::TimeTicks::Now() - request_start_time_);
  request_start_time_ = base::TimeTicks();
  scoped_clipboard_modifier_.reset();
  weak_ptr_factory_.InvalidateWeakPtrs();
  copy_surface_weak_ptr_factory_.InvalidateWeakPtrs();
  timeout_timer_.Stop();
  widget_->Hide();
  deliver_image_model_callback_.Reset();
  request_id_ = base::UnguessableToken();
  did_stop_loading_ = false;

  on_request_finished_callback_.Run();

  if (g_test_params && g_test_params->callback)
    g_test_params->callback.Run(ShouldEnableAutoResizeMode());
}

ClipboardImageModelRequest::Params
ClipboardImageModelRequest::StopAndGetParams() {
  DCHECK(IsRunningRequest());
  Params params(request_id_, html_markup_, bounding_box_size_,
                std::move(deliver_image_model_callback_));
  Stop(RequestStopReason::kRequestCanceled);
  return params;
}

bool ClipboardImageModelRequest::IsModifyingClipboard() const {
  return scoped_clipboard_modifier_.has_value();
}

bool ClipboardImageModelRequest::IsRunningRequest(
    std::optional<base::UnguessableToken> request_id) const {
  return request_id.has_value() ? *request_id == request_id_
                                : !request_id_.is_empty();
}

void ClipboardImageModelRequest::ResizeDueToAutoResize(
    content::WebContents* web_contents,
    const gfx::Size& new_size) {
  web_contents->GetNativeView()->SetBounds(gfx::Rect(gfx::Point(), new_size));

  // `ResizeDueToAutoResize()` can be called before and/or after
  // DidStopLoading(). If `DidStopLoading()` has not been called, wait for the
  // next resize before copying the surface.
  if (!web_contents->IsLoading())
    PostCopySurfaceTask();
}

void ClipboardImageModelRequest::DidStopLoading() {
  // `DidStopLoading()` can be called multiple times after a paste. We are only
  // interested in the initial load of the data URL.
  if (did_stop_loading_)
    return;

  did_stop_loading_ = true;

  // Modify the clipboard so `html_markup_` can be pasted into the WebContents.
  scoped_clipboard_modifier_.emplace(html_markup_);

  web_contents()->GetRenderViewHost()->GetWidget()->InsertVisualStateCallback(
      base::BindOnce(&ClipboardImageModelRequest::OnVisualStateChangeFinished,
                     weak_ptr_factory_.GetWeakPtr()));

  // After navigating to a new page, the surface id is invalidated. As a result,
  // copy from surface is disabled as well. Setting the window's bounds should
  // generate a new local surface id. Hence, window bounds setting should be
  // after the web navigation.
  // Changing auto resize mode does not generate a new local surface id while
  // setting the window bounds will. As a result, enabling/disabling the auto
  // resize mode has to precede the window bounds setting. Otherwise the change
  // in the window bounds may trigger the unnecessary update in the view layout
  // for the obsolete auto resize state. This layout update will consume the
  // newly generated local surface id. Then it will cause a crash when the
  // layout update brought by the change in the auto resize state arrives.
  if (ShouldEnableAutoResizeMode()) {
    web_contents()->GetRenderWidgetHostView()->EnableAutoResize(
        kAutoResizeModeInitialSize, kMaxWebContentsSize);
    web_contents()->GetNativeView()->SetBounds(
        gfx::Rect(kAutoResizeModeInitialSize));
  } else {
    web_contents()->GetRenderWidgetHostView()->DisableAutoResize(
        bounding_box_size_);
    web_contents()->GetNativeView()->SetBounds(gfx::Rect(bounding_box_size_));
  }

  // TODO(https://crbug.com/1149556): Clipboard Contents could be overwritten
  // prior to the `WebContents::Paste()` completing.
  web_contents()->Paste();
}

// static
void ClipboardImageModelRequest::SetTestParams(TestParams* test_params) {
  // Supports only setting `g_test_params` or resetting it.
  DCHECK(!g_test_params || !test_params);
  g_test_params = test_params;
}

void ClipboardImageModelRequest::OnVisualStateChangeFinished(bool done) {
  if (!done)
    return;

  scoped_clipboard_modifier_.reset();
  PostCopySurfaceTask();
}

void ClipboardImageModelRequest::PostCopySurfaceTask() {
  if (!deliver_image_model_callback_)
    return;

  // Debounce calls to `CopySurface()`. `DidStopLoading()` and
  // `ResizeDueToAutoResize()` can be called multiple times in the same task
  // sequence. Wait for the final update before copying the surface.
  copy_surface_weak_ptr_factory_.InvalidateWeakPtrs();
  DCHECK(
      web_contents()->GetRenderWidgetHostView()->IsSurfaceAvailableForCopy());
  base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
      FROM_HERE,
      base::BindOnce(&ClipboardImageModelRequest::CopySurface,
                     copy_surface_weak_ptr_factory_.GetWeakPtr()),
      base::Milliseconds(250));
}

void ClipboardImageModelRequest::CopySurface() {
  content::RenderWidgetHostView* source_view =
      web_contents()->GetRenderViewHost()->GetWidget()->GetView();
  if (source_view->GetViewBounds().size().IsEmpty()) {
    Stop(RequestStopReason::kEmptyResult);
    return;
  }

  // There is no guarantee CopyFromSurface will call OnCopyComplete. If this
  // takes too long, this will be cleaned up by |timeout_timer_|.
  source_view->CopyFromSurface(
      /*src_rect=*/gfx::Rect(), /*output_size=*/gfx::Size(),
      base::BindOnce(&ClipboardImageModelRequest::OnCopyComplete,
                     weak_ptr_factory_.GetWeakPtr(),
                     source_view->GetDeviceScaleFactor()));
}

void ClipboardImageModelRequest::OnCopyComplete(float device_scale_factor,
                                                const SkBitmap& bitmap) {
  if (!deliver_image_model_callback_) {
    Stop(RequestStopReason::kMultipleCopyCompletion);
    return;
  }

  std::move(deliver_image_model_callback_)
      .Run(ui::ImageModel::FromImageSkia(
          gfx::ImageSkia(gfx::ImageSkiaRep(bitmap, device_scale_factor))));
  Stop(RequestStopReason::kFulfilled);
}

void ClipboardImageModelRequest::OnTimeout() {
  DCHECK(deliver_image_model_callback_);
  Stop(RequestStopReason::kTimeout);
}

bool ClipboardImageModelRequest::ShouldEnableAutoResizeMode() const {
  // Prefer to use the auto resize mode specified by `g_test_params` if any.
  if (g_test_params && g_test_params->enforce_auto_resize)
    return *g_test_params->enforce_auto_resize;

  // Use auto resize mode if `bounding_box_size_` is not meaningful.
  if (bounding_box_size_.IsEmpty())
    return true;

  // Use auto resize mode if the copied web content is too big to render.
  return !gfx::Rect(kMaxWebContentsSize)
              .Contains(gfx::Rect(bounding_box_size_));
}