chromium/ash/game_dashboard/game_dashboard_controller.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/game_dashboard/game_dashboard_controller.h"

#include <array>
#include <memory>
#include <string>
#include <vector>

#include "ash/capture_mode/capture_mode_controller.h"
#include "ash/constants/ash_features.h"
#include "ash/constants/ash_pref_names.h"
#include "ash/constants/notifier_catalogs.h"
#include "ash/game_dashboard/game_dashboard_constants.h"
#include "ash/game_dashboard/game_dashboard_context.h"
#include "ash/game_dashboard/game_dashboard_main_menu_view.h"
#include "ash/game_dashboard/game_dashboard_metrics.h"
#include "ash/game_dashboard/game_dashboard_utils.h"
#include "ash/public/cpp/app_types_util.h"
#include "ash/public/cpp/system/toast_data.h"
#include "ash/public/cpp/window_properties.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/system/toast/toast_manager_impl.h"
#include "ash/wm/overview/overview_controller.h"
#include "ash/wm/window_properties.h"
#include "ash/wm/window_state.h"
#include "base/functional/bind.h"
#include "chromeos/ui/base/window_properties.h"
#include "components/prefs/pref_registry_simple.h"
#include "extensions/common/constants.h"
#include "ui/aura/window.h"
#include "ui/aura/window_tracker.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/compositor/property_change_reason.h"
#include "ui/display/screen.h"
#include "ui/display/tablet_state.h"
#include "ui/wm/core/window_util.h"
#include "ui/wm/public/activation_client.h"

namespace ash {

namespace {
// The singleton instance owned by `Shell`.
GameDashboardController* g_instance = nullptr;

// List of known app IDs that are games.
static const std::array<std::string, 7> kGameAppIdAllowList{
    extension_misc::kGeForceNowAppId,   "iicceeckdelepgbcpojbgahbhnklpane",
    "ojjlibnpojmhhabohpkclejfdblglkpj", "hhkmajjdndhdnkbmomodobajdjngeejb",
    "gihmggjjlnjaldngedmnegjmhccccahg", "lbefcdhjbnilmnokeflglbaiaebadckd",
    "bifaabbnnccaenolhjngemgmegdjflkg"};

// List of additional game PWA app IDs that are being tested.
// TODO(b/343400145): Move these PWA app IDs into `kGameAppIdAllowList` once
// they have been fully evaluated and the `game-dashboard-game-pwas` flag is
// removed.
static const std::array<std::string, 15> kPWAGameAppIdAllowList{
    extension_misc::kAmazonLunaAppIdCA,   extension_misc::kAmazonLunaAppIdDE,
    extension_misc::kAmazonLunaAppIdES,   extension_misc::kAmazonLunaAppIdFR,
    extension_misc::kAmazonLunaAppIdIT,   extension_misc::kAmazonLunaAppIdNL,
    extension_misc::kAmazonLunaAppIdPL,   extension_misc::kAmazonLunaAppIdUK,
    extension_misc::kAmazonLunaAppIdUS,   extension_misc::kBoosteroidAppId,
    extension_misc::kCoolMathGamesAppId,  extension_misc::kNowGGAppIdUK,
    extension_misc::kNowGGAppIdUS,        extension_misc::kPokiAppId,
    extension_misc::kXboxCloudGamingAppId};

// Checks whether the given `app_id` is allow listed to show the Game
// Dashboard button.
bool IsAppIdAllowListed(const std::string& app_id) {
  return base::Contains(kGameAppIdAllowList, app_id) ||
         (features::IsGameDashboardGamePWAsEnabled() &&
          base::Contains(kPWAGameAppIdAllowList, app_id));
}
}  // namespace

// static
GameDashboardController* GameDashboardController::Get() {
  return g_instance;
}

// static
bool GameDashboardController::IsGameWindow(aura::Window* window) {
  DCHECK(window);
  return window->GetProperty(chromeos::kIsGameKey);
}

// static
bool GameDashboardController::ReadyForAccelerator(aura::Window* window) {
  return game_dashboard_utils::ShouldEnableFeatures() && IsGameWindow(window) &&
         game_dashboard_utils::ShouldEnableGameDashboardButton(window);
}

// static
void GameDashboardController::RegisterProfilePrefs(
    PrefRegistrySimple* registry) {
  registry->RegisterBooleanPref(prefs::kGameDashboardShowWelcomeDialog, true);
  registry->RegisterBooleanPref(prefs::kGameDashboardShowToolbar, false);
}

GameDashboardController::GameDashboardController(
    std::unique_ptr<GameDashboardDelegate> delegate)
    : delegate_(std::move(delegate)) {
  DCHECK_EQ(g_instance, nullptr);
  g_instance = this;
  CHECK(aura::Env::HasInstance());
  env_observation_.Observe(aura::Env::GetInstance());
  CaptureModeController::Get()->AddObserver(this);
  Shell::Get()->overview_controller()->AddObserver(this);
  Shell::Get()->activation_client()->AddObserver(this);
}

GameDashboardController::~GameDashboardController() {
  CHECK_EQ(g_instance, this);
  g_instance = nullptr;
  Shell::Get()->activation_client()->RemoveObserver(this);
  Shell::Get()->overview_controller()->RemoveObserver(this);
  CaptureModeController::Get()->RemoveObserver(this);
}

std::string GameDashboardController::GetArcAppName(
    const std::string& app_id) const {
  return delegate_->GetArcAppName(app_id);
}

GameDashboardContext* GameDashboardController::GetGameDashboardContext(
    aura::Window* window) const {
  auto it = game_window_contexts_.find(window);
  return it != game_window_contexts_.end() ? it->second.get() : nullptr;
}

void GameDashboardController::MaybeStackAboveWidget(aura::Window* window,
                                                    views::Widget* widget) {
  DCHECK(widget);
  DCHECK(window);

  if (auto* context = GetGameDashboardContext(window)) {
    context->MaybeStackAboveWidget(widget);
  }
}

void GameDashboardController::StartCaptureSession(
    GameDashboardContext* game_context) {
  CHECK(!active_recording_context_);
  auto* game_window = game_context->game_window();
  CHECK(game_window_contexts_.contains(game_window));
  auto* capture_mode_controller = CaptureModeController::Get();
  CHECK(capture_mode_controller->can_start_new_recording());

  active_recording_context_ = game_context;
  capture_mode_controller->StartForGameDashboard(game_window);
}

void GameDashboardController::ShowResizeToggleMenu(aura::Window* window) {
  delegate_->ShowResizeToggleMenu(window);
}

ukm::SourceId GameDashboardController::GetUkmSourceId(
    const std::string& app_id) const {
  return delegate_->GetUkmSourceId(app_id);
}

void GameDashboardController::OnWindowInitialized(aura::Window* new_window) {
  if (const auto* top_level_window = new_window->GetToplevelWindow();
      !top_level_window ||
      top_level_window->GetType() != aura::client::WINDOW_TYPE_NORMAL) {
    // Ignore non-NORMAL window types.
    return;
  }
  GetWindowGameState(new_window);
}

void GameDashboardController::OnWindowPropertyChanged(aura::Window* window,
                                                      const void* key,
                                                      intptr_t old) {
  if (key == kAppIDKey) {
    GetWindowGameState(window);
  } else if (key == kArcGameControlsFlagsKey) {
    RefreshForGameControlsFlags(window);
  } else if (key == kWindowStateKey) {
    MaybeCreateGameDashboardContext(window);
  }
}

void GameDashboardController::OnWindowParentChanged(aura::Window* window,
                                                    aura::Window* parent) {
  if (parent) {
    // When this controller determines that the given `window` is a game, the
    // `window` may not be parented. The controller will not create a
    // `GameDashboardContext`. When the window is reparented to a
    // valid parent, `OnWindowParentChanged` will be called and create a
    // `GameDashboardContext` for it.
    MaybeCreateGameDashboardContext(window);
  }
}

void GameDashboardController::OnWindowBoundsChanged(
    aura::Window* window,
    const gfx::Rect& old_bounds,
    const gfx::Rect& new_bounds,
    ui::PropertyChangeReason reason) {
  if (auto* context = GetGameDashboardContext(window)) {
    context->OnWindowBoundsChanged(reason ==
                                   ui::PropertyChangeReason::FROM_ANIMATION);
  }
}

void GameDashboardController::OnWindowDestroying(aura::Window* window) {
  window_observations_.RemoveObservation(window);
  game_window_contexts_.erase(window);
}

void GameDashboardController::OnWindowTransformed(
    aura::Window* window,
    ui::PropertyChangeReason reason) {
  if (auto* context = GetGameDashboardContext(window);
      context && game_dashboard_utils::ShouldEnableFeatures()) {
    // Enable the features if the window is not minimized or undergoing an
    // animation. Otherwise, disable them.
    const bool enable = (reason == ui::PropertyChangeReason::FROM_ANIMATION) &&
                        !(WindowState::Get(window)->IsMinimized());
    context->EnableFeatures(enable,
                            GameDashboardMainMenuToggleMethod::kAnimation);
  }
}

void GameDashboardController::OnRecordingStarted(aura::Window* current_root) {
  // Update any needed game dashboard UIs if and only if this recording started
  // from a request by a game dashboard entry point.
  for (auto const& [game_window, context] : game_window_contexts_) {
    context->OnRecordingStarted(context.get() == active_recording_context_);
  }
}

void GameDashboardController::OnRecordingEnded() {
  active_recording_context_ = nullptr;
  for (auto const& [game_window, context] : game_window_contexts_) {
    context->OnRecordingEnded();
  }
}

void GameDashboardController::OnVideoFileFinalized(
    bool user_deleted_video_file,
    const gfx::ImageSkia& thumbnail) {
  for (auto const& [game_window, context] : game_window_contexts_) {
    context->OnVideoFileFinalized();
  }
}

void GameDashboardController::OnRecordedWindowChangingRoot(
    aura::Window* new_root) {
  // TODO(phshah): Update any game dashboard UIs that need to change as a result
  // of the recorded window moving to a different display if and only if this
  // recording started from a request by a game dashboard entry point. If
  // nothing needs to change, leave empty.
}

void GameDashboardController::OnRecordingStartAborted() {
  OnRecordingEnded();
}

void GameDashboardController::OnDisplayTabletStateChanged(
    display::TabletState state) {
  switch (state) {
    case display::TabletState::kInClamshellMode:
      // Cancel the tablet toast if it is still shown.
      Shell::Get()->toast_manager()->Cancel(game_dashboard::kTabletToastId);
      MaybeEnableFeatures(/*enable=*/true,
                          GameDashboardMainMenuToggleMethod::kTabletMode);
      break;
    case display::TabletState::kEnteringTabletMode: {
      const int toast_text_id =
          active_recording_context_
              ? IDS_ASH_GAME_DASHBOARD_TABLET_STOP_RECORDING_TOAST
              : IDS_ASH_GAME_DASHBOARD_TABLET_TOAST;
      if (active_recording_context_) {
        auto* capture_mode_controller = CaptureModeController::Get();
        CHECK(capture_mode_controller->is_recording_in_progress());
        capture_mode_controller->EndVideoRecording(
            EndRecordingReason::kGameDashboardTabletMode);
      }
      MaybeEnableFeatures(/*enable=*/false,
                          GameDashboardMainMenuToggleMethod::kTabletMode);
      // Show the toast to notify users when there is any game window open.
      if (!game_window_contexts_.empty()) {
        Shell::Get()->toast_manager()->Show(
            ToastData(game_dashboard::kTabletToastId,
                      ToastCatalogName::kGameDashboardEnterTablet,
                      l10n_util::GetStringUTF16(toast_text_id)));
      }
      break;
    }
    case display::TabletState::kInTabletMode:
    case display::TabletState::kExitingTabletMode:
      break;
  }
}

void GameDashboardController::OnOverviewModeWillStart() {
  // In overview mode, hide the Game Dashboard button, and if open, close the
  // main menu.
  MaybeEnableFeatures(/*enable=*/false,
                      GameDashboardMainMenuToggleMethod::kOverview);
}

void GameDashboardController::OnOverviewModeEnded() {
  // Make the Game Dashboard button visible.
  MaybeEnableFeatures(/*enable=*/true,
                      GameDashboardMainMenuToggleMethod::kOverview);
}

void GameDashboardController::OnWindowActivated(
    wm::ActivationChangeObserver::ActivationReason reason,
    aura::Window* gained_active,
    aura::Window* lost_active) {
  GameDashboardContext* lost_active_context =
      GetGameDashboardContext(wm::GetTransientRoot(lost_active));
  GameDashboardContext* gained_active_context =
      GetGameDashboardContext(wm::GetTransientRoot(gained_active));
  if (lost_active_context == gained_active_context) {
    // Ignore if the activation is moving within the same game window.
    return;
  }

  // If `lost_active_context` and `gained_active_context` both exist, the
  // activated widget is moving between Game Dashboard windows. If only
  // `gained_active_context` exists, activation is moving from a non-game window
  // to a Game Dashboard window. If only `lost_active_context` exists,
  // activation is moving from a Game Dashboard window into a non-game window.
  if (gained_active_context) {
    gained_active_context->MaybeAddPreTargetHandler();
  }
  if (lost_active_context) {
    lost_active_context->MaybeRemovePreTargetHandler();
  }
}

void GameDashboardController::MaybeCreateGameDashboardContext(
    aura::Window* window) {
  DCHECK(window);
  // Do not create a GameDashboardContext if the window is not a game, is not
  // parented, doesn't have a WindowState, or is being destroyed.
  if (!IsGameWindow(window) || !window->parent() || !WindowState::Get(window) ||
      window->is_destroying()) {
    return;
  }
  auto& context = game_window_contexts_[window];
  if (!context) {
    context = std::make_unique<GameDashboardContext>(window);
    context->Initialize();
    RefreshForGameControlsFlags(window);
    delegate_->RecordGameWindowOpenedEvent(window);
  }
}

void GameDashboardController::GetWindowGameState(aura::Window* window) {
  if (const auto* app_id = window->GetProperty(kAppIDKey); !app_id) {
    RefreshWindowTracking(window, WindowGameState::kNotYetKnown);
  } else if (IsAppIdAllowListed(*app_id)) {
    RefreshWindowTracking(window, WindowGameState::kGame);
  } else if (IsArcWindow(window)) {
    // For ARC apps, the "app_id" is equivalent to its package name.
    delegate_->GetIsGame(
        *app_id, base::BindOnce(
                     &GameDashboardController::OnArcWindowIsGame,
                     weak_ptr_factory_.GetWeakPtr(),
                     std::make_unique<aura::WindowTracker>(
                         std::vector<raw_ptr<aura::Window, VectorExperimental>>(
                             {window}))));
  } else {
    RefreshWindowTracking(window, WindowGameState::kNotGame);
  }
}

void GameDashboardController::OnArcWindowIsGame(
    std::unique_ptr<aura::WindowTracker> window_tracker,
    bool is_game) {
  if (const auto windows = window_tracker->windows(); !windows.empty()) {
    RefreshWindowTracking(windows[0], is_game ? WindowGameState::kGame
                                              : WindowGameState::kNotGame);
  }
}

void GameDashboardController::RefreshWindowTracking(aura::Window* window,
                                                    WindowGameState state) {
  DCHECK(window);
  const bool is_observing = window_observations_.IsObservingSource(window);
  const bool should_observe = state != WindowGameState::kNotGame;

  if (state != WindowGameState::kNotYetKnown) {
    const bool is_game = state == WindowGameState::kGame;
    const bool prev_is_game_property =
        window->GetProperty(chromeos::kIsGameKey);
    window->SetProperty(chromeos::kIsGameKey, is_game);
    if (is_game) {
      MaybeCreateGameDashboardContext(window);
    } else if (prev_is_game_property) {
      // The window was a game, but NOT anymore. This can happen if the user
      // disables ARC during the existing session.
      game_window_contexts_.erase(window);
    }
  }

  if (is_observing == should_observe) {
    return;
  }

  if (should_observe) {
    window_observations_.AddObservation(window);
  } else {
    window_observations_.RemoveObservation(window);
  }
}

void GameDashboardController::RefreshForGameControlsFlags(
    aura::Window* window) {
  if (!IsArcWindow(window)) {
    return;
  }

  if (auto* context = GetGameDashboardContext(window)) {
    context->UpdateForGameControlsFlags();
  }
}

void GameDashboardController::MaybeEnableFeatures(
    bool enable,
    GameDashboardMainMenuToggleMethod main_menu_toggle_method) {
  const bool should_enable =
      enable && game_dashboard_utils::ShouldEnableFeatures();
  for (auto const& [_, context] : game_window_contexts_) {
    context->OnWindowBoundsChanged(
        main_menu_toggle_method ==
        GameDashboardMainMenuToggleMethod::kAnimation);
    context->EnableFeatures(should_enable, main_menu_toggle_method);
  }
}

}  // namespace ash