blob: 0531b58f24ccd72b7bb371f7a422f5ff5406d64f [file] [log] [blame]
// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/user_education/webui/help_bubble_handler.h"
#include <memory>
#include <string>
#include <string_view>
#include "base/callback_list.h"
#include "base/check.h"
#include "base/containers/contains.h"
#include "base/functional/callback.h"
#include "base/functional/callback_forward.h"
#include "base/memory/ptr_util.h"
#include "base/memory/raw_ptr.h"
#include "base/notimplemented.h"
#include "base/notreached.h"
#include "base/strings/string_util.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/types/pass_key.h"
#include "components/user_education/common/help_bubble/help_bubble.h"
#include "components/user_education/common/help_bubble/help_bubble_params.h"
#include "components/user_education/webui/help_bubble_webui.h"
#include "components/user_education/webui/tracked_element_help_bubble_webui_anchor.h"
#include "content/public/browser/render_widget_host_view.h"
#include "content/public/browser/visibility.h"
#include "content/public/browser/web_contents.h"
#include "content/public/browser/web_contents_observer.h"
#include "content/public/browser/web_ui.h"
#include "content/public/browser/web_ui_controller.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "third_party/abseil-cpp/absl/strings/ascii.h"
#include "ui/base/interaction/element_identifier.h"
#include "ui/base/interaction/element_tracker.h"
#include "ui/webui/resources/cr_components/help_bubble/help_bubble.mojom-shared.h"
#include "ui/webui/resources/cr_components/help_bubble/help_bubble.mojom.h"
namespace user_education {
namespace {
// Converts help bubble arrow to WebUI bubble position. This is not a complete
// mapping as many HelpBubbleArrow options are not (yet) supported in WebUI.
help_bubble::mojom::HelpBubbleArrowPosition HelpBubbleArrowToPosition(
HelpBubbleArrow arrow) {
switch (arrow) {
case HelpBubbleArrow::kBottomLeft:
return help_bubble::mojom::HelpBubbleArrowPosition::BOTTOM_LEFT;
case HelpBubbleArrow::kBottomCenter:
return help_bubble::mojom::HelpBubbleArrowPosition::BOTTOM_CENTER;
case HelpBubbleArrow::kBottomRight:
return help_bubble::mojom::HelpBubbleArrowPosition::BOTTOM_RIGHT;
case HelpBubbleArrow::kTopLeft:
return help_bubble::mojom::HelpBubbleArrowPosition::TOP_LEFT;
case HelpBubbleArrow::kTopCenter:
return help_bubble::mojom::HelpBubbleArrowPosition::TOP_CENTER;
case HelpBubbleArrow::kTopRight:
return help_bubble::mojom::HelpBubbleArrowPosition::TOP_RIGHT;
case HelpBubbleArrow::kLeftTop:
return help_bubble::mojom::HelpBubbleArrowPosition::LEFT_TOP;
case HelpBubbleArrow::kLeftCenter:
return help_bubble::mojom::HelpBubbleArrowPosition::LEFT_CENTER;
case HelpBubbleArrow::kLeftBottom:
return help_bubble::mojom::HelpBubbleArrowPosition::LEFT_BOTTOM;
case HelpBubbleArrow::kRightTop:
return help_bubble::mojom::HelpBubbleArrowPosition::RIGHT_TOP;
case HelpBubbleArrow::kRightCenter:
return help_bubble::mojom::HelpBubbleArrowPosition::RIGHT_CENTER;
case HelpBubbleArrow::kRightBottom:
return help_bubble::mojom::HelpBubbleArrowPosition::RIGHT_BOTTOM;
default:
NOTIMPLEMENTED();
}
return help_bubble::mojom::HelpBubbleArrowPosition::TOP_CENTER;
}
std::string SnakeCaseFromCamelCase(std::string input) {
std::string output;
output.reserve(input.size());
for (const char c : input) {
if (absl::ascii_isupper(static_cast<unsigned char>(c)) && !output.empty()) {
output.push_back('_');
}
output.push_back(absl::ascii_tolower(static_cast<unsigned char>(c)));
}
return output;
}
// Retrieve the file name from the generated gfx::VectorIcon name
// - Remove the 'k' prefix and 'Icon' suffix from gfx::VectorIcon.name
// - The remaining portion of the name is converted from CamelCase to
// snake_case to yield the original file name
std::string GetFileNameFromIcon(const gfx::VectorIcon* icon) {
std::string icon_name = icon->name;
constexpr char kPrefix[] = "k";
constexpr char kSuffix[] = "Icon";
DCHECK(base::StartsWith(icon_name, kPrefix));
DCHECK(base::EndsWith(icon_name, kSuffix));
icon_name.erase(0, strlen(kPrefix));
icon_name.erase(icon_name.length() - strlen(kSuffix));
return SnakeCaseFromCamelCase(icon_name);
}
content::WebContents* GetWebContentsCallbackForWebUIController(
content::WebUIController* controller) {
// A WebContents is always associated with a WebUIController, so this never
// returns nullptr in production code. The only possible reason for
// returning nullptr is in unit tests where the test WebUIController
// implementation is not set up correctly. If that happens, the test support
// code should be fixed.
return controller->web_ui()->GetWebContents();
}
} // namespace
struct HelpBubbleHandlerBase::ElementData {
ElementData() = default;
ElementData(ElementData&& other) noexcept = default;
ElementData& operator=(ElementData&& other) noexcept = default;
~ElementData() = default;
bool has_webui_help_bubble() const { return static_cast<bool>(params); }
// This shows whether the element is visible within the WebContents aside from
// the WebContents itself being visible.
bool visible = false;
gfx::RectF last_known_bounds;
std::unique_ptr<TrackedElementHelpBubbleWebUIAnchor> element;
std::unique_ptr<HelpBubbleParams> params;
raw_ptr<HelpBubbleWebUI> help_bubble = nullptr;
base::CallbackListSubscription external_bubble_subscription;
// This is set to true if we are closing the help bubble as the result of a
// message from the WebUI, rather than a browser-side event. It is used as a
// guard to prevent a loop where we receive a message that the bubble is
// closing and then tell the WebUI to close the bubble in response.
bool closing = false;
};
void HelpBubbleHandlerBase::VisibilityProvider::SetLastKnownVisibility(
std::optional<bool> visible) {
handler_->OnWebContentsVisibilityChanged(visible);
}
HelpBubbleHandlerBase::HelpBubbleHandlerBase(
std::unique_ptr<ClientProvider> client_provider,
std::unique_ptr<VisibilityProvider> visibility_provider,
GetWebContentsCallback get_web_contents_callback,
const std::vector<ui::ElementIdentifier>& identifiers,
ui::ElementContext context)
: client_provider_(std::move(client_provider)),
visibility_provider_(std::move(visibility_provider)),
get_web_contents_callback_(std::move(get_web_contents_callback)),
context_(context) {
DCHECK(client_provider_);
DCHECK(visibility_provider_);
DCHECK(get_web_contents_callback_);
DCHECK(context_);
visibility_provider_->set_handler(this);
for (auto identifier : identifiers) {
DCHECK(identifier);
const auto it = element_data_.emplace(identifier, ElementData());
DCHECK(it.second) << "Duplicate identifier not allowed: " << identifier;
it.first->second.element =
std::make_unique<TrackedElementHelpBubbleWebUIAnchor>(this, identifier,
context);
}
}
HelpBubbleHandlerBase::~HelpBubbleHandlerBase() {
for (auto& [id, data] : element_data_) {
if (data.help_bubble) {
data.help_bubble->Close();
}
}
}
content::WebContents* HelpBubbleHandlerBase::GetWebContents() {
return get_web_contents_callback_.Run();
}
bool HelpBubbleHandlerBase::IsHelpBubbleShowingForTesting(
ui::ElementIdentifier id) const {
const auto it = element_data_.find(id);
return it != element_data_.end() && it->second.has_webui_help_bubble();
}
help_bubble::mojom::HelpBubbleClient* HelpBubbleHandlerBase::GetClient() {
return client_provider_->GetClient();
}
void HelpBubbleHandlerBase::ReportBadMessage(std::string_view error) {
NOTREACHED() << error;
}
std::unique_ptr<HelpBubbleWebUI> HelpBubbleHandlerBase::CreateHelpBubble(
ui::ElementIdentifier identifier,
HelpBubbleParams params) {
const auto it = element_data_.find(identifier);
if (it == element_data_.end()) {
NOTREACHED() << "Identifier " << identifier << " was never registered.";
}
auto& data = it->second;
if (data.has_webui_help_bubble()) {
LOG(WARNING) << "A help bubble is already being shown for " << identifier;
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
if (data.help_bubble) {
data.help_bubble->Close();
if (!weak_ptr) {
return nullptr;
}
}
}
data.params = std::make_unique<HelpBubbleParams>(std::move(params));
auto result = base::WrapUnique(new HelpBubbleWebUI(this, identifier));
data.help_bubble = result.get();
auto mojom_params = help_bubble::mojom::HelpBubbleParams::New();
mojom_params->native_identifier = identifier.GetName();
mojom_params->body_text = base::UTF16ToUTF8(data.params->body_text);
mojom_params->close_button_alt_text =
base::UTF16ToUTF8(data.params->close_button_alt_text);
auto timeout = data.params->timeout.value_or(
data.params->buttons.empty() ? kDefaultTimeoutWithoutButtons
: kDefaultTimeoutWithButtons);
if (!timeout.is_zero())
mojom_params->timeout = timeout;
if (data.params->body_icon)
mojom_params->body_icon_name = GetFileNameFromIcon(data.params->body_icon);
mojom_params->body_icon_alt_text =
base::UTF16ToUTF8(data.params->body_icon_alt_text);
mojom_params->focus_on_show_hint = data.params->focus_on_show_hint;
mojom_params->position = HelpBubbleArrowToPosition(data.params->arrow);
if (data.params->progress) {
mojom_params->progress = help_bubble::mojom::Progress::New();
mojom_params->progress->current = data.params->progress->first;
mojom_params->progress->total = data.params->progress->second;
}
if (!data.params->title_text.empty())
mojom_params->title_text = base::UTF16ToUTF8(data.params->title_text);
for (auto& button : data.params->buttons) {
auto mojom_button = help_bubble::mojom::HelpBubbleButtonParams::New();
mojom_button->text = base::UTF16ToUTF8(button.text);
mojom_button->is_default = button.is_default;
mojom_params->buttons.emplace_back(std::move(mojom_button));
}
GetClient()->ShowHelpBubble(std::move(mojom_params));
return result;
}
void HelpBubbleHandlerBase::OnHelpBubbleClosing(
ui::ElementIdentifier anchor_id) {
const auto it = element_data_.find(anchor_id);
if (it == element_data_.end()) {
NOTREACHED() << "Identifier " << anchor_id << " was never registered.";
}
if (!it->second.closing)
GetClient()->HideHelpBubble(anchor_id.GetName());
it->second.help_bubble = nullptr;
it->second.params.reset();
// If this anchor element was only considered visible because it still had a
// help bubble, hide it.
if (it->second.element->visible() && !is_web_contents_visible()) {
it->second.element->SetVisible(false);
}
}
void HelpBubbleHandlerBase::OnWebContentsVisibilityChanged(
std::optional<bool> visibility) {
const bool old_visibility = is_web_contents_visible();
web_contents_visibility_ = visibility;
const bool new_visibility = is_web_contents_visible();
if (new_visibility == old_visibility) {
return;
}
// Callbacks during this call may cause almost anything to happen, so make
// sure that we bail if this object is destroyed.
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
for (auto& [id, data] : element_data_) {
if (new_visibility && data.visible) {
data.element->SetVisible(true, data.last_known_bounds);
} else if (!new_visibility && data.element->visible()) {
// An embedded help bubble prevents the element from being hidden.
// This usually only happens in WebUI that are hosted in browser tabs.
if (!data.has_webui_help_bubble()) {
data.element->SetVisible(false);
}
}
if (!weak_ptr) {
return;
}
}
}
void HelpBubbleHandlerBase::TrackedElementVisibilityChanged(
const std::string& identifier_name,
bool visible,
const gfx::RectF& rect) {
ui::ElementIdentifier id;
ElementData* const data = GetDataByName(identifier_name, &id);
if (!data)
return;
// Only set the bounds if the anchor is visible in the WebContents.
if (visible) {
data->last_known_bounds = rect;
// Also maybe check for the WebContents visibility.
if (!web_contents_visibility_.has_value()) {
web_contents_visibility_ = visibility_provider_->CheckIsVisible();
}
}
// It's possible the element is visible in the WebContents but the WebContents
// itself isn't visible. Save this value in case the two currently do not
// agree with each other.
data->visible = visible;
// An anchor which is currently hosting a WebUI help bubble ignores its
// WebContents' visibility. Otherwise, a hidden WebContents hides its anchors.
if (!data->has_webui_help_bubble()) {
visible = visible && is_web_contents_visible();
}
// Note: any of the following calls could destroy *this* via a callback.
if (visible) {
data->element->SetVisible(true, rect);
} else if (data->element->visible() && !visible) {
// Is a help bubble currently showing?
if (data->has_webui_help_bubble()) {
// Currently, this is the only call that could trigger callbacks and which
// has additional code which executes after it. If that changes, the weak
// pointer can be moved closer to the top of this method.
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
HelpBubbleClosed(
identifier_name,
help_bubble::mojom::HelpBubbleClosedReason::kPageChanged);
if (!weak_ptr)
return;
}
data->element->SetVisible(false);
}
}
void HelpBubbleHandlerBase::TrackedElementActivated(
const std::string& identifier_name) {
ui::ElementIdentifier id;
ElementData* const data = GetDataByName(identifier_name, &id);
if (!data)
return;
if (!data->element->visible()) {
ReportBadMessage(
base::StringPrintf("TrackedElementActivated message received for "
"anchor element \"%s\" but element was not visible.",
identifier_name.c_str()));
return;
}
data->element->Activate();
}
void HelpBubbleHandlerBase::TrackedElementCustomEvent(
const std::string& identifier_name,
const std::string& event_name) {
ui::ElementIdentifier id;
ElementData* const data = GetDataByName(identifier_name, &id);
if (!data)
return;
if (!data->element->visible()) {
ReportBadMessage(
base::StringPrintf("TrackedElementCustomEvent message received for "
"anchor element \"%s\" but element was not visible.",
identifier_name.c_str()));
return;
}
// Because names of events are lazily loaded the first time someone tries to
// listen for them, the name of a valid event may not be registered. So it's
// okay if this query comes up empty.
const ui::CustomElementEventType event_type =
ui::CustomElementEventType::FromName(event_name.c_str());
if (!event_type)
return;
data->element->CustomEvent(event_type);
}
void HelpBubbleHandlerBase::HelpBubbleButtonPressed(
const std::string& identifier_name,
uint8_t button_index) {
ElementData* const data = GetDataByName(identifier_name);
if (!data)
return;
if (!data->has_webui_help_bubble()) {
ReportBadMessage(
base::StringPrintf("HelpBubbleButtonPressed message received for "
"anchor element \"%s\" but no help bubble was open.",
identifier_name.c_str()));
return;
}
if (button_index >= data->params->buttons.size()) {
ReportBadMessage(base::StringPrintf(
"HelpBubbleButtonPressed received but button index was invalid; "
"got %u but there are only %zu buttons.",
button_index, data->params->buttons.size()));
return;
}
// We can never ensure that objects will persist across callbacks.
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
data->closing = true;
base::OnceClosure callback =
std::move(data->params->buttons[button_index].callback);
if (callback)
std::move(callback).Run();
if (!weak_ptr)
return;
if (data->help_bubble)
data->help_bubble->Close();
if (!weak_ptr)
return;
data->closing = false;
}
void HelpBubbleHandlerBase::HelpBubbleClosed(
const std::string& identifier_name,
help_bubble::mojom::HelpBubbleClosedReason reason) {
ElementData* const data = GetDataByName(identifier_name);
if (!data)
return;
if (!data->has_webui_help_bubble()) {
ReportBadMessage(base::StringPrintf(
"HelpBubbleClosed message received for identifier_name = \"%s\" but no "
"help bubble was open.",
identifier_name.c_str()));
return;
}
// We can never ensure that `this` will persist across callbacks.
auto weak_ptr = weak_ptr_factory_.GetWeakPtr();
data->closing = true;
base::OnceClosure callback;
switch (reason) {
case help_bubble::mojom::HelpBubbleClosedReason::kDismissedByUser:
callback = std::move(data->params->dismiss_callback);
break;
case help_bubble::mojom::HelpBubbleClosedReason::kTimedOut:
callback = std::move(data->params->timeout_callback);
break;
case help_bubble::mojom::HelpBubbleClosedReason::kPageChanged:
break;
}
if (callback) {
std::move(callback).Run();
if (!weak_ptr)
return;
}
// This could also theoretically trigger callbacks.
if (data->help_bubble) {
data->help_bubble->Close();
}
if (!weak_ptr)
return;
data->closing = false;
}
void HelpBubbleHandlerBase::BindTrackedElementHandler(
mojo::PendingReceiver<tracked_element::mojom::TrackedElementHandler>
handler) {
tracked_element_handler_receiver_.reset();
tracked_element_handler_receiver_.Bind(std::move(handler));
}
bool HelpBubbleHandlerBase::ToggleHelpBubbleFocusForAccessibility(
ui::ElementIdentifier anchor_id) {
if (base::Contains(element_data_, anchor_id)) {
GetClient()->ToggleFocusForAccessibility(anchor_id.GetName());
return true;
}
return false;
}
gfx::Rect HelpBubbleHandlerBase::GetHelpBubbleBoundsInScreen(
ui::ElementIdentifier anchor_id) const {
// TODO(dfried): implement.
return gfx::Rect();
}
void HelpBubbleHandlerBase::OnFloatingHelpBubbleCreated(
ui::ElementIdentifier anchor_id,
HelpBubble* help_bubble) {
GetClient()->ExternalHelpBubbleUpdated(anchor_id.GetName(), true);
const auto it = element_data_.find(anchor_id);
if (it == element_data_.end()) {
return;
}
DCHECK(!it->second.external_bubble_subscription);
it->second.external_bubble_subscription = help_bubble->AddOnCloseCallback(
base::BindOnce(&HelpBubbleHandlerBase::OnFloatingHelpBubbleClosed,
weak_ptr_factory_.GetWeakPtr(), anchor_id));
}
void HelpBubbleHandlerBase::OnFloatingHelpBubbleClosed(
ui::ElementIdentifier anchor_id,
HelpBubble* help_bubble,
HelpBubble::CloseReason) {
const auto it = element_data_.find(anchor_id);
if (it == element_data_.end()) {
return;
}
it->second.external_bubble_subscription = base::CallbackListSubscription();
GetClient()->ExternalHelpBubbleUpdated(anchor_id.GetName(), false);
}
HelpBubbleHandlerBase::ElementData* HelpBubbleHandlerBase::GetDataByName(
const std::string& identifier_name,
ui::ElementIdentifier* found_identifier) {
for (auto& [id, data] : element_data_) {
if (id.GetName() == identifier_name) {
if (found_identifier)
*found_identifier = id;
return &data;
}
}
if (found_identifier)
*found_identifier = ui::ElementIdentifier();
ReportBadMessage(base::StringPrintf(
"HelpBubbleHandler IPC message received with unrecognized "
"identifier_name: \"%s\"",
identifier_name.c_str()));
return nullptr;
}
class HelpBubbleHandler::ClientProvider
: public HelpBubbleHandlerBase::ClientProvider {
public:
explicit ClientProvider(
mojo::PendingRemote<help_bubble::mojom::HelpBubbleClient> pending_client)
: remote_client_(std::move(pending_client)) {}
~ClientProvider() override = default;
help_bubble::mojom::HelpBubbleClient* GetClient() override {
return remote_client_.get();
}
private:
mojo::Remote<help_bubble::mojom::HelpBubbleClient> remote_client_;
};
// Implementation of the WebContents visibility tracker.
class HelpBubbleHandler::VisibilityProvider
: public HelpBubbleHandlerBase::VisibilityProvider,
public content::WebContentsObserver {
public:
VisibilityProvider() = default;
~VisibilityProvider() override = default;
bool CheckIsVisible() override {
auto* const contents = handler()->GetWebContents();
CHECK(!web_contents());
Observe(contents);
return contents->GetVisibility() == content::Visibility::VISIBLE;
}
private:
// content::WebContentsObserver:
void OnVisibilityChanged(content::Visibility new_visibility) override {
SetLastKnownVisibility(new_visibility == content::Visibility::VISIBLE);
}
void WebContentsDestroyed() override { SetLastKnownVisibility(std::nullopt); }
};
HelpBubbleHandler::HelpBubbleHandler(
mojo::PendingReceiver<help_bubble::mojom::HelpBubbleHandler>
pending_handler,
mojo::PendingRemote<help_bubble::mojom::HelpBubbleClient> pending_client,
content::WebUIController* controller,
const std::vector<ui::ElementIdentifier>& identifiers)
: HelpBubbleHandler(
std::move(pending_handler),
std::move(pending_client),
base::BindRepeating(&GetWebContentsCallbackForWebUIController,
controller),
controller,
identifiers) {
DCHECK(controller);
}
HelpBubbleHandler::HelpBubbleHandler(
mojo::PendingReceiver<help_bubble::mojom::HelpBubbleHandler>
pending_handler,
mojo::PendingRemote<help_bubble::mojom::HelpBubbleClient> pending_client,
GetWebContentsCallback get_web_contents_callback,
void* context,
const std::vector<ui::ElementIdentifier>& identifiers)
: HelpBubbleHandlerBase(
std::make_unique<ClientProvider>(std::move(pending_client)),
std::make_unique<VisibilityProvider>(),
std::move(get_web_contents_callback),
identifiers,
ui::ElementContext(context, base::PassKey<HelpBubbleHandler>())),
receiver_(this, std::move(pending_handler)) {}
HelpBubbleHandler::~HelpBubbleHandler() = default;
void HelpBubbleHandler::ReportBadMessage(std::string_view error) {
receiver_.ReportBadMessage(std::move(error));
}
} // namespace user_education