chromium/chrome/browser/ui/views/bruschetta/bruschetta_installer_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 "chrome/browser/ui/views/bruschetta/bruschetta_installer_view.h"

#include <memory>
#include <optional>

#include "ash/public/cpp/new_window_delegate.h"
#include "ash/public/cpp/style/dark_light_mode_controller.h"
#include "base/functional/bind.h"
#include "base/functional/callback_forward.h"
#include "base/strings/utf_string_conversions.h"
#include "chrome/browser/ash/bruschetta/bruschetta_installer.h"
#include "chrome/browser/ash/bruschetta/bruschetta_installer_impl.h"
#include "chrome/browser/ash/bruschetta/bruschetta_pref_names.h"
#include "chrome/browser/ash/bruschetta/bruschetta_service.h"
#include "chrome/browser/ash/bruschetta/bruschetta_util.h"
#include "chrome/browser/ui/views/chrome_typography.h"
#include "chrome/grit/generated_resources.h"
#include "components/strings/grit/components_strings.h"
#include "content/public/browser/browser_thread.h"
#include "ui/accessibility/ax_node_data.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/mojom/dialog_button.mojom.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/strings/grit/ui_strings.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/controls/button/radio_button.h"
#include "ui/views/controls/label.h"
#include "ui/views/controls/link.h"
#include "ui/views/controls/progress_bar.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/style/typography.h"
#include "ui/views/style/typography_provider.h"
#include "ui/views/view_class_properties.h"

namespace {

BruschettaInstallerView* g_bruschetta_installer_view = nullptr;

constexpr auto kButtonRowInsets = gfx::Insets::TLBR(0, 64, 32, 64);
constexpr int kWindowWidth = 768;
constexpr int kWindowHeight = 636;

std::u16string GetDetailedErrorMessage(
    bruschetta::BruschettaInstallResult error) {
  switch (error) {
    case bruschetta::BruschettaInstallResult::kInstallationProhibited:
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_PROHIBITED_MESSAGE,
          bruschetta::BruschettaInstallResultString(error));

    case bruschetta::BruschettaInstallResult::kDownloadError:
    case bruschetta::BruschettaInstallResult::kInvalidBootDisk:
    case bruschetta::BruschettaInstallResult::kInvalidPflash:
    case bruschetta::BruschettaInstallResult::kUnableToOpenImages:
      // These are all probably download or disk errors.
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_DOWNLOAD_ERROR_MESSAGE,
          bruschetta::BruschettaInstallResultString(error));

    case bruschetta::BruschettaInstallResult::kToolsDlcOfflineError:
    case bruschetta::BruschettaInstallResult::kFirmwareDlcOfflineError:
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_DOWNLOAD_ERROR_MESSAGE,
          bruschetta::BruschettaInstallResultString(error));

    case bruschetta::BruschettaInstallResult::kToolsDlcDiskFullError:
    case bruschetta::BruschettaInstallResult::kFirmwareDlcDiskFullError:
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_DISK_FULL_ERROR_MESSAGE,
          bruschetta::BruschettaInstallResultString(error));

    case bruschetta::BruschettaInstallResult::kToolsDlcNeedUpdateError:
    case bruschetta::BruschettaInstallResult::kFirmwareDlcNeedUpdateError:
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_DLC_NEED_UPDATE_ERROR_MESSAGE,
          bruschetta::BruschettaInstallResultString(error));

    case bruschetta::BruschettaInstallResult::kToolsDlcNeedRebootError:
    case bruschetta::BruschettaInstallResult::kFirmwareDlcNeedRebootError:
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_NEED_REBOOT_ERROR_MESSAGE,
          bruschetta::BruschettaInstallResultString(error));

    case bruschetta::BruschettaInstallResult::kToolsDlcBusyError:
    case bruschetta::BruschettaInstallResult::kToolsDlcUnknownError:
    case bruschetta::BruschettaInstallResult::kFirmwareDlcBusyError:
    case bruschetta::BruschettaInstallResult::kFirmwareDlcUnknownError:
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_GENERIC_DLC_ERROR_MESSAGE,
          bruschetta::BruschettaInstallResultString(error));

    default:
      // Everything else gets a generic error, they can get dedicated error
      // messages later if it's useful.
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_GENERIC_ERROR_MESSAGE,
          bruschetta::BruschettaInstallResultString(error));
  }
}

}  // namespace

// static
void BruschettaInstallerView::Show(Profile* profile,
                                   const guest_os::GuestId& guest_id) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
  if (bruschetta::GetInstallableConfigs(profile).empty()) {
    LOG(ERROR)
        << "Bruschetta has no installable configs, not running the installer.";
    return;
  }
  if (!g_bruschetta_installer_view) {
    g_bruschetta_installer_view =
        new BruschettaInstallerView(profile, guest_id);
    views::DialogDelegate::CreateDialogWidget(g_bruschetta_installer_view,
                                              nullptr, nullptr);
  }
  g_bruschetta_installer_view->SetButtonRowInsets(kButtonRowInsets);

  g_bruschetta_installer_view->GetWidget()->Show();
}

// static
BruschettaInstallerView* BruschettaInstallerView::GetActiveViewForTesting() {
  return g_bruschetta_installer_view;
}

// We need a separate class so that we can alert screen readers appropriately
// when the text changes.
class BruschettaInstallerView::TitleLabel : public views::Label {
  METADATA_HEADER(TitleLabel, views::Label)

 public:
  using Label::Label;

  TitleLabel() { GetViewAccessibility().SetRole(ax::mojom::Role::kStatus); }
  ~TitleLabel() override = default;
};

BEGIN_METADATA(BruschettaInstallerView, TitleLabel)
END_METADATA

BruschettaInstallerView::BruschettaInstallerView(Profile* profile,
                                                 guest_os::GuestId guest_id)
    : profile_(profile), observation_(this), guest_id_(guest_id) {
  // Layout constants from the spec used for the plugin vm installer.
  constexpr auto kDialogInsets = gfx::Insets::TLBR(60, 64, 0, 64);
  const auto& typography_provider = views::TypographyProvider::Get();
  const int kPrimaryMessageHeight = typography_provider.GetLineHeight(
      CONTEXT_HEADLINE, views::style::STYLE_PRIMARY);
  const int kSecondaryMessageHeight = typography_provider.GetLineHeight(
      views::style::CONTEXT_DIALOG_BODY_TEXT, views::style::STYLE_SECONDARY);
  constexpr int kProgressBarHeight = 5;
  constexpr int kProgressBarTopMargin = 32;

  // Set the window title.
  SetTitle(l10n_util::GetStringFUTF16(IDS_BRUSCHETTA_INSTALLER_WINDOW_TITLE,
                                      bruschetta::GetOverallVmName(profile)));

  SetCanMinimize(true);
  set_draggable(true);
  // Removed margins so dialog insets specify it instead.
  set_margins(gfx::Insets());

  SetLayoutManager(std::make_unique<views::BoxLayout>(
      views::BoxLayout::Orientation::kVertical, kDialogInsets));

  views::View* upper_container_view =
      AddChildView(std::make_unique<views::View>());
  upper_container_view->SetLayoutManager(std::make_unique<views::BoxLayout>(
      views::BoxLayout::Orientation::kVertical, gfx::Insets()));

  radio_button_container_ = AddChildView(std::make_unique<views::View>());
  radio_button_container_->SetLayoutManager(std::make_unique<views::BoxLayout>(
      views::BoxLayout::Orientation::kVertical));

  primary_message_label_ = new TitleLabel(GetPrimaryMessage(), CONTEXT_HEADLINE,
                                          views::style::STYLE_PRIMARY);
  primary_message_label_->SetProperty(
      views::kMarginsKey, gfx::Insets::TLBR(kPrimaryMessageHeight, 0, 0, 0));
  primary_message_label_->SetMultiLine(false);
  primary_message_label_->SetHorizontalAlignment(gfx::ALIGN_LEFT);
  upper_container_view->AddChildView(primary_message_label_.get());

  views::View* secondary_message_container_view =
      AddChildView(std::make_unique<views::View>());
  secondary_message_container_view->SetLayoutManager(
      std::make_unique<views::BoxLayout>(
          views::BoxLayout::Orientation::kVertical,
          gfx::Insets::TLBR(kSecondaryMessageHeight, 0, 0, 0)));
  upper_container_view->AddChildView(secondary_message_container_view);
  // The label content will be populated by SetSecondaryMessageLabel shortly.
  secondary_message_label_ =
      new views::Label(u"", views::style::CONTEXT_DIALOG_BODY_TEXT,
                       views::style::STYLE_SECONDARY);
  secondary_message_label_->SetMultiLine(true);
  secondary_message_label_->SetHorizontalAlignment(gfx::ALIGN_LEFT);
  secondary_message_container_view->AddChildView(
      secondary_message_label_.get());

  // Add "Learn More" link.
  link_label_ = new views::Link(l10n_util::GetStringUTF16(IDS_LEARN_MORE));
  link_label_->SetHorizontalAlignment(gfx::ALIGN_LEFT);
  learn_more_url_ = bruschetta::GetLearnMoreUrl(profile_);
  link_label_->SetCallback(base::BindRepeating(
      [](GURL url) {
        ash::NewWindowDelegate::GetPrimary()->OpenUrl(
            url, ash::NewWindowDelegate::OpenUrlFrom::kUserInteraction,
            ash::NewWindowDelegate::Disposition::kNewForegroundTab);
      },
      learn_more_url_));
  secondary_message_container_view->AddChildView(link_label_.get());

  progress_bar_ = new views::ProgressBar();
  progress_bar_->SetPreferredHeight(kProgressBarHeight);
  progress_bar_->SetProperty(
      views::kMarginsKey,
      gfx::Insets::TLBR(kProgressBarTopMargin - kProgressBarHeight, 0, 0, 0));
  upper_container_view->AddChildView(progress_bar_.get());

  // Add list of configs in display order.
  {
    std::vector<bruschetta::InstallableConfig> configs =
        bruschetta::GetInstallableConfigs(profile_).extract();
    bruschetta::SortInstallableConfigs(&configs);
    for (const auto& [config_name, config_dict] : configs) {
      const auto& label =
          config_dict.Find(bruschetta::prefs::kPolicyNameKey)->GetString();

      auto* radio_button = radio_button_container_->AddChildView(
          std::make_unique<views::RadioButton>(base::UTF8ToUTF16(label)));

      radio_buttons_.emplace(config_name, radio_button);
    }
  }

  DCHECK(radio_button_container_->children().size() > 0);
  static_cast<views::RadioButton*>(radio_button_container_->children()[0])
      ->SetChecked(true);

  ash::DarkLightModeController* dark_light_controller =
      ash::DarkLightModeController::Get();
  if (dark_light_controller) {
    dark_light_controller->AddObserver(this);
  }
  installer_factory_ =
      base::BindRepeating([](Profile* profile, base::OnceClosure closure) {
        return static_cast<std::unique_ptr<bruschetta::BruschettaInstaller>>(
            std::make_unique<bruschetta::BruschettaInstallerImpl>(
                profile, std::move(closure)));
      });
}

BruschettaInstallerView::~BruschettaInstallerView() {
  // installer_->Cancel calls back into us, so remember that we're being
  // destroyed now to avoid doing work (that crashes us) in the callback.
  is_destroying_ = true;
  if (installer_) {
    installer_->Cancel();
  }
  observation_.Reset();
  ash::DarkLightModeController* dark_light_controller =
      ash::DarkLightModeController::Get();
  if (dark_light_controller) {
    dark_light_controller->RemoveObserver(this);
  }
  g_bruschetta_installer_view = nullptr;
}

bool BruschettaInstallerView::Accept() {
  DCHECK(state_ == State::kConfirmInstall || state_ == State::kFailed ||
         state_ == State::kFailedCleanup);

  if (state_ == State::kConfirmInstall) {
    std::optional<std::string> selected_config;
    for (const auto& it : radio_buttons_) {
      if (it.second->GetChecked()) {
        selected_config = it.first;
      }
    }

    DCHECK(selected_config.has_value()) << "No install config selected";
    selected_config_ = *selected_config;

    RemoveChildViewT(radio_button_container_.get());
    radio_button_container_ = nullptr;
    radio_buttons_.clear();
    NotifyAccessibilityEvent(ax::mojom::Event::kChildrenChanged, true);
  }

  observation_.Reset();
  installer_.reset();
  StartInstallation();
  return false;
}

bool BruschettaInstallerView::Cancel() {
  if (state_ == State::kInstalling) {
    CleanupPartialInstall();
  }
  // We're about to get destroyed, and since all the cleanup happens in our
  // destructor there's nothing special to do here.
  return true;
}

void BruschettaInstallerView::StartInstallation() {
  state_ = State::kInstalling;
  progress_bar_->SetValue(-1);

  DCHECK(!installer_)
      << "Expect to create a new installer every run, but already had one";
  installer_ = installer_factory_.Run(
      profile_, base::BindOnce(&BruschettaInstallerView::OnInstallationEnded,
                               weak_factory_.GetWeakPtr()));
  observation_.Observe(installer_.get());
  installer_->Install(guest_id_.vm_name, selected_config_);

  OnStateUpdated();
}

void BruschettaInstallerView::StateChanged(InstallerState new_state) {
  VLOG(2) << "State changed: " << static_cast<int>(installing_state_) << " -> "
          << static_cast<int>(new_state);
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
  DCHECK_EQ(state_, State::kInstalling);
  installing_state_ = new_state;
  OnStateUpdated();
}

void BruschettaInstallerView::Error(bruschetta::BruschettaInstallResult error) {
  error_ = error;
  CleanupPartialInstall();
  OnStateUpdated();
}

void BruschettaInstallerView::OnInstallationEnded() {
  if (is_destroying_) {
    return;
  }
  observation_.Reset();
  installer_.reset();
  GetWidget()->CloseWithReason(views::Widget::ClosedReason::kUnspecified);

  if (finish_callback_) {
    std::move(finish_callback_)
        .Run(bruschetta::BruschettaInstallResult::kSuccess);
  }
}

bool BruschettaInstallerView::ShouldShowCloseButton() const {
  return true;
}

bool BruschettaInstallerView::ShouldShowWindowTitle() const {
  return false;
}

gfx::Size BruschettaInstallerView::CalculatePreferredSize(
    const views::SizeBounds& /*available_size*/) const {
  return gfx::Size(kWindowWidth, kWindowHeight);
}

std::u16string BruschettaInstallerView::GetPrimaryMessage() const {
  switch (state_) {
    case State::kConfirmInstall:
      return l10n_util::GetStringUTF16(
          IDS_BRUSCHETTA_INSTALLER_CONFIRMATION_TITLE);
    case State::kInstalling:
      return l10n_util::GetStringUTF16(IDS_BRUSCHETTA_INSTALLER_ONGOING_TITLE);
    case State::kCleaningUp:
    case State::kFailed:
    case State::kFailedCleanup:
      return l10n_util::GetStringUTF16(IDS_BRUSCHETTA_INSTALLER_ERROR_TITLE);
  }
}

std::u16string BruschettaInstallerView::GetSecondaryMessage() const {
  switch (state_) {
    case State::kConfirmInstall:
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_CONFIRMATION_MESSAGE,
          bruschetta::GetOverallVmName((profile_)));
    case State::kInstalling:
      switch (installing_state_) {
        case InstallerState::kInstallStarted:
          // We don't really spend any time in the InstallStarted state, the
          // real first step is installing DLC so fall through to that.
        case InstallerState::kToolsDlcInstall:
        case InstallerState::kFirmwareDlcInstall:
          return l10n_util::GetStringUTF16(
              IDS_BRUSCHETTA_INSTALLER_INSTALLING_DLC_MESSAGE);
        case InstallerState::kBootDiskDownload:
        case InstallerState::kPflashDownload:
        case InstallerState::kOpenFiles:
          return l10n_util::GetStringUTF16(
              IDS_BRUSCHETTA_INSTALLER_DOWNLOADING_MESSAGE);
        case InstallerState::kCreateVmDisk:
        case InstallerState::kInstallPflash:
        case InstallerState::kStartVm:
        case InstallerState::kLaunchTerminal:
        case InstallerState::kClearVek:
          return l10n_util::GetStringUTF16(
              IDS_BRUSCHETTA_INSTALLER_STARTING_VM_MESSAGE);
      }
    case State::kCleaningUp:
      return l10n_util::GetStringUTF16(
          IDS_BRUSCHETTA_INSTALLER_CLEANING_UP_MESSAGE);
    case State::kFailed:
      return GetDetailedErrorMessage(error_);
    case State::kFailedCleanup:
      return l10n_util::GetStringFUTF16(
          IDS_BRUSCHETTA_INSTALLER_ERROR_CLEANUP_MESSAGE,
          bruschetta::BruschettaInstallResultString(error_));
  }
}

int BruschettaInstallerView::GetCurrentDialogButtons() const {
  switch (state_) {
    case State::kInstalling:
      return static_cast<int>(ui::mojom::DialogButton::kCancel);
    case State::kConfirmInstall:
      // Cancel | Start installing
      return static_cast<int>(ui::mojom::DialogButton::kCancel) |
             static_cast<int>(ui::mojom::DialogButton::kOk);
    case State::kCleaningUp:
      return 0;
    case State::kFailedCleanup:
    case State::kFailed:
      // Quit | Retry
      return static_cast<int>(ui::mojom::DialogButton::kCancel) |
             static_cast<int>(ui::mojom::DialogButton::kOk);
  }
}

std::u16string BruschettaInstallerView::GetCurrentDialogButtonLabel(
    ui::mojom::DialogButton button) const {
  switch (state_) {
    case State::kConfirmInstall:
      return l10n_util::GetStringUTF16(
          button == ui::mojom::DialogButton::kOk
              ? IDS_BRUSCHETTA_INSTALLER_INSTALL_BUTTON
              : IDS_APP_CANCEL);
    case State::kInstalling:
      DCHECK_EQ(button, ui::mojom::DialogButton::kCancel);
      return l10n_util::GetStringUTF16(IDS_APP_CANCEL);
    case State::kCleaningUp:
      return {};
    case State::kFailed:
    case State::kFailedCleanup:
      return l10n_util::GetStringUTF16(
          button == ui::mojom::DialogButton::kOk
              ? IDS_BRUSCHETTA_INSTALLER_RETRY_BUTTON
              : IDS_APP_CLOSE);
  }
}

void BruschettaInstallerView::OnStateUpdated() {
  SetPrimaryMessageLabel();
  SetSecondaryMessageLabel();

  const bool link_label_visible =
      state_ == State::kConfirmInstall && learn_more_url_.is_valid();
  link_label_->SetVisible(link_label_visible);

  int buttons = GetCurrentDialogButtons();
  SetButtons(buttons);
  if (buttons & static_cast<int>(ui::mojom::DialogButton::kOk)) {
    SetButtonLabel(ui::mojom::DialogButton::kOk,
                   GetCurrentDialogButtonLabel(ui::mojom::DialogButton::kOk));
    SetDefaultButton(static_cast<int>(ui::mojom::DialogButton::kOk));
  } else {
    SetDefaultButton(static_cast<int>(ui::mojom::DialogButton::kNone));
  }
  if (buttons & static_cast<int>(ui::mojom::DialogButton::kCancel)) {
    SetButtonLabel(
        ui::mojom::DialogButton::kCancel,
        GetCurrentDialogButtonLabel(ui::mojom::DialogButton::kCancel));
  }

  const bool progress_bar_visible =
      (state_ == State::kInstalling || state_ == State::kCleaningUp);
  progress_bar_->SetVisible(progress_bar_visible);
  secondary_message_label_->GetViewAccessibility().SetIsIgnored(
      progress_bar_visible);
  if (progress_bar_visible) {
    progress_bar_->GetViewAccessibility().SetDescription(
        *secondary_message_label_);
    progress_bar_->NotifyAccessibilityEvent(ax::mojom::Event::kTextChanged,
                                            true);
  }

  DialogModelChanged();
  primary_message_label_->NotifyAccessibilityEvent(
      ax::mojom::Event::kLiveRegionChanged,
      /* send_native_event = */ true);
}

void BruschettaInstallerView::AddedToWidget() {
  // At this point GetWidget() is guaranteed to return non-null.
  OnStateUpdated();
}

void BruschettaInstallerView::OnColorModeChanged(bool dark_mode_enabled) {
  // We check dark-mode ourselves, so no need to propagate the param.
  OnStateUpdated();
}

void BruschettaInstallerView::SetPrimaryMessageLabel() {
  primary_message_label_->SetText(GetPrimaryMessage());
  primary_message_label_->SetVisible(true);
  primary_message_label_->NotifyAccessibilityEvent(
      ax::mojom::Event::kTextChanged, true);
}

void BruschettaInstallerView::SetSecondaryMessageLabel() {
  secondary_message_label_->SetText(GetSecondaryMessage());
  secondary_message_label_->SetVisible(true);
  secondary_message_label_->NotifyAccessibilityEvent(
      ax::mojom::Event::kTextChanged, true);
}

void BruschettaInstallerView::CleanupPartialInstall() {
  state_ = State::kCleaningUp;
  OnStateUpdated();
  bruschetta::BruschettaService::GetForProfile(profile_)->RemoveVm(
      guest_id_,
      base::BindOnce(&BruschettaInstallerView::UninstallBruschettaFinished,
                     weak_factory_.GetWeakPtr()));
}

void BruschettaInstallerView::UninstallBruschettaFinished(bool success) {
  if (!success) {
    LOG(ERROR) << "Failed to clean up after a failed install";
    state_ = State::kFailedCleanup;
  } else {
    state_ = State::kFailed;
  }
  OnStateUpdated();

  if (finish_callback_) {
    std::move(finish_callback_).Run(error_);
  }
}

BEGIN_METADATA(BruschettaInstallerView)
ADD_READONLY_PROPERTY_METADATA(std::u16string, PrimaryMessage)
ADD_READONLY_PROPERTY_METADATA(std::u16string, SecondaryMessage)
ADD_READONLY_PROPERTY_METADATA(int, CurrentDialogButtons)
END_METADATA