chromium/chrome/browser/ash/mahi/media_app/mahi_media_app_client.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 "chrome/browser/ash/mahi/media_app/mahi_media_app_client.h"

#include "ash/shell.h"
#include "base/check_deref.h"
#include "base/i18n/break_iterator.h"
#include "base/strings/utf_string_conversions.h"
#include "base/unguessable_token.h"
#include "chrome/browser/ash/crosapi/crosapi_ash.h"
#include "chrome/browser/ash/crosapi/crosapi_manager.h"
#include "chrome/browser/ash/mahi/mahi_browser_delegate_ash.h"
#include "chromeos/components/mahi/public/cpp/mahi_media_app_content_manager.h"
#include "chromeos/components/mahi/public/cpp/mahi_media_app_events_proxy.h"
#include "chromeos/crosapi/mojom/mahi.mojom.h"
#include "ui/aura/client/focus_client.h"
#include "ui/gfx/geometry/rect_conversions.h"
#include "ui/gfx/image/image_skia.h"

namespace ash {

namespace {
// We truncate PDF files that have thousands of pages and tremendous amount of
// text with this limit, to save timecost of GetPdfContent, and to respect the
// capacity of the server side model.
constexpr int32_t kContentByteSizeLimit = 5 * 1000 * 1000;
constexpr int32_t kContentWordCountThreshold = 50;

using ::base::i18n::BreakIterator;

// Checks the content word count meets the `threshold`.
bool ContentsWordCountSatisfied(std::u16string_view contents,
                                int32_t threshold) {
  int32_t word_count = 0;
  BreakIterator break_iter(contents, BreakIterator::BREAK_WORD);
  if (!break_iter.Init()) {
    return false;
  }

  while (break_iter.Advance()) {
    if (break_iter.IsWord()) {
      ++word_count;
      if (word_count >= threshold) {
        return true;
      }
    }
  }
  return false;
}
}  // namespace

MahiMediaAppClient::MahiMediaAppClient(
    mojo::PendingRemote<ash::media_app_ui::mojom::MahiUntrustedPage> page,
    const std::string& file_name,
    aura::Window* media_app_window)
    : client_id_(base::UnguessableToken::Create()),
      media_app_pdf_file_(std::move(page)),
      file_name_(file_name),
      media_app_window_(media_app_window) {
  if (!ash::Shell::HasInstance()) {
    return;
  }
  CHECK(media_app_window_);

  // Registers self to `MahiMediaAppContentManager` as a client.
  chromeos::MahiMediaAppContentManager::Get()->AddClient(client_id_, this);

  // Starts to observe media_app_window_ events.
  window_observation_.Observe(media_app_window_);
}

MahiMediaAppClient::~MahiMediaAppClient() {
  // If `media_app_window_` is null, it means the media app window is closed and
  // `OnWindowDestroying` is already called, the following part is done at that
  // time.
  if (media_app_window_ == nullptr) {
    return;
  }
  // Broadcasts the PDF closed event.
  chromeos::MahiMediaAppEventsProxy::Get()->OnPdfClosed(client_id_);

  // Manually calls `RemoveClient()` when disconnecting.
  chromeos::MahiMediaAppContentManager::Get()->RemoveClient(client_id_);
}

void MahiMediaAppClient::OnPdfLoaded() {
  if (!ash::Shell::HasInstance()) {
    return;
  }

  // On PDF loaded, the client starts to observe focus events and notify Mahi
  // system when the media app window has focus.
  focus_observation_.Reset();
  aura::client::FocusClient* focus_client =
      aura::client::GetFocusClient(ash::Shell::GetPrimaryRootWindow());
  focus_observation_.Observe(focus_client);

  // Checks the current focused window.
  OnWindowFocused(focus_client->GetFocusedWindow(), nullptr);
}

void MahiMediaAppClient::OnPdfFileNameUpdated(const std::string& new_name) {
  if (file_name_ == new_name) {
    return;
  }
  file_name_ = new_name;
  CHECK(focus_observation_.IsObserving());

  // Notifies this change if the media app window has focus.
  OnWindowFocused(focus_observation_.GetSource()->GetFocusedWindow(), nullptr);
}

void MahiMediaAppClient::OnPdfContextMenuShow(const ::gfx::RectF& anchor) {
  chromeos::MahiMediaAppEventsProxy::Get()->OnPdfContextMenuShown(
      client_id_, ::gfx::ToEnclosingRect(anchor));
}

void MahiMediaAppClient::OnPdfContextMenuHide() {
  chromeos::MahiMediaAppEventsProxy::Get()->OnPdfContextMenuHide();
}

void MahiMediaAppClient::GetPdfContent(GetContentCallback callback) {
  media_app_pdf_file_->GetPdfContent(
      kContentByteSizeLimit,
      base::BindOnce(
          [](GetContentCallback callback, base::UnguessableToken client_id,
             const std::optional<std::string>& content) {
            if (!content.has_value()) {
              // TODO(b/335741382): UMA metric for this case.
              std::move(callback).Run(nullptr);
              return;
            }

            const std::u16string u16content =
                base::UTF8ToUTF16(content.value());
            if (!ContentsWordCountSatisfied(u16content,
                                            kContentWordCountThreshold)) {
              // TODO(b/335741382): UMA metric for this case.
              std::move(callback).Run(nullptr);
              return;
            }

            std::move(callback).Run(crosapi::mojom::MahiPageContent::New(
                client_id,
                /*page_id=*/client_id, std::move(u16content)));
          },
          std::move(callback), client_id_));
}

void MahiMediaAppClient::HideMediaAppContextMenu() {
  media_app_pdf_file_->HidePdfContextMenu();
}

void MahiMediaAppClient::OnWindowFocused(aura::Window* gained_focus,
                                         aura::Window* lost_focus) {
  if (gained_focus == nullptr || gained_focus == lost_focus) {
    return;
  }

  if (gained_focus == media_app_window_ ||
      gained_focus->GetToplevelWindow() == media_app_window_) {
    // Observed media app window get focus.
    chromeos::MahiMediaAppEventsProxy::Get()->OnPdfGetFocus(client_id_);
  }
}

void MahiMediaAppClient::OnWindowBoundsChanged(
    aura::Window* window,
    const gfx::Rect& old_bounds,
    const gfx::Rect& new_bounds,
    ui::PropertyChangeReason reason) {
  if (window == media_app_window_) {
    // Any changes to the window bounds (from moving the window or resizing)
    // might affect the context menu position. Instead of updating the Mahi card
    // to follow the context menu, make the Media app hide the context menu.
    media_app_pdf_file_->HidePdfContextMenu();
  }
}

void MahiMediaAppClient::OnWindowDestroying(aura::Window* window) {
  if (window == media_app_window_) {
    // Broadcasts the PDF closed event.
    chromeos::MahiMediaAppEventsProxy::Get()->OnPdfClosed(client_id_);
    // Manually calls `RemoveClient()` when window destories.
    chromeos::MahiMediaAppContentManager::Get()->RemoveClient(client_id_);
    media_app_window_ = nullptr;
    window_observation_.Reset();
  }
}

}  // namespace ash