| // 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/ui/views/webid/account_selection_view_base.h" |
| |
| #include "chrome/browser/image_fetcher/image_decoder_impl.h" |
| #include "chrome/browser/ui/views/controls/hover_button.h" |
| #include "chrome/browser/ui/views/webid/account_selection_bubble_view.h" |
| #include "chrome/grit/generated_resources.h" |
| #include "components/image_fetcher/core/image_fetcher_impl.h" |
| #include "third_party/blink/public/mojom/webid/federated_auth_request.mojom.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/metadata/metadata_impl_macros.h" |
| #include "ui/views/border.h" |
| #include "ui/views/controls/styled_label.h" |
| #include "ui/views/layout/box_layout.h" |
| #include "ui/views/layout/flex_layout.h" |
| #include "ui/views/layout/flex_layout_view.h" |
| #include "ui/views/widget/widget_observer.h" |
| #include "url/gurl.h" |
| |
| namespace { |
| |
| // safe_zone_diameter/icon_size as defined in |
| // https://www.w3.org/TR/appmanifest/#icon-masks |
| constexpr float kMaskableWebIconSafeZoneRatio = 0.8f; |
| |
| // Selects string for disclosure text based on passed-in `privacy_policy_url` |
| // and `terms_of_service_url`. |
| int SelectDisclosureTextResourceId(const GURL& privacy_policy_url, |
| const GURL& terms_of_service_url) { |
| if (privacy_policy_url.is_empty()) { |
| return terms_of_service_url.is_empty() |
| ? IDS_ACCOUNT_SELECTION_DATA_SHARING_CONSENT_NO_PP_OR_TOS |
| : IDS_ACCOUNT_SELECTION_DATA_SHARING_CONSENT_NO_PP; |
| } |
| |
| return terms_of_service_url.is_empty() |
| ? IDS_ACCOUNT_SELECTION_DATA_SHARING_CONSENT_NO_TOS |
| : IDS_ACCOUNT_SELECTION_DATA_SHARING_CONSENT; |
| } |
| |
| gfx::ImageSkia CreateCircleCroppedImage(const gfx::ImageSkia& original_image, |
| int image_size) { |
| return gfx::CanvasImageSource::MakeImageSkia<CircleCroppedImageSkiaSource>( |
| original_image, original_image.width() * kMaskableWebIconSafeZoneRatio, |
| image_size); |
| } |
| |
| } // namespace |
| |
| constexpr net::NetworkTrafficAnnotationTag kTrafficAnnotation = |
| net::DefineNetworkTrafficAnnotation("fedcm_account_profile_image_fetcher", |
| R"( |
| semantics { |
| sender: "Profile image fetcher for FedCM Account chooser on desktop." |
| description: |
| "Retrieves profile images for user's accounts in the FedCM login" |
| "flow." |
| trigger: |
| "Triggered when FedCM API is called and account chooser shows up." |
| "The accounts shown are ones for which the user has previously" |
| "signed into the identity provider." |
| data: |
| "Account picture URL of user account, provided by the identity" |
| "provider." |
| destination: WEBSITE |
| internal { |
| contacts { |
| email: "web-identity-eng@google.com" |
| } |
| } |
| user_data { |
| type: USER_CONTENT |
| } |
| last_reviewed: "2024-01-25" |
| } |
| policy { |
| cookies_allowed: NO |
| setting: |
| "You can enable or disable this feature in chrome://settings, under" |
| "'Privacy and security', then 'Site Settings', and finally" |
| "'Third party sign-in'." |
| policy_exception_justification: |
| "Not implemented. This is a feature that sites use for" |
| "Federated Sign-In, for which we do not have an Enterprise policy." |
| })"); |
| |
| void LetterCircleCroppedImageSkiaSource::Draw(gfx::Canvas* canvas) { |
| monogram::DrawMonogramInCanvas(canvas, size().width(), size().width(), |
| letter_, SK_ColorWHITE, SK_ColorGRAY); |
| } |
| |
| void CircleCroppedImageSkiaSource::Draw(gfx::Canvas* canvas) { |
| const int canvas_edge_size = size().width(); |
| |
| // Center the avatar in the canvas. |
| const int x = (canvas_edge_size - avatar_.width()) / 2; |
| const int y = (canvas_edge_size - avatar_.height()) / 2; |
| |
| SkPath circular_mask; |
| circular_mask.addCircle(SkIntToScalar(canvas_edge_size / 2), |
| SkIntToScalar(canvas_edge_size / 2), |
| SkIntToScalar(canvas_edge_size / 2)); |
| canvas->ClipPath(circular_mask, true); |
| canvas->DrawImageInt(avatar_, x, y); |
| } |
| |
| BrandIconImageView::BrandIconImageView( |
| base::OnceCallback<void(const GURL&, const gfx::ImageSkia&)> add_image, |
| int image_size) |
| : add_image_(std::move(add_image)), image_size_(image_size) {} |
| |
| BrandIconImageView::~BrandIconImageView() = default; |
| |
| void BrandIconImageView::FetchImage( |
| const GURL& icon_url, |
| image_fetcher::ImageFetcher& image_fetcher) { |
| image_fetcher::ImageFetcherParams params(kTrafficAnnotation, |
| kImageFetcherUmaClient); |
| image_fetcher.FetchImage( |
| icon_url, |
| base::BindOnce(&BrandIconImageView::OnImageFetched, |
| weak_ptr_factory_.GetWeakPtr(), icon_url), |
| std::move(params)); |
| } |
| |
| void BrandIconImageView::OnImageFetched( |
| const GURL& image_url, |
| const gfx::Image& image, |
| const image_fetcher::RequestMetadata& metadata) { |
| if (image.Width() != image.Height() || |
| image.Width() < AccountSelectionView::GetBrandIconMinimumSize()) { |
| return; |
| } |
| gfx::ImageSkia skia_image = image.AsImageSkia(); |
| SetImage(ui::ImageModel::FromImageSkia( |
| CreateCircleCroppedImage(skia_image, image_size_))); |
| // TODO(crbug.com/327509202): This stops the crashes but should fix to prevent |
| // this from crashing in the first place. |
| if (!add_image_) { |
| return; |
| } |
| std::move(add_image_).Run(image_url, skia_image); |
| } |
| |
| BEGIN_METADATA(BrandIconImageView) |
| END_METADATA |
| |
| class AccountImageView : public views::ImageView { |
| METADATA_HEADER(AccountImageView, views::ImageView) |
| |
| public: |
| AccountImageView() = default; |
| |
| AccountImageView(const AccountImageView&) = delete; |
| AccountImageView& operator=(const AccountImageView&) = delete; |
| ~AccountImageView() override = default; |
| |
| // Fetch image and set it on AccountImageView. |
| void FetchAccountImage(const content::IdentityRequestAccount& account, |
| image_fetcher::ImageFetcher& image_fetcher) { |
| image_fetcher::ImageFetcherParams params(kTrafficAnnotation, |
| kImageFetcherUmaClient); |
| |
| // OnImageFetched() is a member of AccountImageView so that the callback |
| // is cancelled in the case that AccountImageView is destroyed prior to |
| // the callback returning. |
| image_fetcher.FetchImage( |
| account.picture, |
| base::BindOnce(&AccountImageView::OnAccountImageFetched, |
| weak_ptr_factory_.GetWeakPtr(), |
| base::UTF8ToUTF16(account.name), &image_fetcher), |
| std::move(params)); |
| } |
| |
| void SetBadgeImage(std::unique_ptr<gfx::ImageSkia> idp_image) { |
| idp_image_ = std::move(idp_image); |
| } |
| |
| void FetchBadgeImage( |
| const GURL& brand_icon_url, |
| image_fetcher::ImageFetcher& image_fetcher, |
| base::OnceCallback<void(const GURL&, const gfx::ImageSkia&)> |
| add_idp_image) { |
| // Fetch the IDP image to use as badge for the account image. |
| image_fetcher::ImageFetcherParams params(kTrafficAnnotation, |
| kImageFetcherUmaClient); |
| add_idp_image_ = std::move(add_idp_image); |
| image_fetcher.FetchImage( |
| brand_icon_url, |
| base::BindOnce(&AccountImageView::OnIdpImageFetched, |
| weak_ptr_factory_.GetWeakPtr(), brand_icon_url), |
| std::move(params)); |
| } |
| |
| private: |
| void OnAccountImageFetched(const std::u16string& account_name, |
| image_fetcher::ImageFetcher* image_fetcher, |
| const gfx::Image& image, |
| const image_fetcher::RequestMetadata& metadata) { |
| gfx::ImageSkia avatar; |
| if (image.IsEmpty()) { |
| std::u16string letter = account_name; |
| if (letter.length() > 0) { |
| letter = base::i18n::ToUpper(account_name.substr(0, 1)); |
| } |
| avatar = gfx::CanvasImageSource::MakeImageSkia< |
| LetterCircleCroppedImageSkiaSource>(letter, kDesiredAvatarSize); |
| } else { |
| avatar = |
| gfx::CanvasImageSource::MakeImageSkia<CircleCroppedImageSkiaSource>( |
| image.AsImageSkia(), std::nullopt, kDesiredAvatarSize); |
| } |
| if (idp_image_) { |
| SetBadgedImage(avatar, *idp_image_); |
| return; |
| } |
| // If we are not waiting for an IDP image, set the image right away. |
| // Otherwise, store the account image so the badged image can be set when |
| // the IDP image is fetched. |
| if (add_idp_image_.is_null()) { |
| SetImage(avatar); |
| return; |
| } |
| account_image_ = std::make_unique<gfx::ImageSkia>(avatar); |
| } |
| |
| void OnIdpImageFetched(const GURL& url, |
| const gfx::Image& image, |
| const image_fetcher::RequestMetadata& metadata) { |
| if (image.Width() != image.Height() || |
| image.Width() < AccountSelectionView::GetBrandIconMinimumSize()) { |
| if (account_image_) { |
| SetImage(ui::ImageModel::FromImageSkia(*account_image_)); |
| } |
| add_idp_image_.Reset(); |
| return; |
| } |
| gfx::ImageSkia skia_image = image.AsImageSkia(); |
| std::move(add_idp_image_).Run(url, skia_image); |
| // If we stored the account image, set the badged image. Otherwise, store |
| // the IDP image so the badged image can be set when the account image is |
| // fetched. |
| if (account_image_) { |
| SetBadgedImage(*account_image_, skia_image); |
| } else { |
| idp_image_ = std::make_unique<gfx::ImageSkia>(skia_image); |
| } |
| } |
| |
| void SetBadgedImage(const gfx::ImageSkia& account_image, |
| const gfx::ImageSkia& idp_image) { |
| gfx::ImageSkia badged_image = gfx::ImageSkiaOperations::CreateIconWithBadge( |
| account_image, |
| CreateCircleCroppedImage(idp_image, kLargeAvatarBadgeSize)); |
| SetImage(ui::ImageModel::FromImageSkia(badged_image)); |
| } |
| |
| // The already cropped and circled account image. |
| std::unique_ptr<gfx::ImageSkia> account_image_; |
| // The original IDP image. |
| std::unique_ptr<gfx::ImageSkia> idp_image_; |
| base::OnceCallback<void(const GURL&, const gfx::ImageSkia&)> add_idp_image_; |
| base::WeakPtrFactory<AccountImageView> weak_ptr_factory_{this}; |
| }; |
| |
| BEGIN_METADATA(AccountImageView) |
| END_METADATA |
| |
| AccountSelectionViewBase::AccountSelectionViewBase( |
| content::WebContents* web_contents, |
| AccountSelectionViewBase::Observer* observer, |
| views::WidgetObserver* widget_observer, |
| scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory) |
| : web_contents_(web_contents), |
| widget_observer_(widget_observer), |
| observer_(observer) { |
| image_fetcher_ = std::make_unique<image_fetcher::ImageFetcherImpl>( |
| std::make_unique<ImageDecoderImpl>(), std::move(url_loader_factory)); |
| } |
| |
| AccountSelectionViewBase::AccountSelectionViewBase() = default; |
| AccountSelectionViewBase::~AccountSelectionViewBase() {} |
| |
| int AccountSelectionViewBase::SelectSingleIdpTitleResourceId( |
| blink::mojom::RpContext rp_context) { |
| switch (rp_context) { |
| case blink::mojom::RpContext::kSignIn: |
| return IDS_ACCOUNT_SELECTION_SHEET_TITLE_EXPLICIT_SIGN_IN; |
| case blink::mojom::RpContext::kSignUp: |
| return IDS_ACCOUNT_SELECTION_SHEET_TITLE_EXPLICIT_SIGN_UP; |
| case blink::mojom::RpContext::kUse: |
| return IDS_ACCOUNT_SELECTION_SHEET_TITLE_EXPLICIT_USE; |
| case blink::mojom::RpContext::kContinue: |
| return IDS_ACCOUNT_SELECTION_SHEET_TITLE_EXPLICIT_CONTINUE; |
| } |
| } |
| |
| // Returns the title to be shown in the dialog. This does not include the |
| // subtitle. For screen reader purposes, GetAccessibleTitle() is used instead. |
| std::u16string AccountSelectionViewBase::GetTitle( |
| const std::u16string& top_frame_for_display, |
| const std::optional<std::u16string>& iframe_for_display, |
| const std::optional<std::u16string>& idp_title, |
| blink::mojom::RpContext rp_context) { |
| std::u16string frame_in_title = iframe_for_display.has_value() |
| ? iframe_for_display.value() |
| : top_frame_for_display; |
| return idp_title.has_value() |
| ? l10n_util::GetStringFUTF16( |
| SelectSingleIdpTitleResourceId(rp_context), frame_in_title, |
| idp_title.value()) |
| : l10n_util::GetStringFUTF16( |
| IDS_MULTI_IDP_ACCOUNT_SELECTION_SHEET_TITLE_EXPLICIT, |
| frame_in_title); |
| } |
| |
| std::u16string AccountSelectionViewBase::GetSubtitle( |
| const std::u16string& top_frame_for_display) { |
| return l10n_util::GetStringFUTF16(IDS_ACCOUNT_SELECTION_SHEET_SUBTITLE, |
| top_frame_for_display); |
| } |
| |
| // Returns the title combined with the subtitle for screen reader purposes. |
| std::u16string AccountSelectionViewBase::GetAccessibleTitle( |
| const std::u16string& top_frame_for_display, |
| const std::optional<std::u16string>& iframe_for_display, |
| const std::optional<std::u16string>& idp_title, |
| blink::mojom::RpContext rp_context) { |
| std::u16string title = GetTitle(top_frame_for_display, iframe_for_display, |
| idp_title, rp_context); |
| return iframe_for_display.has_value() |
| ? title + u" " + GetSubtitle(top_frame_for_display) |
| : title; |
| } |
| |
| void AccountSelectionViewBase::SetLabelProperties(views::Label* label) { |
| label->SetMultiLine(true); |
| label->SetHorizontalAlignment(gfx::ALIGN_LEFT); |
| label->SetAllowCharacterBreak(true); |
| label->SetProperty( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero, |
| views::MaximumFlexSizeRule::kUnbounded, |
| /*adjust_height_for_width =*/true)); |
| } |
| |
| std::unique_ptr<views::View> AccountSelectionViewBase::CreateAccountRow( |
| const content::IdentityRequestAccount& account, |
| const IdentityProviderDisplayData& idp_display_data, |
| bool should_hover, |
| bool should_include_idp) { |
| auto account_image_view = std::make_unique<AccountImageView>(); |
| account_image_view->SetImageSize({kDesiredAvatarSize, kDesiredAvatarSize}); |
| CHECK(should_hover || !should_include_idp); |
| if (should_hover) { |
| if (should_include_idp) { |
| account_image_view->SetImageSize({kLargerAvatarSize, kLargerAvatarSize}); |
| ConfigureBadgeIdp(*account_image_view, |
| idp_display_data.idp_metadata.brand_icon_url); |
| } |
| account_image_view->FetchAccountImage(account, *image_fetcher_); |
| // We can pass crefs to OnAccountSelected because the `observer_` owns the |
| // data. |
| std::u16string footer = |
| should_include_idp ? idp_display_data.idp_etld_plus_one : u""; |
| auto row = std::make_unique<HoverButton>( |
| base::BindRepeating( |
| &AccountSelectionViewBase::Observer::OnAccountSelected, |
| base::Unretained(observer_), std::cref(account), |
| std::cref(idp_display_data)), |
| std::move(account_image_view), |
| /*title=*/base::UTF8ToUTF16(account.name), |
| /*subtitle=*/base::UTF8ToUTF16(account.email), |
| /*secondary_view=*/nullptr, |
| /*add_vertical_label_spacing=*/true, footer); |
| row->SetBorder(views::CreateEmptyBorder( |
| gfx::Insets::VH(/*vertical=*/0, /*horizontal=*/kLeftRightPadding))); |
| row->SetSubtitleTextStyle(views::style::CONTEXT_LABEL, |
| views::style::STYLE_SECONDARY); |
| if (should_include_idp) { |
| row->SetFooterTextStyle(views::style::CONTEXT_LABEL, |
| views::style::STYLE_SECONDARY); |
| } |
| return row; |
| } |
| account_image_view->FetchAccountImage(account, *image_fetcher_); |
| auto row = std::make_unique<views::View>(); |
| row->SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal, |
| gfx::Insets::VH(/*vertical=*/kVerticalSpacing, /*horizontal=*/0), |
| kLeftRightPadding)); |
| row->AddChildView(std::move(account_image_view)); |
| views::View* const text_column = |
| row->AddChildView(std::make_unique<views::View>()); |
| text_column->SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kVertical)); |
| |
| // Add account name. |
| views::StyledLabel* const account_name = |
| text_column->AddChildView(std::make_unique<views::StyledLabel>()); |
| account_name->SetText(base::UTF8ToUTF16(account.name)); |
| account_name->SetHorizontalAlignment(gfx::HorizontalAlignment::ALIGN_LEFT); |
| |
| // Add account email. |
| views::Label* const account_email = text_column->AddChildView( |
| std::make_unique<views::Label>(base::UTF8ToUTF16(account.email), |
| views::style::CONTEXT_DIALOG_BODY_TEXT, |
| views::style::STYLE_SECONDARY)); |
| account_email->SetHorizontalAlignment(gfx::HorizontalAlignment::ALIGN_LEFT); |
| |
| return row; |
| } |
| |
| void AccountSelectionViewBase::AddIdpImage(const GURL& image_url, |
| const gfx::ImageSkia& image) { |
| brand_icon_images_[image_url] = image; |
| } |
| |
| void AccountSelectionViewBase::ConfigureBrandImageView( |
| BrandIconImageView* image_view, |
| const GURL& brand_icon_url, |
| int image_size) { |
| // Show placeholder brand icon prior to brand icon being fetched so that |
| // header text wrapping does not change when brand icon is fetched. |
| bool has_icon = brand_icon_url.is_valid(); |
| image_view->SetVisible(has_icon); |
| if (!has_icon) { |
| return; |
| } |
| |
| auto it = brand_icon_images_.find(brand_icon_url); |
| if (it != brand_icon_images_.end()) { |
| image_view->SetImage(ui::ImageModel::FromImageSkia( |
| CreateCircleCroppedImage(it->second, image_size))); |
| return; |
| } |
| |
| image_view->FetchImage(brand_icon_url, *image_fetcher_); |
| } |
| |
| void AccountSelectionViewBase::ConfigureBadgeIdp( |
| AccountImageView& account_image_view, |
| const GURL& brand_icon_url) { |
| if (!brand_icon_url.is_valid()) { |
| return; |
| } |
| auto it = brand_icon_images_.find(brand_icon_url); |
| if (it != brand_icon_images_.end()) { |
| account_image_view.SetBadgeImage( |
| std::make_unique<gfx::ImageSkia>(it->second)); |
| return; |
| } |
| |
| account_image_view.FetchBadgeImage( |
| brand_icon_url, *image_fetcher_, |
| base::BindOnce(&AccountSelectionViewBase::AddIdpImage, |
| weak_ptr_factory_.GetWeakPtr())); |
| } |
| |
| std::unique_ptr<views::View> AccountSelectionViewBase::CreateDisclosureLabel( |
| const IdentityProviderDisplayData& idp_display_data) { |
| // It requires a StyledLabel so that we can add the links |
| // to the privacy policy and terms of service URLs. |
| std::unique_ptr<views::StyledLabel> disclosure_label = |
| std::make_unique<views::StyledLabel>(); |
| disclosure_label->SetHorizontalAlignment( |
| gfx::HorizontalAlignment::ALIGN_LEFT); |
| |
| // Set custom top margin for `disclosure_label` in order to take |
| // (line_height - font_height) into account. |
| disclosure_label->SetBorder( |
| views::CreateEmptyBorder(gfx::Insets::TLBR(5, 0, 0, 0))); |
| disclosure_label->SetDefaultTextStyle(views::style::STYLE_SECONDARY); |
| |
| const content::ClientMetadata& client_metadata = |
| idp_display_data.client_metadata; |
| int disclosure_resource_id = SelectDisclosureTextResourceId( |
| client_metadata.privacy_policy_url, client_metadata.terms_of_service_url); |
| |
| // The order that the links are added to `link_data` should match the order of |
| // the links in `disclosure_resource_id`. |
| std::vector<std::pair<LinkType, GURL>> link_data; |
| if (!client_metadata.privacy_policy_url.is_empty()) { |
| link_data.emplace_back(LinkType::PRIVACY_POLICY, |
| client_metadata.privacy_policy_url); |
| } |
| if (!client_metadata.terms_of_service_url.is_empty()) { |
| link_data.emplace_back(LinkType::TERMS_OF_SERVICE, |
| client_metadata.terms_of_service_url); |
| } |
| |
| // Each link has both <ph name="BEGIN_LINK"> and <ph name="END_LINK">. |
| std::vector<std::u16string> replacements = { |
| idp_display_data.idp_etld_plus_one}; |
| replacements.insert(replacements.end(), link_data.size() * 2, |
| std::u16string()); |
| |
| std::vector<size_t> offsets; |
| const std::u16string disclosure_text = l10n_util::GetStringFUTF16( |
| disclosure_resource_id, replacements, &offsets); |
| disclosure_label->SetText(disclosure_text); |
| |
| size_t offset_index = 1u; |
| for (const std::pair<LinkType, GURL>& link_data_item : link_data) { |
| disclosure_label->AddStyleRange( |
| gfx::Range(offsets[offset_index], offsets[offset_index + 1]), |
| views::StyledLabel::RangeStyleInfo::CreateForLink(base::BindRepeating( |
| &AccountSelectionViewBase::Observer::OnLinkClicked, |
| base::Unretained(observer_), link_data_item.first, |
| link_data_item.second))); |
| offset_index += 2; |
| } |
| |
| return disclosure_label; |
| } |
| |
| base::WeakPtr<views::Widget> AccountSelectionViewBase::GetDialogWidget() { |
| return dialog_widget_; |
| } |
| |
| // static |
| net::NetworkTrafficAnnotationTag |
| AccountSelectionViewBase::GetTrafficAnnotation() { |
| return kTrafficAnnotation; |
| } |