| // Copyright 2019 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/views/extensions/extensions_menu_view.h" |
| |
| #include "base/containers/contains.h" |
| #include "base/feature_list.h" |
| #include "base/i18n/case_conversion.h" |
| #include "base/memory/ptr_util.h" |
| #include "base/ranges/algorithm.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/ui/browser.h" |
| #include "chrome/browser/ui/chrome_pages.h" |
| #include "chrome/browser/ui/extensions/extension_action_view_controller.h" |
| #include "chrome/browser/ui/extensions/extensions_container.h" |
| #include "chrome/browser/ui/tabs/tab_strip_model.h" |
| #include "chrome/browser/ui/toolbar/toolbar_action_view_controller.h" |
| #include "chrome/browser/ui/views/bubble_menu_item_factory.h" |
| #include "chrome/browser/ui/views/chrome_layout_provider.h" |
| #include "chrome/browser/ui/views/chrome_typography.h" |
| #include "chrome/browser/ui/views/controls/hover_button.h" |
| #include "chrome/browser/ui/views/extensions/extensions_menu_item_view.h" |
| #include "chrome/grit/generated_resources.h" |
| #include "components/vector_icons/vector_icons.h" |
| #include "extensions/common/extension_features.h" |
| #include "third_party/skia/include/core/SkPath.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/metadata/metadata_impl_macros.h" |
| #include "ui/color/color_id.h" |
| #include "ui/color/color_provider.h" |
| #include "ui/gfx/color_utils.h" |
| #include "ui/gfx/paint_vector_icon.h" |
| #include "ui/views/accessibility/view_accessibility.h" |
| #include "ui/views/animation/ink_drop_host.h" |
| #include "ui/views/border.h" |
| #include "ui/views/controls/label.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/flex_layout.h" |
| #include "ui/views/style/typography.h" |
| #include "ui/views/view_class_properties.h" |
| #include "ui/views/view_utils.h" |
| |
| namespace { |
| // If true, allows more than one instance of the ExtensionsMenuView, which may |
| // not be the active instance in g_extensions_dialog. |
| bool g_allow_testing_dialogs = false; |
| |
| ExtensionsMenuView* g_extensions_dialog = nullptr; |
| |
| constexpr int EXTENSIONS_SETTINGS_ID = 42; |
| |
| bool CompareExtensionMenuItemViews(const ExtensionMenuItemView* a, |
| const ExtensionMenuItemView* b) { |
| return base::i18n::ToLower(a->view_controller()->GetActionName()) < |
| base::i18n::ToLower(b->view_controller()->GetActionName()); |
| } |
| |
| // A helper method to convert to an ExtensionMenuItemView. This cannot |
| // be used to *determine* if a view is an ExtensionMenuItemView (it |
| // should only be used when the view is known to be one). It is only used as an |
| // extra measure to prevent bad static casts. |
| ExtensionMenuItemView* GetAsMenuItemView(views::View* view) { |
| DCHECK(views::IsViewClass<ExtensionMenuItemView>(view)); |
| return static_cast<ExtensionMenuItemView*>(view); |
| } |
| |
| } // namespace |
| |
| ExtensionsMenuView::ExtensionsMenuView( |
| views::View* anchor_view, |
| Browser* browser, |
| ExtensionsContainer* extensions_container) |
| : BubbleDialogDelegateView(anchor_view, |
| views::BubbleBorder::Arrow::TOP_RIGHT), |
| browser_(browser), |
| extensions_container_(extensions_container), |
| toolbar_model_(ToolbarActionsModel::Get(browser_->profile())), |
| cant_access_{nullptr, nullptr, |
| IDS_EXTENSIONS_MENU_CANT_ACCESS_SITE_DATA_SHORT, |
| IDS_EXTENSIONS_MENU_CANT_ACCESS_SITE_DATA, |
| extensions::SitePermissionsHelper::SiteInteraction::kNone}, |
| wants_access_{ |
| nullptr, nullptr, IDS_EXTENSIONS_MENU_WANTS_TO_ACCESS_SITE_DATA_SHORT, |
| IDS_EXTENSIONS_MENU_WANTS_TO_ACCESS_SITE_DATA, |
| extensions::SitePermissionsHelper::SiteInteraction::kWithheld}, |
| has_access_{ |
| nullptr, nullptr, IDS_EXTENSIONS_MENU_ACCESSING_SITE_DATA_SHORT, |
| IDS_EXTENSIONS_MENU_ACCESSING_SITE_DATA, |
| extensions::SitePermissionsHelper::SiteInteraction::kGranted} { |
| // Ensure layer masking is used for the extensions menu to ensure buttons with |
| // layer effects sitting flush with the bottom of the bubble are clipped |
| // appropriately. |
| SetPaintClientToLayer(true); |
| |
| toolbar_model_observation_.Observe(toolbar_model_.get()); |
| browser_->tab_strip_model()->AddObserver(this); |
| set_margins(gfx::Insets(0)); |
| |
| SetButtons(ui::DIALOG_BUTTON_NONE); |
| SetShowCloseButton(true); |
| SetTitle(IDS_EXTENSIONS_MENU_TITLE); |
| |
| // ExtensionsMenuView::GetAccessibleWindowTitle always returns an empty |
| // string. This was done to prevent repetition of "Alert Extensions" |
| // when the user selects Extensions from the Desktop PWA three dot menu. |
| // See crrev.com/c/2661700. Should that change, kAttributeExplicitlyEmpty |
| // will not be appropriate. |
| ax::mojom::NameFrom name_from = |
| GetAccessibleWindowTitle().empty() |
| ? ax::mojom::NameFrom::kAttributeExplicitlyEmpty |
| : ax::mojom::NameFrom::kAttribute; |
| GetViewAccessibility().OverrideName(GetAccessibleWindowTitle(), name_from); |
| |
| SetEnableArrowKeyTraversal(true); |
| |
| // Let anchor view's MenuButtonController handle the highlight. |
| set_highlight_button_when_shown(false); |
| |
| set_fixed_width(views::LayoutProvider::Get()->GetDistanceMetric( |
| views::DISTANCE_BUBBLE_PREFERRED_WIDTH)); |
| SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kVertical)); |
| Populate(); |
| } |
| |
| ExtensionsMenuView::~ExtensionsMenuView() { |
| if (!g_allow_testing_dialogs) |
| DCHECK_EQ(g_extensions_dialog, this); |
| g_extensions_dialog = nullptr; |
| extensions_menu_items_.clear(); |
| |
| // Note: No need to call TabStripModel::RemoveObserver(), because it's handled |
| // directly within TabStripModelObserver::~TabStripModelObserver(). |
| } |
| |
| void ExtensionsMenuView::Populate() { |
| // The actions for the profile haven't been initialized yet. We'll call in |
| // again once they have. |
| if (!toolbar_model_->actions_initialized()) |
| return; |
| |
| DCHECK(children().empty()) << "Populate() can only be called once!"; |
| |
| auto extension_buttons = CreateExtensionButtonsContainer(); |
| |
| // This is set so that the extensions menu doesn't fall outside the monitor in |
| // a maximized window in 1024x768. See https://crbug.com/1096630. |
| // TODO(pbos): Consider making this dynamic and handled by views. Ideally we |
| // wouldn't ever pop up so that they pop outside the screen. |
| constexpr int kMaxExtensionButtonsHeightDp = 448; |
| auto scroll_view = std::make_unique<views::ScrollView>(); |
| scroll_view->ClipHeightTo(0, kMaxExtensionButtonsHeightDp); |
| scroll_view->SetDrawOverflowIndicator(false); |
| scroll_view->SetHorizontalScrollBarMode( |
| views::ScrollView::ScrollBarMode::kDisabled); |
| scroll_view->SetContents(std::move(extension_buttons)); |
| AddChildView(std::move(scroll_view)); |
| |
| AddChildView(std::make_unique<views::Separator>()); |
| |
| // TODO(pbos): Consider moving this a footnote view (::SetFootnoteView()). |
| // If so this needs to be created before being added to a widget, constructor |
| // would do. |
| auto footer = CreateBubbleMenuItem( |
| EXTENSIONS_SETTINGS_ID, l10n_util::GetStringUTF16(IDS_MANAGE_EXTENSIONS), |
| base::BindRepeating(&chrome::ShowExtensions, browser_, std::string())); |
| |
| // TODO(emiliapaz): Note that `DISTANCE_EXTENSIONS_MENU_ICON_SPACING` relies |
| // on CreateBubbleMenuItem() using the same inset as |
| // `DISTANCE_EXTENSIONS_MENU_BUTTON_MARGIN`. |
| ChromeLayoutProvider* provider = ChromeLayoutProvider::Get(); |
| const int icon_spacing = |
| provider->GetDistanceMetric(DISTANCE_EXTENSIONS_MENU_ICON_SPACING); |
| footer->SetBorder(views::CreateEmptyBorder( |
| footer->GetInsets() + gfx::Insets::TLBR(0, icon_spacing, 0, 0))); |
| footer->SetImageLabelSpacing(footer->GetImageLabelSpacing() + icon_spacing); |
| footer->SetImageModel(views::Button::STATE_NORMAL, |
| ui::ImageModel::FromVectorIcon( |
| vector_icons::kSettingsIcon, ui::kColorIcon, |
| provider->GetDistanceMetric( |
| DISTANCE_EXTENSIONS_MENU_BUTTON_ICON_SIZE))); |
| |
| manage_extensions_button_ = footer.get(); |
| AddChildView(std::move(footer)); |
| |
| // Add menu items for each extension. |
| for (const auto& id : toolbar_model_->action_ids()) |
| CreateAndInsertNewItem(id); |
| |
| SortMenuItemsByName(); |
| UpdateSectionVisibility(); |
| |
| SanityCheck(); |
| } |
| |
| std::unique_ptr<views::View> |
| ExtensionsMenuView::CreateExtensionButtonsContainer() { |
| auto extension_buttons = std::make_unique<views::View>(); |
| extension_buttons->SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kVertical)); |
| |
| auto create_section = |
| [&extension_buttons](Section* section) { |
| auto container = std::make_unique<views::View>(); |
| section->container = container.get(); |
| container->SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kVertical)); |
| |
| const int horizontal_spacing = |
| ChromeLayoutProvider::Get()->GetDistanceMetric( |
| views::DISTANCE_BUTTON_HORIZONTAL_PADDING); |
| |
| // Add an emphasized short header explaining the section. |
| auto header = std::make_unique<views::Label>( |
| l10n_util::GetStringUTF16(section->header_string_id), |
| ChromeTextContext::CONTEXT_DIALOG_BODY_TEXT_SMALL, |
| views::style::STYLE_EMPHASIZED); |
| header->SetHorizontalAlignment(gfx::ALIGN_LEFT); |
| header->SetBorder(views::CreateEmptyBorder( |
| gfx::Insets::TLBR(ChromeLayoutProvider::Get()->GetDistanceMetric( |
| DISTANCE_CONTROL_LIST_VERTICAL), |
| horizontal_spacing, 0, horizontal_spacing))); |
| container->AddChildView(std::move(header)); |
| |
| // Add longer text that explains the section in more detail. |
| auto description = std::make_unique<views::Label>( |
| l10n_util::GetStringUTF16(section->description_string_id), |
| ChromeTextContext::CONTEXT_DIALOG_BODY_TEXT_SMALL, |
| views::style::STYLE_PRIMARY); |
| description->SetMultiLine(true); |
| description->SetHorizontalAlignment(gfx::ALIGN_LEFT); |
| description->SetBorder(views::CreateEmptyBorder( |
| gfx::Insets::TLBR(0, horizontal_spacing, 0, horizontal_spacing))); |
| container->AddChildView(std::move(description)); |
| |
| // Add a (currently empty) section for the menu items of the section. |
| auto menu_items = std::make_unique<views::View>(); |
| menu_items->SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kVertical)); |
| section->menu_items = menu_items.get(); |
| container->AddChildView(std::move(menu_items)); |
| |
| // Start off with the section invisible. We'll update it as we add items |
| // if necessary. |
| container->SetVisible(false); |
| |
| extension_buttons->AddChildView(std::move(container)); |
| }; |
| |
| create_section(&has_access_); |
| create_section(&wants_access_); |
| create_section(&cant_access_); |
| |
| return extension_buttons; |
| } |
| |
| ExtensionsMenuView::Section* ExtensionsMenuView::GetSectionForSiteInteraction( |
| extensions::SitePermissionsHelper::SiteInteraction site_interaction) { |
| Section* section = nullptr; |
| switch (site_interaction) { |
| case extensions::SitePermissionsHelper::SiteInteraction::kNone: |
| section = &cant_access_; |
| break; |
| case extensions::SitePermissionsHelper::SiteInteraction::kWithheld: |
| case extensions::SitePermissionsHelper::SiteInteraction::kActiveTab: |
| section = &wants_access_; |
| break; |
| case extensions::SitePermissionsHelper::SiteInteraction::kGranted: |
| section = &has_access_; |
| break; |
| } |
| DCHECK(section); |
| return section; |
| } |
| |
| void ExtensionsMenuView::SortMenuItemsByName() { |
| auto sort_section = [](Section* section) { |
| if (section->menu_items->children().empty()) |
| return; |
| |
| std::vector<ExtensionMenuItemView*> menu_item_views; |
| for (views::View* view : section->menu_items->children()) |
| menu_item_views.push_back(GetAsMenuItemView(view)); |
| |
| std::sort(menu_item_views.begin(), menu_item_views.end(), |
| &CompareExtensionMenuItemViews); |
| for (size_t i = 0; i < menu_item_views.size(); ++i) |
| section->menu_items->ReorderChildView(menu_item_views[i], i); |
| }; |
| |
| sort_section(&has_access_); |
| sort_section(&wants_access_); |
| sort_section(&cant_access_); |
| } |
| |
| void ExtensionsMenuView::CreateAndInsertNewItem( |
| const ToolbarActionsModel::ActionId& id) { |
| std::unique_ptr<ExtensionActionViewController> controller = |
| ExtensionActionViewController::Create(id, browser_, |
| extensions_container_); |
| |
| // The bare `new` is safe here, because InsertMenuItem is guaranteed to |
| // be added to the view hierarchy, which takes ownership. |
| auto* item = new ExtensionMenuItemView( |
| browser_, std::move(controller), |
| extensions_container_->CanShowActionsInToolbar()); |
| extensions_menu_items_.insert(item); |
| InsertMenuItem(item); |
| // Sanity check that the item was added. |
| DCHECK(Contains(item)); |
| } |
| |
| void ExtensionsMenuView::InsertMenuItem(ExtensionMenuItemView* menu_item) { |
| DCHECK(!Contains(menu_item)) |
| << "Trying to insert a menu item that is already added in a section!"; |
| auto site_interaction = menu_item->view_controller()->GetSiteInteraction( |
| browser_->tab_strip_model()->GetActiveWebContents()); |
| Section* const section = GetSectionForSiteInteraction(site_interaction); |
| // Add the view at the end. Note that this *doesn't* insert the item at the |
| // correct spot or ensure the view is visible; it's assumed that any callers |
| // will handle those separately. |
| section->menu_items->AddChildView(menu_item); |
| } |
| |
| void ExtensionsMenuView::UpdateSectionVisibility() { |
| auto update_section = [](Section* section) { |
| bool should_be_visible = !section->menu_items->children().empty(); |
| if (section->container->GetVisible() != should_be_visible) |
| section->container->SetVisible(should_be_visible); |
| }; |
| |
| update_section(&has_access_); |
| update_section(&wants_access_); |
| update_section(&cant_access_); |
| } |
| |
| void ExtensionsMenuView::Update() { |
| for (ExtensionMenuItemView* view : extensions_menu_items_) { |
| view->view_controller()->UpdateState(); |
| } |
| |
| content::WebContents* const web_contents = |
| browser_->tab_strip_model()->GetActiveWebContents(); |
| auto move_children_between_sections_if_necessary = [this, web_contents]( |
| Section* section) { |
| // Note: Collect the views to move separately, so that we don't change the |
| // children of the view during iteration. |
| std::vector<ExtensionMenuItemView*> views_to_move; |
| for (views::View* view : section->menu_items->children()) { |
| auto* menu_item = GetAsMenuItemView(view); |
| auto site_interaction = |
| menu_item->view_controller()->GetSiteInteraction(web_contents); |
| if (site_interaction == section->site_interaction) |
| continue; |
| views_to_move.push_back(menu_item); |
| } |
| |
| for (ExtensionMenuItemView* menu_item : views_to_move) { |
| section->menu_items->RemoveChildView(menu_item); |
| InsertMenuItem(menu_item); |
| } |
| }; |
| |
| move_children_between_sections_if_necessary(&has_access_); |
| move_children_between_sections_if_necessary(&wants_access_); |
| move_children_between_sections_if_necessary(&cant_access_); |
| |
| SortMenuItemsByName(); |
| UpdateSectionVisibility(); |
| |
| SanityCheck(); |
| } |
| |
| void ExtensionsMenuView::SanityCheck() { |
| #if DCHECK_IS_ON() |
| content::WebContents* web_contents = |
| browser_->tab_strip_model()->GetActiveWebContents(); |
| |
| // Sanity checks: verify that all extensions are properly sorted and in the |
| // correct section. |
| auto check_section = [this, web_contents](Section* section) { |
| std::vector<ExtensionMenuItemView*> menu_items; |
| for (views::View* view : section->menu_items->children()) { |
| auto* menu_item = GetAsMenuItemView(view); |
| auto site_interaction = |
| menu_item->view_controller()->GetSiteInteraction(web_contents); |
| DCHECK_EQ(section, GetSectionForSiteInteraction(site_interaction)); |
| menu_items.push_back(menu_item); |
| } |
| DCHECK(std::is_sorted(menu_items.begin(), menu_items.end(), |
| CompareExtensionMenuItemViews)); |
| }; |
| |
| check_section(&has_access_); |
| check_section(&wants_access_); |
| check_section(&cant_access_); |
| |
| const base::flat_set<std::string>& action_ids = toolbar_model_->action_ids(); |
| DCHECK_EQ(action_ids.size(), extensions_menu_items_.size()); |
| |
| // Check that all items are owned by the view hierarchy, and that each |
| // corresponds to an item in the model (since we already checked that the size |
| // is equal for |action_ids| and |extensions_menu_items_|, this implicitly |
| // guarantees that we have a view per item in |action_ids| as well). |
| for (ExtensionMenuItemView* item : extensions_menu_items_) { |
| DCHECK(Contains(item)); |
| DCHECK(base::Contains(action_ids, item->view_controller()->GetId())); |
| } |
| #endif |
| } |
| |
| std::u16string ExtensionsMenuView::GetAccessibleWindowTitle() const { |
| // The title is already spoken via the call to SetTitle(). |
| return std::u16string(); |
| } |
| |
| void ExtensionsMenuView::TabChangedAt(content::WebContents* contents, |
| int index, |
| TabChangeType change_type) { |
| Update(); |
| } |
| |
| void ExtensionsMenuView::OnTabStripModelChanged( |
| TabStripModel* tab_strip_model, |
| const TabStripModelChange& change, |
| const TabStripSelectionChange& selection) { |
| Update(); |
| } |
| |
| void ExtensionsMenuView::OnToolbarActionAdded( |
| const ToolbarActionsModel::ActionId& item) { |
| CreateAndInsertNewItem(item); |
| SortMenuItemsByName(); |
| UpdateSectionVisibility(); |
| |
| SanityCheck(); |
| } |
| |
| void ExtensionsMenuView::OnToolbarActionRemoved( |
| const ToolbarActionsModel::ActionId& action_id) { |
| auto iter = base::ranges::find(extensions_menu_items_, action_id, |
| [](const ExtensionMenuItemView* item) { |
| return item->view_controller()->GetId(); |
| }); |
| DCHECK(iter != extensions_menu_items_.end()); |
| ExtensionMenuItemView* const view = *iter; |
| DCHECK(Contains(view)); |
| view->parent()->RemoveChildViewT(view); |
| extensions_menu_items_.erase(iter); |
| |
| UpdateSectionVisibility(); |
| |
| SanityCheck(); |
| } |
| |
| void ExtensionsMenuView::OnToolbarActionUpdated( |
| const ToolbarActionsModel::ActionId& action_id) { |
| Update(); |
| } |
| |
| void ExtensionsMenuView::OnToolbarModelInitialized() { |
| DCHECK(extensions_menu_items_.empty()); |
| Populate(); |
| } |
| |
| void ExtensionsMenuView::OnToolbarPinnedActionsChanged() { |
| for (auto* menu_item : extensions_menu_items_) { |
| extensions::ExtensionId extension_id = |
| GetAsMenuItemView(menu_item)->view_controller()->GetId(); |
| bool is_force_pinned = |
| toolbar_model_ && toolbar_model_->IsActionForcePinned(extension_id); |
| bool is_pinned = |
| toolbar_model_ && toolbar_model_->IsActionPinned(extension_id); |
| menu_item->UpdatePinButton(is_force_pinned, is_pinned); |
| } |
| } |
| |
| // static |
| base::AutoReset<bool> ExtensionsMenuView::AllowInstancesForTesting() { |
| return base::AutoReset<bool>(&g_allow_testing_dialogs, true); |
| } |
| |
| // static |
| views::Widget* ExtensionsMenuView::ShowBubble( |
| views::View* anchor_view, |
| Browser* browser, |
| ExtensionsContainer* extensions_container) { |
| DCHECK(!g_extensions_dialog); |
| // Experiment `kExtensionsMenuAccessControl` is introducing a new menu. Check |
| // `ExtensionsMenuView` is only constructed when the experiment is disabled. |
| DCHECK(!base::FeatureList::IsEnabled( |
| extensions_features::kExtensionsMenuAccessControl)); |
| g_extensions_dialog = |
| new ExtensionsMenuView(anchor_view, browser, extensions_container); |
| views::Widget* widget = |
| views::BubbleDialogDelegateView::CreateBubble(g_extensions_dialog); |
| widget->Show(); |
| return widget; |
| } |
| |
| // static |
| bool ExtensionsMenuView::IsShowing() { |
| return g_extensions_dialog != nullptr; |
| } |
| |
| // static |
| void ExtensionsMenuView::Hide() { |
| if (IsShowing()) |
| g_extensions_dialog->GetWidget()->Close(); |
| } |
| |
| // static |
| ExtensionsMenuView* ExtensionsMenuView::GetExtensionsMenuViewForTesting() { |
| return g_extensions_dialog; |
| } |
| |
| // static |
| std::vector<ExtensionMenuItemView*> |
| ExtensionsMenuView::GetSortedItemsForSectionForTesting( |
| extensions::SitePermissionsHelper::SiteInteraction site_interaction) { |
| const ExtensionsMenuView::Section* section = |
| GetExtensionsMenuViewForTesting()->GetSectionForSiteInteraction( |
| site_interaction); |
| std::vector<ExtensionMenuItemView*> menu_item_views; |
| for (views::View* view : section->menu_items->children()) |
| menu_item_views.push_back(GetAsMenuItemView(view)); |
| return menu_item_views; |
| } |
| |
| BEGIN_METADATA(ExtensionsMenuView, views::BubbleDialogDelegateView) |
| END_METADATA |