chromium/ash/components/arc/net/passpoint_dialog_view.cc

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "ash/components/arc/net/passpoint_dialog_view.h"

#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>

#include "ash/components/arc/compat_mode/overlay_dialog.h"
#include "ash/components/arc/net/browser_url_opener.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/ash_color_provider.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/time/time.h"
#include "chromeos/ash/components/network/network_event_log.h"
#include "components/strings/grit/components_strings.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/ui_base_types.h"
#include "ui/chromeos/devicetype_utils.h"
#include "ui/strings/grit/ui_strings.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/bubble/bubble_border.h"
#include "ui/views/controls/button/md_text_button.h"
#include "ui/views/controls/styled_label.h"
#include "ui/views/layout/layout_provider.h"
#include "ui/views/style/typography.h"
#include "ui/views/style/typography_provider.h"
#include "url/gurl.h"

namespace {

constexpr char kPasspointHelpPage[] =
    "https://support.google.com/chromebook?p=wifi_passpoint";

}  // namespace

namespace {

// Radius value to make the bubble border of the Passpoint dialog.
constexpr int kCornerRadius = 12;
// Top, left, bottom, and right inside margin for the Passpoint dialog.
constexpr int kDialogBorderMargin[] = {24, 24, 20, 24};
// Bottom margin for the Passpoint dialog's body label.
constexpr int kDialogBodyBottomMargin = 23;
// Expiration time for showing Passpoint additional dialog.
constexpr base::TimeDelta kDialogExpiration = base::Days(365);

// |subscription_expiration_time_ms| is in the format of number of milliseconds
// since January 1, 1970, 00:00:00 GMT. Expiration time of int64_min means no
// expiry date based on Android's behavior.
std::optional<base::Time> GetTimeFromSubscriptionExpirationMs(
    int64_t subscription_expiration_time_ms) {
  if (subscription_expiration_time_ms == std::numeric_limits<int64_t>::min()) {
    return std::nullopt;
  }
  return base::Time::UnixEpoch() +
         base::Milliseconds(subscription_expiration_time_ms);
}

// Returns true if the expiration time is within |kDialogExpiration| and is
// still valid.
bool IsExpiring(std::optional<base::Time> subscription_expiration_time) {
  if (!subscription_expiration_time.has_value()) {
    return false;
  }
  auto expiry_time_delta =
      subscription_expiration_time.value() - base::Time::Now();
  return expiry_time_delta.is_positive() &&
         (expiry_time_delta < kDialogExpiration);
}

}  // namespace

namespace arc {

PasspointDialogView::PasspointDialogView(
    mojom::PasspointApprovalRequestPtr request,
    PasspointDialogCallback callback)
    : app_name_(base::UTF8ToUTF16(request->app_name)),
      callback_(std::move(callback)) {
  views::LayoutProvider* provider = views::LayoutProvider::Get();
  SetOrientation(views::BoxLayout::Orientation::kVertical);
  SetMainAxisAlignment(views::BoxLayout::MainAxisAlignment::kStart);
  SetInsideBorderInsets(
      gfx::Insets::TLBR(kDialogBorderMargin[0], kDialogBorderMargin[1],
                        kDialogBorderMargin[2], kDialogBorderMargin[3]));
  SetBetweenChildSpacing(
      provider->GetDistanceMetric(views::DISTANCE_RELATED_CONTROL_VERTICAL));

  auto border = std::make_unique<views::BubbleBorder>(
      views::BubbleBorder::NONE, views::BubbleBorder::STANDARD_SHADOW,
      ash::kColorAshDialogBackgroundColor);
  border->SetCornerRadius(kCornerRadius);
  SetBackground(std::make_unique<views::BubbleBackground>(border.get()));
  SetBorder(std::move(border));

  AddChildView(
      views::Builder<views::Label>()
          .SetText(l10n_util::GetStringFUTF16(
              IDS_ASH_ARC_PASSPOINT_APP_APPROVAL_TITLE, app_name_))
          .SetTextContext(views::style::CONTEXT_DIALOG_TITLE)
          .SetMultiLine(true)
          .SetHorizontalAlignment(gfx::ALIGN_LEFT)
          .SetAllowCharacterBreak(true)
          .SetFontList(
              views::TypographyProvider::Get()
                  .GetFont(views::style::TextContext::CONTEXT_DIALOG_TITLE,
                           views::style::TextStyle::STYLE_PRIMARY)
                  .DeriveWithWeight(gfx::Font::Weight::MEDIUM))
          .Build());

  AddChildView(
      MakeContentsView(IsExpiring(GetTimeFromSubscriptionExpirationMs(
                           request->subscription_expiration_time_ms)),
                       request->friendly_name.value_or(std::string())));
  AddChildView(MakeButtonsView());
}

PasspointDialogView::~PasspointDialogView() = default;

gfx::Size PasspointDialogView::CalculatePreferredSize(
    const views::SizeBounds& available_size) const {
  return GetLayoutManager()->GetPreferredSize(this, available_size);
}

void PasspointDialogView::AddedToWidget() {
  auto& view_ax = GetWidget()->GetRootView()->GetViewAccessibility();
  view_ax.SetRole(ax::mojom::Role::kDialog);
  view_ax.SetName(l10n_util::GetStringFUTF16(
                      IDS_ASH_ARC_PASSPOINT_APP_APPROVAL_TITLE, app_name_),
                  ax::mojom::NameFrom::kAttribute);
}

std::unique_ptr<views::View> PasspointDialogView::MakeBaseLabelView(
    bool is_expiring) {
  const std::u16string label = l10n_util::GetStringFUTF16(
      IDS_ASH_ARC_PASSPOINT_APP_APPROVAL_BODY, ui::GetChromeOSDeviceName());

  std::unique_ptr<views::StyledLabel> styled_label =
      views::Builder<views::StyledLabel>()
          .CopyAddressTo(&body_text_)
          .SetText(label)
          .SetHorizontalAlignment(gfx::ALIGN_LEFT)
          .SetAutoColorReadabilityEnabled(false)
          .Build();

  if (!is_expiring) {
    std::vector<size_t> offsets;
    const std::u16string learn_more = l10n_util::GetStringUTF16(
        IDS_ASH_ARC_PASSPOINT_APP_APPROVAL_LEARN_MORE_LABEL);
    const std::u16string label_expiring = l10n_util::GetStringFUTF16(
        IDS_ASH_ARC_PASSPOINT_APP_APPROVAL_BODY_WITH_LEARN_MORE,
        ui::GetChromeOSDeviceName(), learn_more, &offsets);
    styled_label->SetText(label_expiring);
    styled_label->AddStyleRange(
        gfx::Range(offsets.back(), offsets.back() + learn_more.length()),
        views::StyledLabel::RangeStyleInfo::CreateForLink(
            base::BindRepeating(&PasspointDialogView::OnLearnMoreClicked,
                                weak_factory_.GetWeakPtr())));
  }
  return styled_label;
}

std::unique_ptr<views::View> PasspointDialogView::MakeSubscriptionLabelView(
    std::string_view friendly_name) {
  std::vector<size_t> offsets;
  const std::u16string learn_more = l10n_util::GetStringUTF16(
      IDS_ASH_ARC_PASSPOINT_APP_APPROVAL_LEARN_MORE_LABEL);
  const std::u16string label = l10n_util::GetStringFUTF16(
      IDS_ASH_ARC_PASSPOINT_APP_APPROVAL_SUBSCRIPTION_BODY_WITH_LEARN_MORE,
      {ui::GetChromeOSDeviceName(), base::UTF8ToUTF16(friendly_name),
       learn_more},
      &offsets);
  std::unique_ptr<views::StyledLabel> styled_label =
      views::Builder<views::StyledLabel>()
          .CopyAddressTo(&body_subscription_text_)
          .SetText(label)
          .SetHorizontalAlignment(gfx::ALIGN_LEFT)
          .SetAutoColorReadabilityEnabled(false)
          .AddStyleRange(
              gfx::Range(offsets.back(), offsets.back() + learn_more.length()),
              views::StyledLabel::RangeStyleInfo::CreateForLink(
                  base::BindRepeating(&PasspointDialogView::OnLearnMoreClicked,
                                      weak_factory_.GetWeakPtr())))
          .Build();
  return styled_label;
}

std::unique_ptr<views::View> PasspointDialogView::MakeContentsView(
    bool is_expiring,
    std::string_view friendly_name) {
  views::LayoutProvider* provider = views::LayoutProvider::Get();
  std::unique_ptr<views::BoxLayoutView> contents =
      views::Builder<views::BoxLayoutView>()
          .SetOrientation(views::BoxLayout::Orientation::kVertical)
          .SetProperty(views::kMarginsKey,
                       gfx::Insets::TLBR(0, 0, kDialogBodyBottomMargin, 0))
          .SetBetweenChildSpacing(provider->GetDistanceMetric(
              views::DISTANCE_RELATED_CONTROL_VERTICAL))
          .Build();

  contents->AddChildView(MakeBaseLabelView(is_expiring));
  if (is_expiring) {
    contents->AddChildView(MakeSubscriptionLabelView(friendly_name));
  }
  return contents;
}

std::unique_ptr<views::View> PasspointDialogView::MakeButtonsView() {
  views::LayoutProvider* provider = views::LayoutProvider::Get();
  return views::Builder<views::BoxLayoutView>()
      .SetOrientation(views::BoxLayout::Orientation::kHorizontal)
      .SetMainAxisAlignment(views::BoxLayout::MainAxisAlignment::kEnd)
      .SetBetweenChildSpacing(provider->GetDistanceMetric(
          views::DistanceMetric::DISTANCE_RELATED_BUTTON_HORIZONTAL))
      .AddChildren(
          views::Builder<views::MdTextButton>()  // Don't allow button.
              .CopyAddressTo(&dont_allow_button_)
              .SetCallback(base::BindRepeating(
                  &PasspointDialogView::OnButtonClicked,
                  weak_factory_.GetWeakPtr(), /*allow=*/false))
              .SetText(l10n_util::GetStringUTF16(
                  IDS_ASH_ARC_PASSPOINT_APP_APPROVAL_DONT_ALLOW_BUTTON))
              .SetStyle(ui::ButtonStyle::kDefault)
              .SetIsDefault(false),
          views::Builder<views::MdTextButton>()  // Allow button.
              .CopyAddressTo(&allow_button_)
              .SetCallback(base::BindRepeating(
                  &PasspointDialogView::OnButtonClicked,
                  weak_factory_.GetWeakPtr(), /*allow=*/true))
              .SetText(l10n_util::GetStringUTF16(
                  IDS_ASH_ARC_PASSPOINT_APP_APPROVAL_ALLOW_BUTTON))
              .SetStyle(ui::ButtonStyle::kProminent)
              .SetIsDefault(true))
      .Build();
}

void PasspointDialogView::OnLearnMoreClicked() {
  BrowserUrlOpener::Get()->OpenUrl(GURL(kPasspointHelpPage));
}

void PasspointDialogView::OnButtonClicked(bool allow) {
  // Callback can be null as this is also called on dialog deletion.
  if (!callback_) {
    return;
  }
  std::move(callback_).Run(mojom::PasspointApprovalResponse::New(allow));
}

void PasspointDialogView::Show(aura::Window* parent,
                               mojom::PasspointApprovalRequestPtr request,
                               PasspointDialogCallback callback) {
  // This is safe because the callback is triggered only on button click which
  // requires the dialog to be valid. The dialog is deleted alongside the
  // parent's destructor.
  auto remove_overlay =
      base::BindOnce(&OverlayDialog::CloseIfAny, base::Unretained(parent));

  auto dialog_view = std::make_unique<PasspointDialogView>(
      std::move(request), std::move(callback).Then(std::move(remove_overlay)));
  auto* dialog_view_ptr = dialog_view.get();

  OverlayDialog::Show(
      parent,
      base::BindOnce(&PasspointDialogView::OnButtonClicked,
                     base::Unretained(dialog_view_ptr), /*allow=*/false),
      std::move(dialog_view));
}

}  // namespace arc