1 // Copyright 2019 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 #include "chrome/browser/ui/views/native_file_system/native_file_system_usage_bubble_view.h"
6 
7 #include "base/i18n/message_formatter.h"
8 #include "base/i18n/unicodestring.h"
9 #include "base/metrics/user_metrics.h"
10 #include "base/stl_util.h"
11 #include "chrome/app/vector_icons/vector_icons.h"
12 #include "chrome/browser/native_file_system/chrome_native_file_system_permission_context.h"
13 #include "chrome/browser/native_file_system/native_file_system_permission_context_factory.h"
14 #include "chrome/browser/ui/browser.h"
15 #include "chrome/browser/ui/browser_finder.h"
16 #include "chrome/browser/ui/browser_window.h"
17 #include "chrome/browser/ui/views/chrome_layout_provider.h"
18 #include "chrome/browser/ui/views/chrome_typography.h"
19 #include "chrome/browser/ui/views/frame/browser_view.h"
20 #include "chrome/browser/ui/views/frame/toolbar_button_provider.h"
21 #include "chrome/browser/ui/views/native_file_system/native_file_system_ui_helpers.h"
22 #include "chrome/browser/ui/views/page_action/page_action_icon_view.h"
23 #include "chrome/grit/generated_resources.h"
24 #include "components/strings/grit/components_strings.h"
25 #include "components/vector_icons/vector_icons.h"
26 #include "content/public/browser/render_frame_host.h"
27 #include "content/public/browser/render_process_host.h"
28 #include "content/public/browser/web_contents.h"
29 #include "third_party/icu/source/common/unicode/unistr.h"
30 #include "third_party/icu/source/common/unicode/utypes.h"
31 #include "third_party/icu/source/i18n/unicode/listformatter.h"
32 #include "ui/base/l10n/l10n_util.h"
33 #include "ui/gfx/paint_vector_icon.h"
34 #include "ui/views/controls/button/image_button.h"
35 #include "ui/views/controls/button/image_button_factory.h"
36 #include "ui/views/controls/button/label_button.h"
37 #include "ui/views/controls/button/md_text_button.h"
38 #include "ui/views/controls/label.h"
39 #include "ui/views/controls/scroll_view.h"
40 #include "ui/views/controls/table/table_view.h"
41 #include "ui/views/layout/box_layout.h"
42 
43 namespace {
44 
45 // Returns the message Id to use as heading text, depending on what types of
46 // usage are present (i.e. just writable files, or also readable directories,
47 // etc).
48 // |need_lifetime_text_at_end| is set to false iff the returned message Id
49 // already includes an explanation for how long a website will have access to
50 // the listed paths. It is set to true iff a separate label is needed at the end
51 // of the dialog to explain lifetime.
ComputeHeadingMessageFromUsage(const NativeFileSystemUsageBubbleView::Usage & usage,base::FilePath * embedded_path)52 int ComputeHeadingMessageFromUsage(
53     const NativeFileSystemUsageBubbleView::Usage& usage,
54     base::FilePath* embedded_path) {
55   // Only files.
56   if (usage.writable_directories.empty() &&
57       usage.readable_directories.empty()) {
58     // Only writable files.
59     if (usage.readable_files.empty()) {
60       DCHECK(!usage.writable_files.empty());
61       if (usage.writable_files.size() == 1) {
62         *embedded_path = usage.writable_files.front();
63         return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_SINGLE_WRITABLE_FILE_TEXT;
64       }
65       return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_WRITABLE_FILES_TEXT;
66     }
67 
68     // Only readable files.
69     if (usage.writable_files.empty()) {
70       DCHECK(!usage.readable_files.empty());
71       if (usage.readable_files.size() == 1) {
72         *embedded_path = usage.readable_files.front();
73         return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_SINGLE_READABLE_FILE_TEXT;
74       }
75       return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_READABLE_FILES_TEXT;
76     }
77   }
78 
79   // Only directories.
80   if (usage.writable_files.empty() && usage.readable_files.empty()) {
81     // Only writable directories.
82     if (usage.readable_directories.empty()) {
83       DCHECK(!usage.writable_directories.empty());
84       if (usage.writable_directories.size() == 1) {
85         *embedded_path = usage.writable_directories.front();
86         return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_SINGLE_WRITABLE_DIRECTORY_TEXT;
87       }
88       return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_WRITABLE_DIRECTORIES_TEXT;
89     }
90 
91     // Only readable directories.
92     if (usage.writable_directories.empty()) {
93       DCHECK(!usage.readable_directories.empty());
94       if (usage.readable_directories.size() == 1) {
95         *embedded_path = usage.readable_directories.front();
96         return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_SINGLE_READABLE_DIRECTORY_TEXT;
97       }
98       return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_READABLE_DIRECTORIES_TEXT;
99     }
100   }
101 
102   // Only readable files and directories.
103   if (usage.writable_files.empty() && usage.writable_directories.empty()) {
104     DCHECK(!usage.readable_files.empty());
105     DCHECK(!usage.readable_directories.empty());
106     return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_READABLE_FILES_AND_DIRECTORIES_TEXT;
107   }
108 
109   // Only writable files and directories.
110   if (usage.readable_files.empty() && usage.readable_directories.empty()) {
111     DCHECK(!usage.writable_files.empty());
112     DCHECK(!usage.writable_directories.empty());
113     return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_WRITABLE_FILES_AND_DIRECTORIES_TEXT;
114   }
115 
116   // Some combination of read and/or write access to files and/or directories.
117   return IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_READ_AND_WRITE;
118 }
119 
120 // Displays a (one-column) table model as a one-line summary showing the
121 // first few items, with a toggle button to expand a table below to contain the
122 // full list of items.
123 class CollapsibleListView : public views::View {
124  public:
125   // How many rows to show in the expanded table without having to scroll.
126   static constexpr int kExpandedTableRowCount = 3;
127 
CollapsibleListView(ui::TableModel * model)128   explicit CollapsibleListView(ui::TableModel* model) {
129     const views::LayoutProvider* provider = ChromeLayoutProvider::Get();
130 
131     SetLayoutManager(std::make_unique<views::BoxLayout>(
132         views::BoxLayout::Orientation::kVertical, gfx::Insets(0, 0),
133         provider->GetDistanceMetric(views::DISTANCE_RELATED_CONTROL_VERTICAL)));
134 
135     auto label_container = std::make_unique<views::View>();
136     int indent =
137         provider->GetDistanceMetric(DISTANCE_SUBSECTION_HORIZONTAL_INDENT);
138     auto* label_layout =
139         label_container->SetLayoutManager(std::make_unique<views::BoxLayout>(
140             views::BoxLayout::Orientation::kHorizontal,
141             gfx::Insets(/*vertical=*/0, indent),
142             provider->GetDistanceMetric(
143                 views::DISTANCE_RELATED_LABEL_HORIZONTAL)));
144     base::string16 label_text;
145     if (model->RowCount() > 0) {
146       auto icon = std::make_unique<views::ImageView>();
147       icon->SetImage(model->GetIcon(0));
148       label_container->AddChildView(std::move(icon));
149 
150       base::string16 first_item = model->GetText(0, 0);
151       base::string16 second_item =
152           model->RowCount() > 1 ? model->GetText(1, 0) : base::string16();
153 
154       label_text = base::i18n::MessageFormatter::FormatWithNumberedArgs(
155           l10n_util::GetStringUTF16(
156               IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_FILES_TEXT),
157           model->RowCount(), first_item, second_item);
158     }
159     auto* label = label_container->AddChildView(std::make_unique<views::Label>(
160         label_text, CONTEXT_DIALOG_BODY_TEXT_SMALL,
161         views::style::STYLE_PRIMARY));
162     label->SetHorizontalAlignment(gfx::ALIGN_LEFT);
163     label_layout->SetFlexForView(label, 1);
164     auto button = views::CreateVectorToggleImageButton(base::BindRepeating(
165         &CollapsibleListView::ButtonPressed, base::Unretained(this)));
166     button->SetTooltipText(
167         l10n_util::GetStringUTF16(IDS_NATIVE_FILE_SYSTEM_USAGE_EXPAND));
168     button->SetToggledTooltipText(
169         l10n_util::GetStringUTF16(IDS_NATIVE_FILE_SYSTEM_USAGE_COLLAPSE));
170     expand_collapse_button_ = label_container->AddChildView(std::move(button));
171     if (model->RowCount() < 3)
172       expand_collapse_button_->SetVisible(false);
173     int preferred_width = label_container->GetPreferredSize().width();
174     AddChildView(std::move(label_container));
175 
176     std::vector<ui::TableColumn> table_columns{ui::TableColumn()};
177     auto table_view = std::make_unique<views::TableView>(
178         model, std::move(table_columns), views::ICON_AND_TEXT,
179         /*single_selection=*/true);
180     table_view->SetEnabled(false);
181     int row_height = table_view->GetRowHeight();
182     int table_height = table_view->GetPreferredSize().height();
183     table_view_parent_ = AddChildView(
184         views::TableView::CreateScrollViewWithTable(std::move(table_view)));
185     // Ideally we'd use table_view_parent_->GetInsets().height(), but that only
186     // returns the correct value after the view has been added to a root widget.
187     // So just hardcode the inset height to 2 pixels as that is what the scroll
188     // view uses.
189     int inset_height = 2;
190     table_view_parent_->SetPreferredSize(
191         gfx::Size(preferred_width,
192                   std::min(table_height, kExpandedTableRowCount * row_height) +
193                       inset_height));
194     table_view_parent_->SetVisible(false);
195   }
196 
197   // views::View
OnThemeChanged()198   void OnThemeChanged() override {
199     views::View::OnThemeChanged();
200     auto* theme = GetNativeTheme();
201     const SkColor icon_color =
202         theme->GetSystemColor(ui::NativeTheme::kColorId_DefaultIconColor);
203     const SkColor disabled_icon_color =
204         theme->GetSystemColor(ui::NativeTheme::kColorId_DisabledIconColor);
205     views::SetImageFromVectorIconWithColor(
206         expand_collapse_button_, kCaretDownIcon, ui::TableModel::kIconSize,
207         icon_color);
208     views::SetToggledImageFromVectorIconWithColor(
209         expand_collapse_button_, kCaretUpIcon, ui::TableModel::kIconSize,
210         icon_color, disabled_icon_color);
211   }
212 
213  private:
ButtonPressed()214   void ButtonPressed() {
215     table_is_expanded_ = !table_is_expanded_;
216     expand_collapse_button_->SetToggled(table_is_expanded_);
217     table_view_parent_->SetVisible(table_is_expanded_);
218     PreferredSizeChanged();
219   }
220 
221   bool table_is_expanded_ = false;
222   views::ScrollView* table_view_parent_;
223   views::ToggleImageButton* expand_collapse_button_;
224 };
225 
226 }  // namespace
227 
228 NativeFileSystemUsageBubbleView::Usage::Usage() = default;
229 NativeFileSystemUsageBubbleView::Usage::~Usage() = default;
230 NativeFileSystemUsageBubbleView::Usage::Usage(Usage&&) = default;
231 NativeFileSystemUsageBubbleView::Usage& NativeFileSystemUsageBubbleView::Usage::
232 operator=(Usage&&) = default;
233 
FilePathListModel(const views::View * owner,std::vector<base::FilePath> files,std::vector<base::FilePath> directories)234 NativeFileSystemUsageBubbleView::FilePathListModel::FilePathListModel(
235     const views::View* owner,
236     std::vector<base::FilePath> files,
237     std::vector<base::FilePath> directories)
238     : owner_(owner),
239       files_(std::move(files)),
240       directories_(std::move(directories)) {}
241 
242 NativeFileSystemUsageBubbleView::FilePathListModel::~FilePathListModel() =
243     default;
244 
RowCount()245 int NativeFileSystemUsageBubbleView::FilePathListModel::RowCount() {
246   return files_.size() + directories_.size();
247 }
248 
GetText(int row,int column_id)249 base::string16 NativeFileSystemUsageBubbleView::FilePathListModel::GetText(
250     int row,
251     int column_id) {
252   if (size_t{row} < files_.size())
253     return files_[row].BaseName().LossyDisplayName();
254   return directories_[row - files_.size()].BaseName().LossyDisplayName();
255 }
256 
GetIcon(int row)257 gfx::ImageSkia NativeFileSystemUsageBubbleView::FilePathListModel::GetIcon(
258     int row) {
259   const SkColor icon_color = owner_->GetNativeTheme()->GetSystemColor(
260       ui::NativeTheme::kColorId_DefaultIconColor);
261   return gfx::CreateVectorIcon(size_t{row} < files_.size()
262                                    ? vector_icons::kInsertDriveFileOutlineIcon
263                                    : vector_icons::kFolderOpenIcon,
264                                kIconSize, icon_color);
265 }
266 
GetTooltip(int row)267 base::string16 NativeFileSystemUsageBubbleView::FilePathListModel::GetTooltip(
268     int row) {
269   if (size_t{row} < files_.size())
270     return files_[row].LossyDisplayName();
271   return directories_[row - files_.size()].LossyDisplayName();
272 }
273 
SetObserver(ui::TableModelObserver *)274 void NativeFileSystemUsageBubbleView::FilePathListModel::SetObserver(
275     ui::TableModelObserver*) {}
276 
277 // static
278 NativeFileSystemUsageBubbleView* NativeFileSystemUsageBubbleView::bubble_ =
279     nullptr;
280 
281 // static
ShowBubble(content::WebContents * web_contents,const url::Origin & origin,Usage usage)282 void NativeFileSystemUsageBubbleView::ShowBubble(
283     content::WebContents* web_contents,
284     const url::Origin& origin,
285     Usage usage) {
286   base::RecordAction(
287       base::UserMetricsAction("NativeFileSystemAPI.OpenedBubble"));
288 
289   Browser* browser = chrome::FindBrowserWithWebContents(web_contents);
290   if (!browser)
291     return;
292 
293   ToolbarButtonProvider* button_provider =
294       BrowserView::GetBrowserViewForBrowser(browser)->toolbar_button_provider();
295 
296   // Writable files or directories are generally also readable, but we don't
297   // want to display the same path twice. So filter out any writable paths from
298   // the readable lists.
299   std::set<base::FilePath> writable_directories(
300       usage.writable_directories.begin(), usage.writable_directories.end());
301   base::EraseIf(usage.readable_directories, [&](const base::FilePath& path) {
302     return base::Contains(writable_directories, path);
303   });
304   std::set<base::FilePath> writable_files(usage.writable_files.begin(),
305                                           usage.writable_files.end());
306   base::EraseIf(usage.readable_files, [&](const base::FilePath& path) {
307     return base::Contains(writable_files, path);
308   });
309 
310   bubble_ = new NativeFileSystemUsageBubbleView(
311       button_provider->GetAnchorView(
312           PageActionIconType::kNativeFileSystemAccess),
313       web_contents, origin, std::move(usage));
314 
315   bubble_->SetHighlightedButton(button_provider->GetPageActionIconView(
316       PageActionIconType::kNativeFileSystemAccess));
317   views::BubbleDialogDelegateView::CreateBubble(bubble_);
318 
319   bubble_->ShowForReason(DisplayReason::USER_GESTURE,
320                          /*allow_refocus_alert=*/true);
321 }
322 
323 // static
CloseCurrentBubble()324 void NativeFileSystemUsageBubbleView::CloseCurrentBubble() {
325   if (bubble_)
326     bubble_->CloseBubble();
327 }
328 
329 // static
GetBubble()330 NativeFileSystemUsageBubbleView* NativeFileSystemUsageBubbleView::GetBubble() {
331   return bubble_;
332 }
333 
NativeFileSystemUsageBubbleView(views::View * anchor_view,content::WebContents * web_contents,const url::Origin & origin,Usage usage)334 NativeFileSystemUsageBubbleView::NativeFileSystemUsageBubbleView(
335     views::View* anchor_view,
336     content::WebContents* web_contents,
337     const url::Origin& origin,
338     Usage usage)
339     : LocationBarBubbleDelegateView(anchor_view, web_contents),
340       origin_(origin),
341       usage_(std::move(usage)),
342       readable_paths_model_(this,
343                             std::move(usage_.readable_files),
344                             std::move(usage_.readable_directories)),
345       writable_paths_model_(this,
346                             std::move(usage_.writable_files),
347                             std::move(usage_.writable_directories)) {
348   SetButtonLabel(ui::DIALOG_BUTTON_OK, l10n_util::GetStringUTF16(IDS_DONE));
349   SetButtonLabel(
350       ui::DIALOG_BUTTON_CANCEL,
351       l10n_util::GetStringUTF16(IDS_NATIVE_FILE_SYSTEM_USAGE_REMOVE_ACCESS));
352   SetCancelCallback(
353       base::BindOnce(&NativeFileSystemUsageBubbleView::OnDialogCancelled,
354                      base::Unretained(this)));
355   set_fixed_width(views::LayoutProvider::Get()->GetDistanceMetric(
356       views::DISTANCE_BUBBLE_PREFERRED_WIDTH));
357 }
358 
359 NativeFileSystemUsageBubbleView::~NativeFileSystemUsageBubbleView() = default;
360 
GetAccessibleWindowTitle() const361 base::string16 NativeFileSystemUsageBubbleView::GetAccessibleWindowTitle()
362     const {
363   Browser* browser = chrome::FindBrowserWithWebContents(web_contents());
364   // Don't crash if the web_contents is destroyed/unloaded.
365   if (!browser)
366     return {};
367 
368   return BrowserView::GetBrowserViewForBrowser(browser)
369       ->toolbar_button_provider()
370       ->GetPageActionIconView(PageActionIconType::kNativeFileSystemAccess)
371       ->GetTextForTooltipAndAccessibleName();
372 }
373 
ShouldShowCloseButton() const374 bool NativeFileSystemUsageBubbleView::ShouldShowCloseButton() const {
375   return true;
376 }
377 
Init()378 void NativeFileSystemUsageBubbleView::Init() {
379   // Set up the layout of the bubble.
380   const views::LayoutProvider* provider = ChromeLayoutProvider::Get();
381   gfx::Insets dialog_insets =
382       provider->GetInsetsMetric(views::InsetsMetric::INSETS_DIALOG);
383   SetLayoutManager(std::make_unique<views::BoxLayout>(
384       views::BoxLayout::Orientation::kVertical,
385       gfx::Insets(0, dialog_insets.left(), 0, dialog_insets.right()),
386       provider->GetDistanceMetric(views::DISTANCE_RELATED_CONTROL_VERTICAL)));
387   set_margins(
388       gfx::Insets(provider->GetDistanceMetric(
389                       views::DISTANCE_DIALOG_CONTENT_MARGIN_TOP_TEXT),
390                   0,
391                   provider->GetDistanceMetric(
392                       views::DISTANCE_DIALOG_CONTENT_MARGIN_BOTTOM_CONTROL),
393                   0));
394 
395   base::FilePath embedded_path;
396   int heading_message_id =
397       ComputeHeadingMessageFromUsage(usage_, &embedded_path);
398 
399   if (!embedded_path.empty()) {
400     AddChildView(native_file_system_ui_helper::CreateOriginPathLabel(
401         heading_message_id, origin_, embedded_path,
402         views::style::CONTEXT_DIALOG_BODY_TEXT,
403         /*show_emphasis=*/false));
404   } else {
405     AddChildView(native_file_system_ui_helper::CreateOriginLabel(
406         heading_message_id, origin_, views::style::CONTEXT_DIALOG_BODY_TEXT,
407         /*show_emphasis=*/false));
408 
409     if (writable_paths_model_.RowCount() > 0) {
410       if (readable_paths_model_.RowCount() > 0) {
411         auto label = std::make_unique<views::Label>(
412             l10n_util::GetStringUTF16(
413                 IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_SAVE_CHANGES),
414             views::style::CONTEXT_DIALOG_BODY_TEXT,
415             views::style::STYLE_PRIMARY);
416         label->SetHorizontalAlignment(gfx::ALIGN_LEFT);
417         AddChildView(std::move(label));
418       }
419       AddChildView(
420           std::make_unique<CollapsibleListView>(&writable_paths_model_));
421     }
422 
423     if (readable_paths_model_.RowCount() > 0) {
424       if (writable_paths_model_.RowCount() > 0) {
425         auto label = std::make_unique<views::Label>(
426             l10n_util::GetStringUTF16(
427                 IDS_NATIVE_FILE_SYSTEM_USAGE_BUBBLE_VIEW_CHANGES),
428             views::style::CONTEXT_DIALOG_BODY_TEXT,
429             views::style::STYLE_PRIMARY);
430         label->SetHorizontalAlignment(gfx::ALIGN_LEFT);
431         AddChildView(std::move(label));
432       }
433       AddChildView(
434           std::make_unique<CollapsibleListView>(&readable_paths_model_));
435     }
436   }
437 }
438 
OnDialogCancelled()439 void NativeFileSystemUsageBubbleView::OnDialogCancelled() {
440   base::RecordAction(
441       base::UserMetricsAction("NativeFileSystemAPI.RevokePermissions"));
442 
443   if (!web_contents())
444     return;
445 
446   content::BrowserContext* profile = web_contents()->GetBrowserContext();
447   auto* context =
448       NativeFileSystemPermissionContextFactory::GetForProfileIfExists(profile);
449   if (!context)
450     return;
451 
452   context->RevokeGrants(origin_);
453 }
454 
WindowClosing()455 void NativeFileSystemUsageBubbleView::WindowClosing() {
456   // |bubble_| can be a new bubble by this point (as Close(); doesn't
457   // call this right away). Only set to nullptr when it's this bubble.
458   if (bubble_ == this)
459     bubble_ = nullptr;
460 }
461 
CloseBubble()462 void NativeFileSystemUsageBubbleView::CloseBubble() {
463   // Widget's Close() is async, but we don't want to use bubble_ after
464   // this. Additionally web_contents() may have been destroyed.
465   bubble_ = nullptr;
466   LocationBarBubbleDelegateView::CloseBubble();
467 }
468 
ChildPreferredSizeChanged(views::View * child)469 void NativeFileSystemUsageBubbleView::ChildPreferredSizeChanged(
470     views::View* child) {
471   LocationBarBubbleDelegateView::ChildPreferredSizeChanged(child);
472   SizeToContents();
473 }
474