chromium/ash/wm/window_restore/informed_restore_controller.cc

// 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 "ash/wm/window_restore/informed_restore_controller.h"

#include "ash/birch/birch_model.h"
#include "ash/constants/ash_features.h"
#include "ash/constants/ash_pref_names.h"
#include "ash/constants/ash_switches.h"
#include "ash/constants/notifier_catalogs.h"
#include "ash/display/screen_ash.h"
#include "ash/public/cpp/image_util.h"
#include "ash/public/cpp/resources/grit/ash_public_unscaled_resources.h"
#include "ash/public/cpp/system/anchored_nudge_data.h"
#include "ash/public/cpp/system/anchored_nudge_manager.h"
#include "ash/public/cpp/window_properties.h"
#include "ash/screen_util.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/shell_delegate.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/dark_light_mode_controller_impl.h"
#include "ash/style/system_dialog_delegate_view.h"
#include "ash/system/toast/toast_manager_impl.h"
#include "ash/wm/desks/desks_util.h"
#include "ash/wm/overview/overview_controller.h"
#include "ash/wm/overview/overview_grid.h"
#include "ash/wm/overview/overview_session.h"
#include "ash/wm/window_restore/informed_restore_constants.h"
#include "ash/wm/window_restore/informed_restore_contents_data.h"
#include "ash/wm/window_restore/window_restore_metrics.h"
#include "ash/wm/window_restore/window_restore_util.h"
#include "ash/wm/window_util.h"
#include "base/command_line.h"
#include "base/files/file_util.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/metrics/histogram_functions.h"
#include "base/task/task_traits.h"
#include "base/task/thread_pool.h"
#include "chromeos/ui/base/app_types.h"
#include "chromeos/ui/base/display_util.h"
#include "chromeos/ui/base/window_properties.h"
#include "components/prefs/pref_service.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/mojom/ui_base_types.mojom-shared.h"
#include "ui/base/ui_base_types.h"
#include "ui/compositor/layer.h"
#include "ui/views/background.h"
#include "ui/views/controls/button/label_button.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/view_class_properties.h"

namespace ash {

namespace {

// The nudge will not be shown if it already been shown 3 times, or if 24 hours
// have not yet passed since it was last shown.
constexpr int kNudgeMaxShownCount = 3;
constexpr base::TimeDelta kNudgeTimeBetweenShown = base::Hours(24);

bool ShouldShowInformedRestoreImage(const gfx::ImageSkia& image) {
  if (image.isNull()) {
    return false;
  }

  const gfx::Size image_size = image.size();
  const bool is_image_landscape = image_size.width() > image_size.height();

  // TODO(minch|sammiequon): The informed restore dialog will only be shown
  // inside the primary display for now. Change the logic here if it changes.
  const display::Display display_with_dialog =
      display::Screen::GetScreen()->GetPrimaryDisplay();
  const bool is_display_landscape = chromeos::IsLandscapeOrientation(
      chromeos::GetDisplayCurrentOrientation(display_with_dialog));

  // Show the image only if the image and the display showing it both have the
  // same orientation.
  return is_image_landscape == is_display_landscape;
}

PrefService* GetActivePrefService() {
  return Shell::Get()->session_controller()->GetActivePrefService();
}

// Returns true if this is the first time login and we should show the informed
// restore onboarding message.
bool ShouldStartInformedRestoreOnboarding() {
  // This dialog is modal and can interfere with some browser tests that aren't
  // expecting it. Do not show it by default.
  if (Shell::Get()->shell_delegate()->IsNoFirstRunSwitchOn()) {
    return false;
  }
  PrefService* prefs = GetActivePrefService();
  return prefs && prefs->GetBoolean(prefs::kShowInformedRestoreOnboarding);
}

}  // namespace

InformedRestoreController::InformedRestoreController() {
  Shell::Get()->overview_controller()->AddObserver(this);

  activation_change_observation_.Observe(Shell::Get()->activation_client());
}

InformedRestoreController::~InformedRestoreController() {
  Shell::Get()->overview_controller()->RemoveObserver(this);
}

void InformedRestoreController::MaybeShowInformedRestoreOnboarding(
    bool restore_on) {
  if (onboarding_widget_ || !ShouldStartInformedRestoreOnboarding()) {
    return;
  }
  GetActivePrefService()->SetBoolean(prefs::kShowInformedRestoreOnboarding,
                                     false);

  auto dialog =
      views::Builder<SystemDialogDelegateView>()
          .SetTitleText(
              l10n_util::GetStringUTF16(IDS_ASH_INFORMED_RESTORE_DIALOG_TITLE))
          .SetDescription(l10n_util::GetStringUTF16(
              IDS_ASH_INFORMED_RESTORE_ONBOARDING_DESCRIPTION))
          .SetAcceptButtonText(l10n_util::GetStringUTF16(
              restore_on
                  ? IDS_ASH_INFORMED_RESTORE_ONBOARDING_RESTORE_ON_ACCEPT
                  : IDS_ASH_INFORMED_RESTORE_ONBOARDING_RESTORE_OFF_ACCEPT))
          .SetAcceptCallback(base::BindOnce(
              &InformedRestoreController::OnOnboardingAcceptPressed,
              base::Unretained(this), restore_on))
          .Build();

  // Since no additional view was set, the buttons will be center aligned.
  dialog->SetButtonContainerAlignment(views::LayoutAlignment::kCenter);
  dialog->SetLayoutManager(std::make_unique<views::FlexLayout>())
      ->SetOrientation(views::LayoutOrientation::kVertical)
      .SetMainAxisAlignment(views::LayoutAlignment::kCenter)
      .SetCrossAxisAlignment(views::LayoutAlignment::kCenter)
      .SetCollapseMargins(true);
  dialog->SetModalType(ui::mojom::ModalType::kSystem);
  dialog->SetTopContentView(
      views::Builder<views::ImageView>()
          .SetImage(
              ui::ResourceBundle::GetSharedInstance().GetThemedLottieImageNamed(
                  IDR_INFORMED_RESTORE_ONBOARDING_IMAGE))
          .Build());
  dialog->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(views::MinimumFlexSizeRule::kPreferred,
                               views::MaximumFlexSizeRule::kUnbounded));
  if (restore_on) {
    // If the user had the restore pref set as "Ask every time", don't show the
    // Cancel button.
    dialog->SetCancelButtonVisible(false);
  } else {
    dialog->SetCancelButtonText(l10n_util::GetStringUTF16(
        IDS_ASH_INFORMED_RESTORE_ONBOARDING_RESTORE_OFF_CANCEL));
    // `this` is guaranteed to outlive the dialog.
    dialog->SetCancelCallback(
        base::BindOnce(&InformedRestoreController::OnOnboardingCancelPressed,
                       base::Unretained(this)));
  }

  views::Widget::InitParams params(
      views::Widget::InitParams::WIDGET_OWNS_NATIVE_WIDGET,
      views::Widget::InitParams::TYPE_WINDOW_FRAMELESS);
  params.name = "PineOnboardingWidget";
  params.delegate = dialog.release();

  onboarding_widget_ = std::make_unique<views::Widget>(std::move(params));
  onboarding_widget_->Show();
}

void InformedRestoreController::
    MaybeStartInformedRestoreSessionDevAccelerator() {
  auto data = std::make_unique<InformedRestoreContentsData>();
  std::pair<base::OnceClosure, base::OnceClosure> split =
      base::SplitOnceCallback(base::BindOnce(
          &InformedRestoreController::MaybeEndInformedRestoreSession,
          weak_ptr_factory_.GetWeakPtr()));
  data->restore_callback = std::move(split.first);
  data->cancel_callback = std::move(split.second);

  // NOTE: Comment/uncomment the following apps locally, but avoid changes as to
  // reduce merge conflicts.
  // Chrome.
  data->apps_infos.emplace_back(
      "mgndgikekgjfcpckkfioiadnlibdjbkf", /*tab_title=*/"Reddit",
      /*window_id=*/0,
      std::vector<GURL>{
          GURL("https://www.cnn.com/"), GURL("https://www.reddit.com/"),
          GURL("https://www.youtube.com/"), GURL("https://www.waymo.com/"),
          GURL("https://www.google.com/")},
      /*tab_count=*/10u, /*lacros_profile_id=*/0);
  // PWA.
  data->apps_infos.emplace_back("kjgfgldnnfoeklkmfkjfagphfepbbdan", "Meet",
                                /*window_id=*/0);

  // SWA.
  data->apps_infos.emplace_back("njfbnohfdkmbmnjapinfcopialeghnmh", "Camera",
                                /*window_id=*/0);
  data->apps_infos.emplace_back("odknhmnlageboeamepcngndbggdpaobj", "Settings",
                                /*window_id=*/0);
  data->apps_infos.emplace_back("fkiggjmkendpmbegkagpmagjepfkpmeb", "Files",
                                /*window_id=*/0);
  data->apps_infos.emplace_back("oabkinaljpjeilageghcdlnekhphhphl",
                                "Calculator", /*window_id=*/0);

  data->apps_infos.emplace_back(
      "mgndgikekgjfcpckkfioiadnlibdjbkf", /*tab_title=*/"Maps", /*window_id=*/0,
      std::vector<GURL>{GURL("https://www.google.com/maps/")},
      /*tab_count=*/1, /*lacros_profile_id=*/0);
  data->apps_infos.emplace_back("fkiggjmkendpmbegkagpmagjepfkpmeb", "Files",
                                /*window_id=*/0);
  data->apps_infos.emplace_back(
      "mgndgikekgjfcpckkfioiadnlibdjbkf", /*tab_title=*/"Twitter",
      /*window_id=*/0,
      std::vector<GURL>{GURL("https://www.twitter.com/"),
                        GURL("https://www.youtube.com/"),
                        GURL("https://www.google.com/")},
      /*tab_count=*/3u, /*lacros_profile_id=*/0);

  MaybeStartInformedRestoreSession(std::move(data));
}

void InformedRestoreController::MaybeStartInformedRestoreSession(
    std::unique_ptr<InformedRestoreContentsData> contents_data) {
  CHECK(features::IsForestFeatureEnabled());

  if (OverviewController::Get()->InOverviewSession()) {
    return;
  }

  // TODO(hewer|sammiequon): This function should only be called once in
  // production code when `contents_data_` is null. It can be called multiple
  // times currently via dev accelerator. Remove this block when
  // `MaybeStartInformedRestoreSessionDevAccelerator()` is removed.
  if (contents_data_) {
    StartInformedRestoreSession();
    return;
  }

  contents_data_ = std::move(contents_data);

  // If this is the first time starting informed restore, show the onboarding
  // dialog instead. Informed restore session will be started if the user hits
  // 'Accept'.
  if (ShouldStartInformedRestoreOnboarding()) {
    MaybeShowInformedRestoreOnboarding(/*restore_on=*/true);
    return;
  }

  if (!contents_data_) {
    return;
  }

  RecordScreenshotDurations(Shell::Get()->local_state());
  image_util::DecodeImageFile(
      base::BindOnce(&InformedRestoreController::OnInformedRestoreImageDecoded,
                     weak_ptr_factory_.GetWeakPtr(), base::TimeTicks::Now()),
      GetInformedRestoreImagePath(), data_decoder::mojom::ImageCodec::kPng);
}

void InformedRestoreController::MaybeEndInformedRestoreSession() {
  contents_data_.reset();
  OverviewController::Get()->EndOverview(OverviewEndAction::kPine,
                                         OverviewEnterExitType::kNormal);
}

base::CallbackListSubscription
InformedRestoreController::RegisterContentsDataUpdateCallback(
    base::RepeatingClosure callback) {
  return contents_data_update_callbacks_.Add(std::move(callback));
}

void InformedRestoreController::OnContentsDataUpdated() {
  contents_data_update_callbacks_.Notify();
}

void InformedRestoreController::OnOverviewModeEnding(
    OverviewSession* overview_session) {
  in_informed_restore_ = false;
  for (const auto& grid : overview_session->grid_list()) {
    if (grid->informed_restore_widget()) {
      in_informed_restore_ = true;
      break;
    }
  }
}

void InformedRestoreController::OnOverviewModeEndingAnimationComplete(bool canceled) {
  // If `canceled` is true, overview was reentered before the exit animations
  // were finished. `in_informed_restore_` will be reset the next time overview
  // ends.
  if (canceled || !in_informed_restore_) {
    return;
  }

  in_informed_restore_ = false;

  // In multi-user scenario, forest may have been available for the user that
  // started overview, but not for the current user. (Switching users ends
  // overview.)
  if (!features::IsForestFeatureEnabled()) {
    return;
  }

  PrefService* prefs = GetActivePrefService();
  if (!prefs) {
    return;
  }

  // Nudge has already been shown three times. No need to educate anymore.
  const int shown_count =
      prefs->GetInteger(prefs::kInformedRestoreNudgeShownCount);
  if (shown_count >= kNudgeMaxShownCount) {
    return;
  }

  // Nudge has been shown within the last 24 hours already.
  base::Time now = base::Time::Now();
  if (now - prefs->GetTime(prefs::kInformedRestoreNudgeLastShown) <
      kNudgeTimeBetweenShown) {
    return;
  }

  AnchoredNudgeData nudge_data(
      informed_restore::kSuggestionsNudgeId,
      NudgeCatalogName::kInformedRestoreEducationNudge,
      l10n_util::GetStringUTF16(IDS_ASH_INFORMED_RESTORE_EDUCATION_NUDGE));
  nudge_data.image_model =
      ui::ResourceBundle::GetSharedInstance().GetThemedLottieImageNamed(
          DarkLightModeControllerImpl::Get()->IsDarkModeEnabled()
              ? IDR_INFORMED_RESTORE_NUDGE_IMAGE_DM
              : IDR_INFORMED_RESTORE_NUDGE_IMAGE_LM);
  nudge_data.fill_image_size = true;
  AnchoredNudgeManager::Get()->Show(nudge_data);

  prefs->SetInteger(prefs::kInformedRestoreNudgeShownCount, shown_count + 1);
  prefs->SetTime(prefs::kInformedRestoreNudgeLastShown, now);
}

void InformedRestoreController::OnWindowActivated(ActivationReason reason,
                                       aura::Window* gained_active,
                                       aura::Window* lost_active) {
  if (gained_active && window_util::IsWindowUserPositionable(gained_active) &&
      gained_active->GetProperty(chromeos::kAppTypeKey) !=
          chromeos::AppType::NON_APP) {
    contents_data_.reset();
  }
}

void InformedRestoreController::OnInformedRestoreImageDecoded(
    base::TimeTicks start_time,
    const gfx::ImageSkia& image) {
  CHECK(contents_data_);
  RecordScreenshotDecodeDuration(base::TimeTicks::Now() - start_time);

  if (ShouldShowInformedRestoreImage(image)) {
    contents_data_->image = image;
  } else {
    RecordScreenshotOnShutdownStatus(
        ScreenshotOnShutdownStatus::kFailedOnDifferentOrientations);
  }
  // Delete the image from the disk to avoid stale screenshot on next time showing the dialog.
  base::ThreadPool::PostTask(
      FROM_HERE, {base::MayBlock(), base::TaskPriority::HIGHEST},
      base::BindOnce(base::IgnoreResult(&base::DeleteFile),
                     GetInformedRestoreImagePath()));

  StartInformedRestoreSession();
}

void InformedRestoreController::StartInformedRestoreSession() {
  if (base::CommandLine::ForCurrentProcess()->HasSwitch(
          switches::kForceBirchFetch)) {
    LOG(WARNING) << "Forcing Birch data fetch";
    Shell::Get()->birch_model()->RequestBirchDataFetch(
        /*is_post_login=*/false, base::BindOnce([]() {
          // Dump the items that were fetched.
          LOG(WARNING) << "All items:";
          auto all_items = Shell::Get()->birch_model()->GetAllItems();
          for (const auto& item : all_items) {
            LOG(WARNING) << item->ToString();
          }
          // Dump the items for display.
          LOG(WARNING) << "Items for display:";
          auto display_items =
              Shell::Get()->birch_model()->GetItemsForDisplay();
          for (const auto& item : display_items) {
            LOG(WARNING) << item->ToString();
          }
        }));
  }

  base::UmaHistogramBoolean(kFullRestoreDialogHistogram, true);

  OverviewController::Get()->StartOverview(
      OverviewStartAction::kPine, OverviewEnterExitType::kInformedRestore);
}

void InformedRestoreController::OnOnboardingAcceptPressed(bool restore_on) {
  // Wait until the onboarding widget is destroyed before starting overview,
  // since we disallow entering overview while system modal windows are open.
  // Use a weak ptr since `this` can be deleted before we close all windows.
  // Only do this if we have contents data.
  if (contents_data_) {
    onboarding_widget_->widget_delegate()->RegisterDeleteDelegateCallback(
        base::BindOnce(
            [](const base::WeakPtr<InformedRestoreController>& weak_this) {
              if (weak_this) {
                weak_this->StartInformedRestoreSession();
              }
            },
            weak_ptr_factory_.GetWeakPtr()));
  }

  if (restore_on) {
    return;
  }

  // The onboarding dialog would only be shown if `GetActivePrefService()` is
  // not null.
  GetActivePrefService()->SetInteger(
      prefs::kRestoreAppsAndPagesPrefName,
      static_cast<int>(full_restore::RestoreOption::kAskEveryTime));

  // Show toast letting users know the pref change will affect them next
  // session.
  Shell::Get()->toast_manager()->Show(ToastData(
      informed_restore::kOnboardingToastId,
      ToastCatalogName::kInformedRestoreOnboarding,
      l10n_util::GetStringUTF16(IDS_ASH_INFORMED_RESTORE_ONBOARDING_TOAST)));

  // We only record the action taken if the user had Restore off.
  RecordOnboardingAction(/*restore=*/true);
}

void InformedRestoreController::OnOnboardingCancelPressed() {
  // The cancel button would only exist if the user had Restore off.
  RecordOnboardingAction(/*restore=*/false);
}

}  // namespace ash