chromium/chrome/browser/ash/growth/campaigns_manager_session.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 "chrome/browser/ash/growth/campaigns_manager_session.h"

#include <optional>

#include "ash/constants/ash_features.h"
#include "ash/constants/ash_switches.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "base/check.h"
#include "base/check_is_test.h"
#include "base/logging.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "chrome/browser/apps/app_service/app_service_proxy_ash.h"
#include "chrome/browser/apps/app_service/app_service_proxy_factory.h"
#include "chrome/browser/ash/ownership/owner_settings_service_ash.h"
#include "chrome/browser/ash/ownership/owner_settings_service_ash_factory.h"
#include "chrome/browser/ash/system_web_apps/system_web_app_manager.h"
#include "chrome/browser/policy/profile_policy_connector.h"
#include "chrome/browser/profiles/profile_manager.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_list.h"
#include "chrome/browser/ui/views/frame/browser_view.h"
#include "chrome/browser/ui/web_applications/app_browser_controller.h"
#include "chromeos/ash/components/growth/action_performer.h"
#include "chromeos/ash/components/growth/campaigns_constants.h"
#include "chromeos/ash/components/growth/campaigns_logger.h"
#include "chromeos/ash/components/growth/campaigns_manager.h"
#include "chromeos/ash/components/growth/campaigns_model.h"
#include "components/account_id/account_id.h"
#include "components/app_constants/constants.h"
#include "components/services/app_service/public/cpp/app_registry_cache_wrapper.h"
#include "components/services/app_service/public/cpp/app_types.h"
#include "components/session_manager/core/session_manager.h"
#include "components/session_manager/session_manager_types.h"
#include "content/public/browser/web_contents.h"

namespace {

CampaignsManagerSession* g_instance = nullptr;

// The time to trigger delayed campaigns.
constexpr base::TimeDelta kTimeToTriggerDelayedCampaigns = base::Minutes(5);

bool IsWebBrowserAppId(const std::string& app_id) {
  return app_id == app_constants::kChromeAppId ||
         app_id == app_constants::kAshDebugBrowserAppId ||
         app_id == app_constants::kLacrosAppId;
}

bool IsAppVisible(const apps::InstanceUpdate& update) {
  return (update.State() & apps::InstanceState::kVisible);
}

bool IsAppActiveAndVisible(const apps::InstanceUpdate& update) {
  return (IsAppVisible(update) &&
          (update.State() & apps::InstanceState::kActive));
}

std::optional<growth::ActionType> GetActionTypeBySlot(growth::Slot slot) {
  if (slot == growth::Slot::kNotification) {
    return growth::ActionType::kShowNotification;
  }

  if (slot == growth::Slot::kNudge) {
    return growth::ActionType::kShowNudge;
  }

  return std::nullopt;
}

std::optional<std::string> GetAppGroupId() {
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  auto app_id = campaigns_manager->GetOpenedAppId();
  if (IsWebBrowserAppId(app_id)) {
    // For web browser, get group id by active url.
    auto active_url = campaigns_manager->GetActiveUrl();
    return growth::GetAppGroupId(active_url);
  }

  // For non web browser, get group id by app id.
  return growth::GetAppGroupId(app_id);
}

base::TimeDelta GetTimeToTriggerDelayedCampaigns() {
  base::CommandLine* command_line = base::CommandLine::ForCurrentProcess();
  if (command_line->HasSwitch(
          ash::switches::kGrowthCampaignsDelayedTriggerTimeInSecs)) {
    const auto& value = command_line->GetSwitchValueASCII(
        ash::switches::kGrowthCampaignsDelayedTriggerTimeInSecs);

    double seconds;
    CHECK(base::StringToDouble(value, &seconds));
    return base::Seconds(seconds);
  }

  return kTimeToTriggerDelayedCampaigns;
}

void MaybeTriggerSlot(growth::Slot slot) {
  const auto action_type = GetActionTypeBySlot(slot);
  if (!action_type) {
    CAMPAIGNS_LOG(ERROR) << "Invalid: no supported action type for slot "
                         << static_cast<int>(action_type.value());
    return;
  }

  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  auto* campaign = campaigns_manager->GetCampaignBySlot(slot);
  if (!campaign) {
    // No campaign matched.
    return;
  }

  auto campaign_id = growth::GetCampaignId(campaign);
  if (!campaign_id) {
    CAMPAIGNS_LOG(ERROR) << "Invalid: Missing campaign id.";
    return;
  }

  const auto* payload = growth::GetPayloadBySlot(campaign, slot);
  if (!payload) {
    // No payload for the targeted slot. It is valid for counterfactual control.
    return;
  }

  campaigns_manager->PerformAction(campaign_id.value(),
                                   growth::GetCampaignGroupId(campaign),
                                   action_type.value(), payload);
}

void MaybeTriggerCampaignsOnEvent(const std::string& event) {
  if (!ash::features::IsGrowthCampaignsTriggerByEventEnabled()) {
    return;
  }

  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  growth::Trigger trigger(growth::TriggerType::kEvent);
  trigger.event = event;
  campaigns_manager->SetTrigger(std::move(trigger));

  MaybeTriggerSlot(growth::Slot::kNudge);
  MaybeTriggerSlot(growth::Slot::kNotification);
}

void MaybeTriggerCampaignsWhenAppOpened() {
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  auto app_group_id = GetAppGroupId();

  // If `app_group_id` is defined, record the `event` and trigger campaigns
  // based on the trigger `event`. An `app_group_id` is used to configurate how
  // often, i.e. the interval, to show the nudges.
  if (app_group_id) {
    campaigns_manager->RecordEventForTargeting(growth::CampaignEvent::kEvent,
                                               app_group_id.value());
    MaybeTriggerCampaignsOnEvent(app_group_id.value());
  }

  if (!ash::features::IsGrowthCampaignsTriggerByAppOpenEnabled()) {
    return;
  }

  growth::Trigger trigger(growth::TriggerType::kAppOpened);
  campaigns_manager->SetTrigger(std::move(trigger));

  MaybeTriggerSlot(growth::Slot::kNudge);
  MaybeTriggerSlot(growth::Slot::kNotification);
}

void MaybeTriggerCampaignsWhenCampaignsLoaded() {
  if (!ash::features::IsGrowthCampaignsTriggerAtLoadComplete()) {
    return;
  }

  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  growth::Trigger trigger(growth::TriggerType::kCampaignsLoaded);
  campaigns_manager->SetTrigger(std::move(trigger));

  MaybeTriggerSlot(growth::Slot::kNudge);
  MaybeTriggerSlot(growth::Slot::kNotification);
}

void MaybeTriggerDelayedCampaigns() {
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  growth::Trigger trigger(growth::TriggerType::kDelayedOneShotTimer);
  campaigns_manager->SetTrigger(std::move(trigger));

  MaybeTriggerSlot(growth::Slot::kNudge);
  MaybeTriggerSlot(growth::Slot::kNotification);
}

// The app_id is optional and only required if the browser type is app.
content::WebContents* FindActiveWebContent(
    const Profile* profile,
    Browser::Type browser_type,
    const webapps::AppId& app_id = std::string()) {
  for (Browser* browser : BrowserList::GetInstance()->OrderedByActivation()) {
    if (browser->IsAttemptingToCloseBrowser() || browser->IsBrowserClosing()) {
      continue;
    }
    if (browser->type() != browser_type) {
      continue;
    }
    if (browser->profile() != profile) {
      continue;
    }
    // For web app type, it must match the app_id.
    if (browser_type == Browser::TYPE_APP &&
        !web_app::AppBrowserController::IsForWebApp(browser, app_id)) {
      continue;
    }

    const auto* tab_strip_model = browser->tab_strip_model();
    if (!tab_strip_model) {
      CAMPAIGNS_LOG(ERROR) << "No tab_strip_model.";
      continue;
    }

    auto* active_web_contents = tab_strip_model->GetActiveWebContents();
    if (!active_web_contents) {
      CAMPAIGNS_LOG(ERROR) << "No active web contents.";
      continue;
    }

    return active_web_contents;
  }
  return nullptr;
}

const GURL FindActiveWebAppUrl(Profile* profile, const webapps::AppId& app_id) {
  auto* active_web_contents =
      FindActiveWebContent(profile, Browser::TYPE_APP, app_id);
  if (!active_web_contents) {
    return GURL::EmptyGURL();
  }
  return active_web_contents->GetURL();
}

content::WebContents* FindActiveTabWebContent(Profile* profile) {
  return FindActiveWebContent(profile, Browser::TYPE_NORMAL);
}

std::optional<apps::AppType> GetAppType(const std::string& app_id) {
  auto account_id =
      ash::Shell::Get()->session_controller()->GetActiveAccountId();
  apps::AppRegistryCache* cache =
      apps::AppRegistryCacheWrapper::Get().GetAppRegistryCache(account_id);
  if (!cache) {
    return std::nullopt;
  }
  return cache->GetAppType(app_id);
}

// Returns current active browser. If there's no active browser, return nullptr.
Browser* GetActiveBrowser() {
  for (Browser* browser : BrowserList::GetInstance()->OrderedByActivation()) {
    if (browser->profile()->IsOffTheRecord() ||
        !browser->window()->IsVisible()) {
      continue;
    }

    if (browser->window()->IsActive()) {
      return browser;
    }
  }
  return nullptr;
}

bool IsSystemWebApp(Profile* profile, const webapps::AppId& app_id) {
  ash::SystemWebAppManager* swa_manager =
      ash::SystemWebAppManager::Get(profile);
  if (!swa_manager) {
    CHECK_IS_TEST();
    return false;
  }
  return swa_manager->IsSystemWebApp(app_id);
}

bool HasValidPwaBrowserForAppId(const std::string& app_id) {
  auto* browser = GetActiveBrowser();

  if (!browser) {
    CAMPAIGNS_LOG(ERROR) << "No browser window";
    return false;
  }

  if (browser->type() != Browser::TYPE_APP) {
    CAMPAIGNS_LOG(ERROR) << "Not pwa browser type";
    return false;
  }

  if (!web_app::AppBrowserController::IsForWebApp(browser, app_id)) {
    CAMPAIGNS_LOG(ERROR) << "Browser belongs to a different app";
    return false;
  }

  return true;
}

void SetCampaignManagerPrefService(Profile* profile) {
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  if (!profile) {
    campaigns_manager->SetPrefs(nullptr);
    return;
  }
  campaigns_manager->SetPrefs(profile->GetPrefs());
}

}  // namespace

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

CampaignsManagerSession::CampaignsManagerSession() {
  CHECK_EQ(g_instance, nullptr);
  g_instance = this;

  // SessionManager may be unset in unit tests.
  auto* session_manager = session_manager::SessionManager::Get();
  if (session_manager) {
    session_manager_observation_.Observe(session_manager);
    OnSessionStateChanged();
  }
}

CampaignsManagerSession::~CampaignsManagerSession() {
  CHECK_EQ(g_instance, this);
  g_instance = nullptr;
  SetCampaignManagerPrefService(nullptr);
}

void CampaignsManagerSession::OnSessionStateChanged() {
  // Stop the timer to avoid triggering campaigns if the session is not active.
  if (delayed_timer_.IsRunning()) {
    delayed_timer_.Stop();
  }

  if (session_manager::SessionManager::Get()->session_state() ==
      session_manager::SessionState::LOCKED) {
    if (scoped_observation_.IsObserving()) {
      scoped_observation_.Reset();
    }
    return;
  }

  if (session_manager::SessionManager::Get()->session_state() !=
      session_manager::SessionState::ACTIVE) {
    // Loads campaigns at session active only.
    return;
  }

  if (!IsEligible()) {
    return;
  }

  SetCampaignManagerPrefService(GetProfile());

  ash::OwnerSettingsServiceAsh* service =
      ash::OwnerSettingsServiceAshFactory::GetForBrowserContext(GetProfile());
  if (service) {
    service->IsOwnerAsync(
        base::BindOnce(&CampaignsManagerSession::OnOwnershipDetermined,
                       weak_ptr_factory_.GetWeakPtr()));
  } else {
    // TODO: b/338085893 - Add metric to track the case that settings service
    // is not available at this point.
    CAMPAIGNS_LOG(ERROR)
        << "Owner settings service unavailable for the profile.";
  }
}

void CampaignsManagerSession::OnInstanceUpdate(
    const apps::InstanceUpdate& update) {
  // No state changes. Ignore the update.
  if (!update.StateChanged()) {
    return;
  }

  if (update.IsDestruction()) {
    HandleAppInstanceDestruction(update);
    return;
  }

  auto app_id = update.AppId();
  auto app_type = GetAppType(app_id);
  if (!app_type) {
    CAMPAIGNS_LOG(ERROR) << "Invalid app type for " << app_id;
    return;
  }

  switch (app_type.value()) {
    case apps::AppType::kUnknown:
      // e.g Ash debug browser.
      break;
    case apps::AppType::kStandaloneBrowser:
    case apps::AppType::kChromeApp:
      HandleWebBrowserInstanceUpdate(update);
      break;
    case apps::AppType::kWeb:
      if (IsSystemWebApp(GetProfile(), app_id)) {
        // Active browser is not available for the SWA case, so we handle
        // it as a regular app open.
        HandleAppInstanceUpdate(update);
        break;
      }
      HandlePwaInstanceUpdate(update);
      break;
    case apps::AppType::kArc:
      HandleArcInstanceUpdate(update);
      break;
    default:
      HandleAppInstanceUpdate(update);
      break;
  }
}

void CampaignsManagerSession::OnInstanceRegistryWillBeDestroyed(
    apps::InstanceRegistry* cache) {
  if (scoped_observation_.GetSource() == cache) {
    scoped_observation_.Reset();
  }
}

void CampaignsManagerSession::PrimaryPageChanged(
    const content::WebContents* web_contents) {
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  auto app_id = campaigns_manager->GetOpenedAppId();
  if (!IsWebBrowserAppId(app_id)) {
    return;
  }

  // Skip triggering campaign if this Primary Page Changed event happens on an
  // inactive tab (i.e. `web_contents` is not the active tab `web_contents`).
  // For example:
  // 1. Load `url1` in "tab 1".
  // 2. While `url1` is loading, open a "tab 2" and load the same URL `url1`
  // 3. The nudge triggered twice - one by the inactive "tab 1" and one by the
  // active "tab 2".
  auto* active_tab_web_contents = FindActiveTabWebContent(GetProfile());
  if (active_tab_web_contents != web_contents) {
    return;
  }

  auto url = active_tab_web_contents->GetURL();
  campaigns_manager->SetActiveUrl(url);
  MaybeTriggerCampaignsWhenAppOpened();
}

void CampaignsManagerSession::SetProfileForTesting(Profile* profile) {
  profile_for_testing_ = profile;
}

Profile* CampaignsManagerSession::GetProfile() {
  if (profile_for_testing_) {
    return profile_for_testing_;
  }

  return ProfileManager::GetActiveUserProfile();
}

bool CampaignsManagerSession::IsEligible() {
  Profile* profile = GetProfile();
  CHECK(profile);
  // TODO(b/320789239): Enable for unicorn users.
  if (profile->GetProfilePolicyConnector()->IsManaged()) {
    // Only enabled for consumer session for now.
    // Demo Mode session is handled separately at `DemoSession`.
    return false;
  }

  // TODO: b/341328441 - Enable Growth Framework on guest mode.
  if (profile->IsGuestSession()) {
    return false;
  }

  return true;
}

void CampaignsManagerSession::SetupWindowObserver() {
  // Tests might not go through LOCKED state and `scoped_observation_` might
  // be observing.
  if (scoped_observation_.IsObserving()) {
    return;
  }

  auto* profile = GetProfile();
  // Some test profiles will not have AppServiceProxy.
  if (!apps::AppServiceProxyFactory::IsAppServiceAvailableForProfile(profile)) {
    return;
  }
  auto* proxy = apps::AppServiceProxyFactory::GetForProfile(profile);
  CHECK(proxy);
  scoped_observation_.Observe(&proxy->InstanceRegistry());
}

void CampaignsManagerSession::OnOwnershipDetermined(bool is_user_owner) {
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  campaigns_manager->SetIsUserOwner(is_user_owner);

  campaigns_manager->LoadCampaigns(
      base::BindOnce(&CampaignsManagerSession::OnLoadCampaignsCompleted,
                     weak_ptr_factory_.GetWeakPtr()));
}

void CampaignsManagerSession::OnLoadCampaignsCompleted() {
  if (ash::features::IsGrowthCampaignsTriggerByAppOpenEnabled()) {
    SetupWindowObserver();
  }

  MaybeTriggerCampaignsWhenCampaignsLoaded();
  StartDelayedTimer();
}

void CampaignsManagerSession::StartDelayedTimer() {
  delayed_timer_.Start(FROM_HERE, GetTimeToTriggerDelayedCampaigns(),
                       base::BindOnce(&MaybeTriggerDelayedCampaigns));
}

void CampaignsManagerSession::CacheAppOpenContext(
    const apps::InstanceUpdate& update,
    const GURL& url) {
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  auto app_id = update.AppId();
  campaigns_manager->SetOpenedApp(app_id);
  campaigns_manager->SetActiveUrl(url);
  opened_window_ = update.Window();
}

void CampaignsManagerSession::ClearAppOpenContext() {
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  campaigns_manager->SetOpenedApp(std::string());
  campaigns_manager->SetActiveUrl(GURL::EmptyGURL());
  opened_window_ = nullptr;
}

void CampaignsManagerSession::HandleAppInstanceUpdate(
    const apps::InstanceUpdate& update) {
  if (!update.IsCreation()) {
    return;
  }
  CacheAppOpenContext(update, GURL::EmptyGURL());
  MaybeTriggerCampaignsWhenAppOpened();
}

void CampaignsManagerSession::HandleArcInstanceUpdate(
    const apps::InstanceUpdate& update) {
  // When an Arc app is opened, the instance state is `Started & Running &
  // Visible`. When an Arc app is closed, there are a sequence of
  // `Destroy`-`Started & Running`-`Destroy` state update as reported in
  // b/342489300. We skip the `Started & Running` state received after
  // closing the app.
  if (!update.IsCreation() || !IsAppVisible(update)) {
    return;
  }
  CacheAppOpenContext(update, GURL::EmptyGURL());
  MaybeTriggerCampaignsWhenAppOpened();
}

void CampaignsManagerSession::HandleWebBrowserInstanceUpdate(
    const apps::InstanceUpdate& update) {
  auto app_id = update.AppId();

  // Non web browser app such as text editor will be handled like default
  // app type.
  if (!IsWebBrowserAppId(app_id)) {
    CAMPAIGNS_LOG(ERROR) << "Not a web broswer: " << app_id;
    HandleAppInstanceUpdate(update);
    return;
  }

  if (!ash::features::IsGrowthCampaignsTriggerByBrowserEnabled()) {
    return;
  }

  // For browser app, the user can open a new tab or switch to an existing
  // tab before navigating to an url. So, it is not limited to a creation event
  // like other app types. In any case, the browser should be active and visible
  // when the user starts inserting the url.
  if (!IsAppActiveAndVisible(update)) {
    return;
  }

  // Caches the current app id and browser window and clears the url since it
  // isn't relevant to url navigation action. Caching url and triggering the
  // campaigns is deferred to PrimaryPageChanged when url navigation actually
  // happens.
  CacheAppOpenContext(update, GURL::EmptyGURL());
}

void CampaignsManagerSession::HandlePwaInstanceUpdate(
    const apps::InstanceUpdate& update) {
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  if (!update.IsCreation()) {
    return;
  }

  // When a user navigates to the url from web browser, an instance update with
  // PWA id is also sent as reported in b/342489221. This check will skip
  // instance update with chrome browser window and PWA app id, which is a
  // mismatch.
  auto app_id = update.AppId();
  if (!HasValidPwaBrowserForAppId(app_id)) {
    CAMPAIGNS_LOG(ERROR) << "Invalid web app browser";
    return;
  }

  CacheAppOpenContext(update, FindActiveWebAppUrl(GetProfile(), app_id));
  MaybeTriggerCampaignsWhenAppOpened();
}

void CampaignsManagerSession::HandleAppInstanceDestruction(
    const apps::InstanceUpdate& update) {
  // TODO: b/330409492 - Maybe trigger a campaign when app is about to be
  // destroyed.
  auto* campaigns_manager = growth::CampaignsManager::Get();
  CHECK(campaigns_manager);

  if (update.AppId() != campaigns_manager->GetOpenedAppId()) {
    return;
  }
  ClearAppOpenContext();
}