blob: 553bde35e06b47132271e1d31668b8267a155db8 [file] [log] [blame]
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/ui/views/intent_picker_bubble_view.h"
#include <utility>
#include "base/feature_list.h"
#include "base/i18n/rtl.h"
#include "base/strings/string_piece.h"
#include "base/strings/utf_string_conversions.h"
#include "chrome/browser/apps/intent_helper/apps_navigation_throttle.h"
#include "chrome/browser/platform_util.h"
#include "chrome/browser/sharing/click_to_call/click_to_call_ui_controller.h"
#include "chrome/browser/ui/browser_dialogs.h"
#include "chrome/browser/ui/views/chrome_typography.h"
#include "chrome/browser/ui/views/toolbar/toolbar_ink_drop_util.h"
#include "chrome/browser/ui/views/toolbar/toolbar_view.h"
#include "chrome/common/chrome_features.h"
#include "chrome/grit/generated_resources.h"
#include "components/url_formatter/elide_url.h"
#include "content/public/browser/navigation_handle.h"
#include "third_party/skia/include/core/SkColor.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/strings/grit/ui_strings.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/animation/ink_drop.h"
#include "ui/views/animation/ink_drop_host_view.h"
#include "ui/views/border.h"
#include "ui/views/controls/button/checkbox.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/scroll_view.h"
#include "ui/views/controls/separator.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/grid_layout.h"
#if defined(OS_CHROMEOS)
#include "components/arc/intent_helper/arc_intent_helper_bridge.h"
#endif // defined(OS_CHROMEOS)
namespace {
// TODO(djacobo): Replace this limit to correctly reflect the UI mocks, which
// now instead of limiting the results to 3.5 will allow whatever fits in 256pt.
// Using |kMaxAppResults| as a measure of how many apps we want to show.
constexpr size_t kMaxAppResults = apps::AppsNavigationThrottle::kMaxAppResults;
// Main components sizes
constexpr int kTitlePadding = 16;
constexpr int kRowHeight = 32;
constexpr int kMaxIntentPickerLabelButtonWidth = 320;
constexpr gfx::Insets kSeparatorPadding(16, 0, 16, 0);
constexpr SkColor kSeparatorColor = SkColorSetARGB(0x1F, 0x0, 0x0, 0x0);
constexpr char kInvalidLaunchName[] = "";
bool IsKeyboardCodeArrow(ui::KeyboardCode key_code) {
return key_code == ui::VKEY_UP || key_code == ui::VKEY_DOWN ||
key_code == ui::VKEY_RIGHT || key_code == ui::VKEY_LEFT;
}
std::unique_ptr<views::Separator> CreateHorizontalSeparator() {
auto separator = std::make_unique<views::Separator>();
separator->SetColor(kSeparatorColor);
separator->SetBorder(views::CreateEmptyBorder(kSeparatorPadding));
return separator;
}
// Creates a label that is identical to CreateFrontElidingTitleLabel but has a
// different style as it is not shown as a title label.
std::unique_ptr<views::View> CreateOriginView(const url::Origin& origin,
int text_id) {
base::string16 origin_text = l10n_util::GetStringFUTF16(
text_id, url_formatter::FormatOriginForSecurityDisplay(origin));
auto label = std::make_unique<views::Label>(
origin_text, ChromeTextContext::CONTEXT_BODY_TEXT_SMALL,
views::style::STYLE_SECONDARY);
label->SetHorizontalAlignment(gfx::ALIGN_LEFT);
label->SetElideBehavior(gfx::ELIDE_HEAD);
label->SetMultiLine(false);
return label;
}
} // namespace
// IntentPickerLabelButton
// A button that represents a candidate intent handler.
class IntentPickerLabelButton : public views::LabelButton {
public:
IntentPickerLabelButton(views::ButtonListener* listener,
const gfx::Image* icon,
const std::string& display_name)
: LabelButton(listener,
base::UTF8ToUTF16(base::StringPiece(display_name))) {
SetHorizontalAlignment(gfx::ALIGN_LEFT);
SetMinSize(gfx::Size(kMaxIntentPickerLabelButtonWidth, kRowHeight));
SetInkDropMode(InkDropMode::ON);
if (!icon->IsEmpty())
SetImage(views::ImageButton::STATE_NORMAL, *icon->ToImageSkia());
SetBorder(views::CreateEmptyBorder(8, 16, 8, 0));
SetFocusForPlatform();
set_ink_drop_base_color(SK_ColorGRAY);
set_ink_drop_visible_opacity(kToolbarInkDropVisibleOpacity);
}
void MarkAsUnselected(const ui::Event* event) {
AnimateInkDrop(views::InkDropState::HIDDEN,
ui::LocatedEvent::FromIfValid(event));
}
void MarkAsSelected(const ui::Event* event) {
AnimateInkDrop(views::InkDropState::ACTIVATED,
ui::LocatedEvent::FromIfValid(event));
}
views::InkDropState GetTargetInkDropState() {
return GetInkDrop()->GetTargetInkDropState();
}
private:
DISALLOW_COPY_AND_ASSIGN(IntentPickerLabelButton);
};
// static
IntentPickerBubbleView* IntentPickerBubbleView::intent_picker_bubble_ = nullptr;
// static
views::Widget* IntentPickerBubbleView::ShowBubble(
views::View* anchor_view,
PageActionIconView* icon_view,
PageActionIconType icon_type,
content::WebContents* web_contents,
std::vector<AppInfo> app_info,
bool show_stay_in_chrome,
bool show_remember_selection,
const base::Optional<url::Origin>& initiating_origin,
IntentPickerResponse intent_picker_cb) {
if (intent_picker_bubble_) {
intent_picker_bubble_->Initialize();
views::Widget* widget =
views::BubbleDialogDelegateView::CreateBubble(intent_picker_bubble_);
widget->Show();
return widget;
}
intent_picker_bubble_ = new IntentPickerBubbleView(
anchor_view, icon_view, icon_type, std::move(app_info),
std::move(intent_picker_cb), web_contents, show_stay_in_chrome,
show_remember_selection, initiating_origin);
if (icon_view)
intent_picker_bubble_->SetHighlightedButton(icon_view);
intent_picker_bubble_->set_margins(gfx::Insets());
intent_picker_bubble_->Initialize();
views::Widget* widget =
views::BubbleDialogDelegateView::CreateBubble(intent_picker_bubble_);
// TODO(ellyjones): It should not at all be necessary to call Layout() here;
// it should have just happened during ::CreateBubble(). Figure out why this
// is here and/or simply delete it.
intent_picker_bubble_->GetWidget()->GetRootView()->Layout();
// TODO(aleventhal) Should not need to be focusable as only descendant widgets
// are interactive; however, it does call RequestFocus(). If it is going to be
// focusable, it needs an accessible name so that it can pass accessibility
// checks. Use the same accessible name as the icon. Set the role as kDialog
// to ensure screen readers immediately announce the text of this view.
intent_picker_bubble_->GetViewAccessibility().OverrideRole(
ax::mojom::Role::kDialog);
if (icon_type == PageActionIconType::kClickToCall) {
intent_picker_bubble_->GetViewAccessibility().OverrideName(
l10n_util::GetStringUTF16(
IDS_BROWSER_SHARING_CLICK_TO_CALL_DIALOG_TITLE_LABEL));
ClickToCallUiController::GetOrCreateFromWebContents(web_contents)
->ClearLastDialog();
} else {
DCHECK(icon_type == PageActionIconType::kIntentPicker);
intent_picker_bubble_->GetViewAccessibility().OverrideName(
l10n_util::GetStringUTF16(IDS_TOOLTIP_INTENT_PICKER_ICON));
}
intent_picker_bubble_->SetFocusBehavior(View::FocusBehavior::ALWAYS);
DCHECK(intent_picker_bubble_->HasCandidates());
intent_picker_bubble_->GetIntentPickerLabelButtonAt(0)->MarkAsSelected(
nullptr);
widget->Show();
return widget;
}
// static
std::unique_ptr<IntentPickerBubbleView>
IntentPickerBubbleView::CreateBubbleViewForTesting(
views::View* anchor_view,
PageActionIconView* icon_view,
PageActionIconType icon_type,
std::vector<AppInfo> app_info,
bool show_stay_in_chrome,
bool show_remember_selection,
const base::Optional<url::Origin>& initiating_origin,
IntentPickerResponse intent_picker_cb,
content::WebContents* web_contents) {
auto bubble = std::make_unique<IntentPickerBubbleView>(
anchor_view, icon_view, icon_type, std::move(app_info),
std::move(intent_picker_cb), web_contents, show_stay_in_chrome,
show_remember_selection, initiating_origin);
bubble->Initialize();
return bubble;
}
// static
void IntentPickerBubbleView::CloseCurrentBubble() {
if (intent_picker_bubble_)
intent_picker_bubble_->CloseBubble();
}
void IntentPickerBubbleView::CloseBubble() {
ClearBubbleView();
LocationBarBubbleDelegateView::CloseBubble();
}
void IntentPickerBubbleView::OnDialogAccepted() {
bool should_persist = remember_selection_checkbox_ &&
remember_selection_checkbox_->GetChecked();
RunCallbackAndCloseBubble(app_info_[selected_app_tag_].launch_name,
app_info_[selected_app_tag_].type,
apps::IntentPickerCloseReason::OPEN_APP,
should_persist);
}
void IntentPickerBubbleView::OnDialogCancelled() {
const char* launch_name =
#if defined(OS_CHROMEOS)
arc::ArcIntentHelperBridge::kArcIntentHelperPackageName;
#else
kInvalidLaunchName;
#endif
bool should_persist = remember_selection_checkbox_ &&
remember_selection_checkbox_->GetChecked();
RunCallbackAndCloseBubble(launch_name, apps::PickerEntryType::kUnknown,
apps::IntentPickerCloseReason::STAY_IN_CHROME,
should_persist);
}
void IntentPickerBubbleView::OnDialogClosed() {
// Whenever closing the bubble without pressing |Just once| or |Always| we
// need to report back that the user didn't select anything.
RunCallbackAndCloseBubble(kInvalidLaunchName, apps::PickerEntryType::kUnknown,
apps::IntentPickerCloseReason::DIALOG_DEACTIVATED,
false);
}
bool IntentPickerBubbleView::ShouldShowCloseButton() const {
return true;
}
base::string16 IntentPickerBubbleView::GetWindowTitle() const {
if (icon_type_ == PageActionIconType::kClickToCall) {
return l10n_util::GetStringUTF16(
IDS_BROWSER_SHARING_CLICK_TO_CALL_DIALOG_TITLE_LABEL);
}
DCHECK(icon_type_ == PageActionIconType::kIntentPicker);
return l10n_util::GetStringUTF16(IDS_INTENT_PICKER_BUBBLE_VIEW_OPEN_WITH);
}
IntentPickerBubbleView::IntentPickerBubbleView(
views::View* anchor_view,
PageActionIconView* icon_view,
PageActionIconType icon_type,
std::vector<AppInfo> app_info,
IntentPickerResponse intent_picker_cb,
content::WebContents* web_contents,
bool show_stay_in_chrome,
bool show_remember_selection,
const base::Optional<url::Origin>& initiating_origin)
: LocationBarBubbleDelegateView(anchor_view, web_contents),
intent_picker_cb_(std::move(intent_picker_cb)),
selected_app_tag_(0),
app_info_(std::move(app_info)),
show_stay_in_chrome_(show_stay_in_chrome),
show_remember_selection_(show_remember_selection),
icon_view_(icon_view),
icon_type_(icon_type),
initiating_origin_(initiating_origin) {
DialogDelegate::SetButtons(
show_stay_in_chrome_ ? (ui::DIALOG_BUTTON_OK | ui::DIALOG_BUTTON_CANCEL)
: ui::DIALOG_BUTTON_OK);
DialogDelegate::SetButtonLabel(
ui::DIALOG_BUTTON_OK,
l10n_util::GetStringUTF16(
icon_type_ == PageActionIconType::kClickToCall
? IDS_BROWSER_SHARING_CLICK_TO_CALL_DIALOG_CALL_BUTTON_LABEL
: IDS_INTENT_PICKER_BUBBLE_VIEW_OPEN));
DialogDelegate::SetButtonLabel(
ui::DIALOG_BUTTON_CANCEL,
l10n_util::GetStringUTF16(IDS_INTENT_PICKER_BUBBLE_VIEW_STAY_IN_CHROME));
DialogDelegate::SetAcceptCallback(base::Bind(
&IntentPickerBubbleView::OnDialogAccepted, base::Unretained(this)));
DialogDelegate::SetCancelCallback(base::Bind(
&IntentPickerBubbleView::OnDialogCancelled, base::Unretained(this)));
DialogDelegate::SetCloseCallback(base::Bind(
&IntentPickerBubbleView::OnDialogClosed, base::Unretained(this)));
// Click to call bubbles need to be closed after navigation if the main frame
// origin changed. Other intent picker bubbles will be handled in
// AppsNavigationThrottle, they will get closed on each navigation start and
// should stay open until after navigation finishes.
set_close_on_main_frame_origin_navigation(icon_type ==
PageActionIconType::kClickToCall);
chrome::RecordDialogCreation(chrome::DialogIdentifier::INTENT_PICKER);
}
IntentPickerBubbleView::~IntentPickerBubbleView() {
SetLayoutManager(nullptr);
}
// If the widget gets closed without an app being selected we still need to use
// the callback so the caller can Resume the navigation.
void IntentPickerBubbleView::OnWidgetDestroying(views::Widget* widget) {
RunCallbackAndCloseBubble(kInvalidLaunchName, apps::PickerEntryType::kUnknown,
apps::IntentPickerCloseReason::DIALOG_DEACTIVATED,
false);
}
void IntentPickerBubbleView::ButtonPressed(views::Button* sender,
const ui::Event& event) {
SetSelectedAppIndex(sender->tag(), &event);
RequestFocus();
}
void IntentPickerBubbleView::ArrowButtonPressed(int index) {
SetSelectedAppIndex(index, nullptr);
AdjustScrollViewVisibleRegion();
}
void IntentPickerBubbleView::OnKeyEvent(ui::KeyEvent* event) {
if (!IsKeyboardCodeArrow(event->key_code()) ||
event->type() != ui::ET_KEY_RELEASED)
return;
int delta = 0;
switch (event->key_code()) {
case ui::VKEY_UP:
delta = -1;
break;
case ui::VKEY_DOWN:
delta = 1;
break;
case ui::VKEY_LEFT:
delta = base::i18n::IsRTL() ? 1 : -1;
break;
case ui::VKEY_RIGHT:
delta = base::i18n::IsRTL() ? -1 : 1;
break;
default:
NOTREACHED();
break;
}
ArrowButtonPressed(CalculateNextAppIndex(delta));
View::OnKeyEvent(event);
}
void IntentPickerBubbleView::Initialize() {
views::GridLayout* layout =
SetLayoutManager(std::make_unique<views::GridLayout>());
// Creates a view to hold the views for each app.
auto scrollable_view = std::make_unique<views::View>();
scrollable_view->SetLayoutManager(std::make_unique<views::BoxLayout>(
views::BoxLayout::Orientation::kVertical));
size_t i = 0;
size_t to_erase = app_info_.size();
for (const auto& app_info : app_info_) {
#if defined(OS_CHROMEOS)
if (arc::ArcIntentHelperBridge::IsIntentHelperPackage(
app_info.launch_name)) {
to_erase = i;
continue;
}
#endif // defined(OS_CHROMEOS)
auto app_button = std::make_unique<IntentPickerLabelButton>(
this, &app_info.icon, app_info.display_name);
app_button->set_tag(i);
scrollable_view->AddChildViewAt(std::move(app_button), i++);
}
// We should delete at most one entry, this is the case when Chrome is listed
// as a candidate to handle a given URL.
if (to_erase != app_info_.size())
app_info_.erase(app_info_.begin() + to_erase);
auto scroll_view = std::make_unique<views::ScrollView>();
scroll_view->SetBackgroundThemeColorId(
ui::NativeTheme::kColorId_BubbleBackground);
scroll_view->SetContents(std::move(scrollable_view));
// This part gives the scroll a fixed width and height. The height depends on
// how many app candidates we got and how many we actually want to show.
// The added 0.5 on the else block allow us to let the user know there are
// more than |kMaxAppResults| apps accessible by scrolling the list.
scroll_view->ClipHeightTo(kRowHeight, (kMaxAppResults + 0.5) * kRowHeight);
constexpr int kColumnSetId = 0;
views::ColumnSet* cs = layout->AddColumnSet(kColumnSetId);
cs->AddColumn(views::GridLayout::FILL, views::GridLayout::CENTER,
views::GridLayout::kFixedSize, views::GridLayout::FIXED,
kMaxIntentPickerLabelButtonWidth, 0);
layout->StartRowWithPadding(views::GridLayout::kFixedSize, kColumnSetId,
views::GridLayout::kFixedSize, kTitlePadding);
scroll_view_ = layout->AddView(std::move(scroll_view));
if (initiating_origin_ &&
!initiating_origin_->IsSameOriginWith(
web_contents()->GetMainFrame()->GetLastCommittedOrigin())) {
constexpr int kColumnSetIdOrigin = 1;
views::ColumnSet* cs_origin = layout->AddColumnSet(kColumnSetIdOrigin);
cs_origin->AddPaddingColumn(views::GridLayout::kFixedSize, kTitlePadding);
cs_origin->AddColumn(
views::GridLayout::FILL, views::GridLayout::CENTER,
views::GridLayout::kFixedSize, views::GridLayout::FIXED,
kMaxIntentPickerLabelButtonWidth - 2 * kTitlePadding, 0);
layout->StartRowWithPadding(views::GridLayout::kFixedSize,
kColumnSetIdOrigin,
views::GridLayout::kFixedSize, kTitlePadding);
layout->AddView(CreateOriginView(
*initiating_origin_,
icon_type_ == PageActionIconType::kClickToCall
? IDS_BROWSER_SHARING_CLICK_TO_CALL_DIALOG_INITIATING_ORIGIN
: IDS_INTENT_PICKER_BUBBLE_VIEW_INITIATING_ORIGIN));
}
layout->StartRow(views::GridLayout::kFixedSize, kColumnSetId, 0);
if (show_remember_selection_) {
layout->AddView(CreateHorizontalSeparator());
// This second ColumnSet has a padding column in order to manipulate the
// Checkbox positioning freely.
constexpr int kColumnSetIdPadded = 2;
views::ColumnSet* cs_padded = layout->AddColumnSet(kColumnSetIdPadded);
cs_padded->AddPaddingColumn(views::GridLayout::kFixedSize, kTitlePadding);
cs_padded->AddColumn(
views::GridLayout::FILL, views::GridLayout::CENTER,
views::GridLayout::kFixedSize, views::GridLayout::FIXED,
kMaxIntentPickerLabelButtonWidth - 2 * kTitlePadding, 0);
layout->StartRowWithPadding(views::GridLayout::kFixedSize,
kColumnSetIdPadded,
views::GridLayout::kFixedSize, 0);
remember_selection_checkbox_ = layout->AddView(
std::make_unique<views::Checkbox>(l10n_util::GetStringUTF16(
IDS_INTENT_PICKER_BUBBLE_VIEW_REMEMBER_SELECTION)));
UpdateCheckboxState();
}
layout->AddPaddingRow(views::GridLayout::kFixedSize, kTitlePadding);
}
IntentPickerLabelButton* IntentPickerBubbleView::GetIntentPickerLabelButtonAt(
size_t index) {
const auto& children = scroll_view_->contents()->children();
return static_cast<IntentPickerLabelButton*>(children[index]);
}
bool IntentPickerBubbleView::HasCandidates() const {
return !app_info_.empty();
}
void IntentPickerBubbleView::RunCallbackAndCloseBubble(
const std::string& launch_name,
apps::PickerEntryType entry_type,
apps::IntentPickerCloseReason close_reason,
bool should_persist) {
if (!intent_picker_cb_.is_null()) {
// Calling Run() will make |intent_picker_cb_| null.
std::move(intent_picker_cb_)
.Run(launch_name, entry_type, close_reason, should_persist);
}
ClearBubbleView();
}
size_t IntentPickerBubbleView::GetScrollViewSize() const {
return scroll_view_->contents()->children().size();
}
void IntentPickerBubbleView::AdjustScrollViewVisibleRegion() {
const views::ScrollBar* bar = scroll_view_->vertical_scroll_bar();
if (bar) {
scroll_view_->ScrollToPosition(const_cast<views::ScrollBar*>(bar),
(selected_app_tag_ - 1) * kRowHeight);
}
}
void IntentPickerBubbleView::SetSelectedAppIndex(int index,
const ui::Event* event) {
// The selected app must be a value in the range [0, app_info_.size()-1].
DCHECK(HasCandidates());
DCHECK_LT(static_cast<size_t>(index), app_info_.size());
DCHECK_GE(static_cast<size_t>(index), 0u);
GetIntentPickerLabelButtonAt(selected_app_tag_)->MarkAsUnselected(nullptr);
selected_app_tag_ = index;
GetIntentPickerLabelButtonAt(selected_app_tag_)->MarkAsSelected(event);
UpdateCheckboxState();
}
size_t IntentPickerBubbleView::CalculateNextAppIndex(int delta) {
size_t size = GetScrollViewSize();
return static_cast<size_t>((selected_app_tag_ + size + delta) % size);
}
void IntentPickerBubbleView::UpdateCheckboxState() {
if (!remember_selection_checkbox_)
return;
// TODO(crbug.com/826982): allow PWAs to have their decision persisted when
// there is a central Chrome OS apps registry to store persistence.
// TODO(crbug.com/1000037): allow to persist remote devices too.
bool should_enable = false;
if (base::FeatureList::IsEnabled(features::kAppServiceIntentHandling)) {
should_enable = true;
} else {
auto selected_app_type = app_info_[selected_app_tag_].type;
should_enable = selected_app_type != apps::PickerEntryType::kWeb &&
selected_app_type != apps::PickerEntryType::kDevice;
}
// Reset the checkbox state to the default unchecked if becomes disabled.
if (!should_enable)
remember_selection_checkbox_->SetChecked(false);
remember_selection_checkbox_->SetEnabled(should_enable);
}
void IntentPickerBubbleView::ClearBubbleView() {
intent_picker_bubble_ = nullptr;
if (icon_view_)
icon_view_->Update();
}
gfx::ImageSkia IntentPickerBubbleView::GetAppImageForTesting(size_t index) {
return GetIntentPickerLabelButtonAt(index)->GetImage(
views::Button::ButtonState::STATE_NORMAL);
}
views::InkDropState IntentPickerBubbleView::GetInkDropStateForTesting(
size_t index) {
return GetIntentPickerLabelButtonAt(index)->GetTargetInkDropState();
}
void IntentPickerBubbleView::PressButtonForTesting(size_t index,
const ui::Event& event) {
views::Button* button =
static_cast<views::Button*>(GetIntentPickerLabelButtonAt(index));
ButtonPressed(button, event);
}