chromium/chrome/browser/ui/ash/shelf/standalone_browser_extension_app_shelf_item_controller.cc

// Copyright 2021 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/ash/shelf/standalone_browser_extension_app_shelf_item_controller.h"

#include <algorithm>
#include <utility>
#include <vector>

#include "ash/public/cpp/window_properties.h"
#include "ash/shell.h"
#include "ash/wm/window_util.h"
#include "base/functional/bind.h"
#include "base/memory/raw_ptr.h"
#include "base/numerics/safe_conversions.h"
#include "chrome/browser/apps/app_service/app_service_proxy.h"
#include "chrome/browser/apps/app_service/app_service_proxy_factory.h"
#include "chrome/browser/apps/app_service/publishers/standalone_browser_extension_apps.h"
#include "chrome/browser/apps/app_service/publishers/standalone_browser_extension_apps_factory.h"
#include "chrome/browser/ash/crosapi/browser_util.h"
#include "chrome/browser/profiles/profile_manager.h"
#include "chrome/browser/ui/ash/shelf/app_window_base.h"
#include "chrome/browser/ui/ash/shelf/chrome_shelf_controller.h"
#include "chrome/browser/ui/ash/shelf/chrome_shelf_controller_util.h"
#include "chrome/browser/ui/ash/shelf/standalone_browser_extension_app_context_menu.h"
#include "components/services/app_service/public/cpp/app_types.h"
#include "components/services/app_service/public/cpp/instance_registry.h"
#include "ui/base/models/simple_menu_model.h"
#include "ui/views/widget/widget.h"

StandaloneBrowserExtensionAppShelfItemController::
    StandaloneBrowserExtensionAppShelfItemController(
        const ash::ShelfID& shelf_id)
    : ash::ShelfItemDelegate(shelf_id) {
  shelf_model_observation_.Observe(ash::ShelfModel::Get());

  auto* activation_client =
      wm::GetActivationClient(ash::Shell::Get()->GetPrimaryRootWindow());
  if (activation_client)
    activation_client_observation_.Observe(activation_client);

  // Lacros is mutually exclusive with multi-signin. As such, there can only be
  // a single ash profile active. We grab it from the profile manager.
  apps::AppServiceProxy* proxy = apps::AppServiceProxyFactory::GetForProfile(
      ProfileManager::GetPrimaryUserProfile());

  icon_loader_releaser_ = proxy->LoadIconWithIconEffects(
      shelf_id.app_id, apps::IconEffects::kNone, apps::IconType::kStandard,
      /*size_hint_in_dip=*/48,
      /*allow_placeholder_icon=*/false,
      base::BindOnce(
          &StandaloneBrowserExtensionAppShelfItemController::OnLoadIcon,
          weak_factory_.GetWeakPtr()));

  context_menu_ = std::make_unique<StandaloneBrowserExtensionAppContextMenu>(
      shelf_id.app_id,
      StandaloneBrowserExtensionAppContextMenu::Source::kShelf);
}

StandaloneBrowserExtensionAppShelfItemController::
    StandaloneBrowserExtensionAppShelfItemController(
        const ash::ShelfID& shelf_id,
        aura::Window* window)
    : StandaloneBrowserExtensionAppShelfItemController(shelf_id) {
  // We intentionally avoid going through StartTrackingInstance since no item
  // exists in the shelf yet.
  windows_.push_back(window);
  InitWindowStatus(window);
  window_observations_.AddObservation(window);
}

StandaloneBrowserExtensionAppShelfItemController::
    ~StandaloneBrowserExtensionAppShelfItemController() {}

void StandaloneBrowserExtensionAppShelfItemController::ItemSelected(
    std::unique_ptr<ui::Event> event,
    int64_t display_id,
    ash::ShelfLaunchSource source,
    ItemSelectedCallback callback,
    const ItemFilterPredicate& filter_predicate) {
  // The behavior of this function matches the behavior of other shelf apps.
  // This is not specific anywhere, so we record it here:
  // First we filter matching windows -- this trims windows on inactive desks.
  // If there's no matching windows, then we launch the app.
  // If there's 1, we focus or minimize it.
  // If there's more than 1, we create menu items and pass them back in
  // ItemSelectedCallback, to be shown by the shelf view as a context menu.
  //
  // We currently do not implement any functionality akin to
  // ActivateOrAdvanceToNextBrowser() as it's not clear how that logic can be
  // triggered.
  //
  // We intentionally do not special case logic for source !=
  // ash::LAUNCH_FROM_SHELF since that path is never triggered.
  DCHECK_EQ(source, ash::LAUNCH_FROM_SHELF);

  std::vector<raw_ptr<aura::Window, VectorExperimental>> filtered_windows;
  for (aura::Window* window : windows_) {
    if (filter_predicate.is_null() || filter_predicate.Run(window)) {
      filtered_windows.push_back(window);
    }
  }

  if (filtered_windows.size() == 0) {
    apps::AppServiceProxy* proxy = apps::AppServiceProxyFactory::GetForProfile(
        ProfileManager::GetPrimaryUserProfile());
    proxy->Launch(app_id(), event->flags(),
                  ShelfLaunchSourceToAppsLaunchSource(source),
                  /*window_info=*/nullptr);

    std::move(callback).Run(ash::SHELF_ACTION_NEW_WINDOW_CREATED, {});
    return;
  }

  if (filtered_windows.size() == 1) {
    views::Widget* widget =
        views::Widget::GetWidgetForNativeWindow(filtered_windows[0]);
    AppWindowBase app_window(shelf_id(), widget);
    ash::ShelfAction action =
        ChromeShelfController::instance()->ActivateWindowOrMinimizeIfActive(
            &app_window, /*can_minimize=*/true);
    std::move(callback).Run(action, {});
    return;
  }

  // Show a context menu. This is done by passing back items in callback
  // alongside SHELF_ACTION_NONE.
  context_menu_windows_ = filtered_windows;
  AppMenuItems items;
  for (aura::Window* window : filtered_windows) {
    // The command id is the index into the array of filtered windows.
    items.push_back({/*command_id=*/static_cast<int>(items.size()),
                     window->GetTitle(),
                     icon_ ? icon_.value() : gfx::ImageSkia()});
  }
  std::move(callback).Run(ash::SHELF_ACTION_NONE, std::move(items));
}

void StandaloneBrowserExtensionAppShelfItemController::GetContextMenu(
    int64_t display_id,
    GetContextMenuCallback callback) {
  context_menu_->GetMenuModel(std::move(callback));
}

void StandaloneBrowserExtensionAppShelfItemController::ExecuteCommand(
    bool from_context_menu,
    int64_t command_id,
    int32_t event_flags,
    int64_t display_id) {
  // The current API for showing existing windows in a context menu, and then
  // later receiving a callback here is intrinsically racy. There is no way to
  // encode all relevant information in |command_id|.
  // We do our best by recording the last context menu shown, and then tracking
  // the aura::Windows for destruction. This should almost always work. In rare
  // edge cases, this will cause the wrong window to be selected, but will not
  // cause undefined behavior.
  if (command_id >= 0 &&
      command_id < base::checked_cast<int32_t>(context_menu_windows_.size())) {
    views::Widget* widget = views::Widget::GetWidgetForNativeWindow(
        context_menu_windows_[command_id]);
    AppWindowBase app_window(shelf_id(), widget);
    ChromeShelfController::instance()->ActivateWindowOrMinimizeIfActive(
        &app_window, /*can_minimize=*/false);
    return;
  }

  // This must be from the context menu, or there has been a race condition and
  // context_menu_windows_ is smaller than it used to be. Either way forward the
  // command.
  context_menu_->ExecuteCommand(command_id, event_flags);
}

void StandaloneBrowserExtensionAppShelfItemController::Close() {
  // There can only be a single active ash profile when Lacros is running.
  apps::AppServiceProxy* proxy = apps::AppServiceProxyFactory::GetForProfile(
      ProfileManager::GetPrimaryUserProfile());
  proxy->StopApp(app_id());
}

void StandaloneBrowserExtensionAppShelfItemController::OnWindowActivated(
    wm::ActivationChangeObserver::ActivationReason reason,
    aura::Window* new_active,
    aura::Window* old_active) {
  SetWindowActivated(old_active, /*is_active=*/false);
  SetWindowActivated(new_active, /*is_active=*/true);
}

void StandaloneBrowserExtensionAppShelfItemController::ShelfItemAdded(
    int index) {
  ShelfItem item = ash::ShelfModel::Get()->items()[index];
  if (item.id != shelf_id())
    return;

  // When the item is first added, if the running state was not set, set it.
  if (item.type == ash::TYPE_UNDEFINED) {
    item.type = windows_.empty() ? ash::TYPE_PINNED_APP : ash::TYPE_APP;
  }
  item.status = windows_.empty() ? ash::STATUS_CLOSED : ash::STATUS_RUNNING;

  if (icon_) {
    item.image = icon_.value();
  }

  // TODO(crbug.com/40188614): title, policy_pinned_state

  ash::ShelfModel::Get()->Set(index, item);

  // Now that the initial state is set the shelf model observation is no longer
  // required.
  shelf_model_observation_.Reset();
}

void StandaloneBrowserExtensionAppShelfItemController::StartTrackingInstance(
    aura::Window* window) {
  windows_.push_back(window);
  InitWindowStatus(window);
  window_observations_.AddObservation(window);

  if (windows_.size() == 1) {
    int index = GetShelfIndex();

    ShelfItem item = ash::ShelfModel::Get()->items()[index];
    // No other entity should be setting the RUNNING status for apps, since no
    // other entity knows about the active instances.
    DCHECK_NE(item.status, ash::STATUS_RUNNING);
    item.status = ash::STATUS_RUNNING;
    ash::ShelfModel::Get()->Set(index, item);
  }
}

size_t StandaloneBrowserExtensionAppShelfItemController::WindowCount() {
  return windows_.size();
}

void StandaloneBrowserExtensionAppShelfItemController::OnLoadIcon(
    apps::IconValuePtr icon_value) {
  if (!icon_value || icon_value->icon_type != apps::IconType::kStandard)
    return;

  icon_ = icon_value->uncompressed;

  if (ItemAddedToShelf()) {
    int index = GetShelfIndex();
    ShelfItem item = ash::ShelfModel::Get()->items()[index];
    item.image = icon_value->uncompressed;
    ash::ShelfModel::Get()->Set(index, item);
    return;
  }
}

void StandaloneBrowserExtensionAppShelfItemController::
    OnWindowVisibilityChanged(aura::Window* window, bool visible) {
  auto it = window_status_.find(window);
  if (it == window_status_.end())
    return;

  if (visible) {
    it->second = static_cast<apps::InstanceState>(
        it->second | apps::InstanceState::kVisible);
  } else {
    it->second = static_cast<apps::InstanceState>(
        it->second & ~apps::InstanceState::kVisible);
  }
  UpdateInstance(window, it->second);
}

void StandaloneBrowserExtensionAppShelfItemController::OnWindowDestroying(
    aura::Window* window) {
  size_t erased = std::erase(windows_, window);
  DCHECK_EQ(erased, 1u);
  window_observations_.RemoveObservation(window);

  // If a window is destroyed, also remove it from the list used to show context
  // menu items.
  std::erase(context_menu_windows_, window);

  // Remove `window` from InstanceRegistry.
  UpdateInstance(window, apps::InstanceState::kDestroyed);
  window_status_.erase(window);

  // There are still instances left. Nothing to change.
  if (windows_.size() != 0)
    return;

  // If this was the last instance, and the item is not pinned, remove it.
  int index = GetShelfIndex();
  ShelfItem item = ash::ShelfModel::Get()->items()[index];
  if (!ash::IsPinnedShelfItemType(item.type)) {
    // Remove the item. That will also invoke destruction of |this|.
    ash::ShelfModel::Get()->RemoveItemAt(index);
    return;
  }

  // The item was pinned. Update its status.
  DCHECK_NE(item.status, ash::STATUS_CLOSED);
  item.status = ash::STATUS_CLOSED;
  ash::ShelfModel::Get()->Set(index, item);
}

void StandaloneBrowserExtensionAppShelfItemController::SetWindowActivated(
    aura::Window* window,
    bool is_active) {
  auto it = window_status_.find(window);
  if (it == window_status_.end())
    return;

  if (is_active) {
    it->second = static_cast<apps::InstanceState>(it->second |
                                                  apps::InstanceState::kActive);
  } else {
    it->second = static_cast<apps::InstanceState>(
        it->second & ~apps::InstanceState::kActive);
  }

  UpdateInstance(window, it->second);
}

void StandaloneBrowserExtensionAppShelfItemController::InitWindowStatus(
    aura::Window* window) {
  apps::InstanceState state = static_cast<apps::InstanceState>(
      apps::InstanceState::kStarted | apps::InstanceState::kRunning);
  if (window->IsVisible()) {
    state =
        static_cast<apps::InstanceState>(state | apps::InstanceState::kVisible);
  }
  if (window == ash::window_util::GetActiveWindow()) {
    state =
        static_cast<apps::InstanceState>(state | apps::InstanceState::kActive);
  }
  window_status_[window] = state;
  UpdateInstance(window, state);

  // Also update the window properties in exo.
  window->SetProperty(ash::kAppIDKey, shelf_id().app_id);
  window->SetProperty(ash::kShelfIDKey, shelf_id().Serialize());
}

void StandaloneBrowserExtensionAppShelfItemController::UpdateInstance(
    aura::Window* window,
    apps::InstanceState instance_state) {
  if (!crosapi::browser_util::IsLacrosChromeAppsEnabled() || !window)
    return;

  auto* app_service_proxy = apps::AppServiceProxyFactory::GetForProfile(
      ProfileManager::GetPrimaryUserProfile());
  DCHECK(app_service_proxy);

  auto& instance_registry = app_service_proxy->InstanceRegistry();

  // If the current state is not changed, we don't need to update.
  if (instance_registry.GetState(window) == instance_state)
    return;

  apps::InstanceParams params(shelf_id().app_id, window);
  params.state = std::make_pair(instance_state, base::Time::Now());
  instance_registry.CreateOrUpdateInstance(std::move(params));
}

int StandaloneBrowserExtensionAppShelfItemController::GetShelfIndex() {
  DCHECK(ItemAddedToShelf());
  int index = ash::ShelfModel::Get()->ItemIndexByID(shelf_id());

  // This instance exists if and only if there's an entry in the shelf.
  DCHECK_GE(index, 0);

  return index;
}

bool StandaloneBrowserExtensionAppShelfItemController::ItemAddedToShelf() {
  return !shelf_model_observation_.IsObserving();
}