// Copyright 2022 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/apps/app_service/metrics/website_metrics.h"
#include <random>
#include "base/containers/contains.h"
#include "base/json/values_util.h"
#include "base/rand_util.h"
#include "chrome/browser/apps/app_service/metrics/app_platform_metrics_utils.h"
#include "chrome/browser/apps/browser_instance/web_contents_instance_id_utils.h"
#include "chrome/browser/history/history_service_factory.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser_finder.h"
#include "chrome/browser/ui/browser_list.h"
#include "chrome/browser/ui/browser_window.h"
#include "chrome/browser/web_applications/web_app_helpers.h"
#include "components/history/core/browser/history_types.h"
#include "components/webapps/browser/banners/installable_web_app_check_result.h"
#include "components/webapps/browser/banners/web_app_banner_data.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
#include "services/metrics/public/cpp/ukm_source_id.h"
#include "third_party/blink/public/common/manifest/manifest_util.h"
#include "third_party/blink/public/common/permissions_policy/permissions_policy.h"
#include "third_party/blink/public/mojom/installation/installation.mojom.h"
#include "third_party/blink/public/mojom/manifest/manifest.mojom.h"
#include "ui/aura/window.h"
#include "ui/wm/core/window_util.h"
#include "ui/wm/public/activation_client.h"
namespace {
const double mean = 1.0;
const double stddev = 0.025;
std::default_random_engine random_generator(base::RandDouble());
std::normal_distribution<double> distribution(mean, stddev);
// Generate random noise following normal_distribution.
double GetRandomNoise() {
return distribution(random_generator);
}
// Checks if a given browser is running a windowed app. It will return true for
// web apps, hosted apps, and packaged V1 apps.
bool IsAppBrowser(const Browser* browser) {
return (browser->is_type_app() || browser->is_type_app_popup()) &&
!web_app::GetAppIdFromApplicationName(browser->app_name()).empty();
}
aura::Window* GetWindowWithBrowser(Browser* browser) {
if (!browser) {
return nullptr;
}
BrowserWindow* browser_window = browser->window();
// In some test cases, browser window might be skipped.
return browser_window ? browser_window->GetNativeWindow() : nullptr;
}
aura::Window* GetWindowWithTabStripModel(TabStripModel* tab_strip_model) {
for (Browser* browser : *BrowserList::GetInstance()) {
if (browser->tab_strip_model() == tab_strip_model) {
return GetWindowWithBrowser(browser);
}
}
return nullptr;
}
wm::ActivationClient* GetActivationClient(aura::Window* window) {
if (!window) {
return nullptr;
}
aura::Window* root_window = window->GetRootWindow();
if (!root_window) {
return nullptr;
}
return wm::GetActivationClient(root_window);
}
bool IsSupportedUrl(const GURL& url) {
return !url.is_empty() && url.SchemeIsHTTPOrHTTPS();
}
} // namespace
namespace apps {
constexpr char kWebsiteUsageTime[] = "app_platform_metrics.website_usage_time";
constexpr char kRunningTimeKey[] = "time";
constexpr char kPromotableKey[] = "promotable";
WebsiteMetrics::ActiveTabWebContentsObserver::ActiveTabWebContentsObserver(
content::WebContents* contents,
WebsiteMetrics* owner)
: content::WebContentsObserver(contents), owner_(owner) {}
WebsiteMetrics::ActiveTabWebContentsObserver::~ActiveTabWebContentsObserver() =
default;
void WebsiteMetrics::ActiveTabWebContentsObserver::OnPrimaryPageChanged() {
owner_->OnWebContentsUpdated(web_contents());
if (app_banner_manager_observer_.IsObserving()) {
return;
}
auto* app_banner_manager =
webapps::AppBannerManager::FromWebContents(web_contents());
// In some test cases, AppBannerManager might be null.
if (app_banner_manager) {
app_banner_manager_observer_.Observe(app_banner_manager);
}
}
void WebsiteMetrics::ActiveTabWebContentsObserver::PrimaryPageChanged(
content::Page& page) {
OnPrimaryPageChanged();
}
void WebsiteMetrics::ActiveTabWebContentsObserver::WebContentsDestroyed() {
app_banner_manager_observer_.Reset();
}
void WebsiteMetrics::ActiveTabWebContentsObserver::
OnInstallableWebAppStatusUpdated(
webapps::InstallableWebAppCheckResult result,
const std::optional<webapps::WebAppBannerData>& data) {
owner_->OnInstallableWebAppStatusUpdated(web_contents(), result, data);
}
WebsiteMetrics::UrlInfo::UrlInfo(const base::Value& value) {
const base::Value::Dict* data_dict = value.GetIfDict();
if (!data_dict) {
return;
}
std::optional<base::TimeDelta> running_time_value =
base::ValueToTimeDelta(data_dict->Find(kRunningTimeKey));
if (!running_time_value.has_value()) {
return;
}
auto promotable_value = data_dict->FindBool(kPromotableKey);
if (!promotable_value.has_value()) {
return;
}
running_time_in_two_hours = running_time_value.value();
promotable = promotable_value.value();
}
base::Value::Dict WebsiteMetrics::UrlInfo::ConvertToDict() const {
base::Value::Dict usage_time_dict;
usage_time_dict.Set(kRunningTimeKey,
base::TimeDeltaToValue(running_time_in_two_hours));
usage_time_dict.Set(kPromotableKey, promotable);
return usage_time_dict;
}
WebsiteMetrics::WebsiteMetrics(Profile* profile, int user_type_by_device_type)
: profile_(profile),
browser_tab_strip_tracker_(this, nullptr),
user_type_by_device_type_(user_type_by_device_type) {
BrowserList::GetInstance()->AddObserver(this);
browser_tab_strip_tracker_.Init();
history::HistoryService* history_service =
HistoryServiceFactory::GetForProfileWithoutCreating(profile);
if (history_service) {
history_observation_.Observe(history_service);
}
}
WebsiteMetrics::~WebsiteMetrics() {
BrowserList::RemoveObserver(this);
// Also notify observers.
for (auto& observer : observers_) {
observer.OnWebsiteMetricsDestroyed();
}
}
void WebsiteMetrics::OnBrowserAdded(Browser* browser) {
if (IsAppBrowser(browser)) {
return;
}
auto* window = GetWindowWithBrowser(browser);
if (window) {
observed_windows_.AddObservation(window);
window_to_web_contents_[window] = nullptr;
}
}
void WebsiteMetrics::OnTabStripModelChanged(
TabStripModel* tab_strip_model,
const TabStripModelChange& change,
const TabStripSelectionChange& selection) {
DCHECK(tab_strip_model);
auto* window = GetWindowWithTabStripModel(tab_strip_model);
if (!window || !base::Contains(window_to_web_contents_, window)) {
// Skip the app browser window.
return;
}
switch (change.type()) {
case TabStripModelChange::kInserted:
OnTabStripModelChangeInsert(window, tab_strip_model, *change.GetInsert(),
selection);
break;
case TabStripModelChange::kRemoved:
OnTabStripModelChangeRemove(window, tab_strip_model, *change.GetRemove(),
selection);
break;
case TabStripModelChange::kReplaced:
OnTabStripModelChangeReplace(*change.GetReplace());
break;
case TabStripModelChange::kMoved:
case TabStripModelChange::kSelectionOnly:
break;
}
if (selection.active_tab_changed()) {
OnActiveTabChanged(window, selection.old_contents, selection.new_contents);
}
}
void WebsiteMetrics::OnWindowActivated(ActivationReason reason,
aura::Window* gained_active,
aura::Window* lost_active) {
SetWindowInActivated(lost_active);
SetWindowActivated(gained_active);
}
void WebsiteMetrics::OnHistoryDeletions(
history::HistoryService* history_service,
const history::DeletionInfo& deletion_info) {
if (deletion_info.is_from_expiration()) {
// This is an auto-expiration of history that happens after 90 days. Any
// data recorded here must be newer than this threshold, so ignore the
// expiration.
return;
}
// To simplify the implementation, remove all recorded urls no matter whatever
// `deletion_info`.
webcontents_to_ukm_key_.clear();
url_infos_.clear();
profile_->GetPrefs()->SetDict(kWebsiteUsageTime, base::Value::Dict());
}
void WebsiteMetrics::OnWindowDestroying(aura::Window* window) {
if (base::Contains(window_to_web_contents_, window)) {
window_to_web_contents_.erase(window);
}
observed_windows_.RemoveObservation(window);
}
void WebsiteMetrics::HistoryServiceBeingDeleted(
history::HistoryService* history_service) {
DCHECK(history_observation_.IsObservingSource(history_service));
history_observation_.Reset();
}
void WebsiteMetrics::OnFiveMinutes() {
// When the user logs in, there might be usage time for some websites saved in
// the user pref for the last login, and they haven't been recorded yet. So
// for the first five minutes, read the usage time saved in the user pref, and
// record the UKM, then save the new usage time to the user pref.
if (should_record_ukm_from_pref_) {
RecordUsageTimeFromPref();
should_record_ukm_from_pref_ = false;
}
SaveUsageTime();
}
void WebsiteMetrics::OnTwoHours() {
RecordUsageTime();
std::map<GURL, UrlInfo> url_infos;
for (const auto& it : webcontents_to_ukm_key_) {
if (!base::Contains(url_infos, it.second) && !it.second.is_empty() &&
it.second.SchemeIsHTTPOrHTTPS()) {
url_infos[it.second] = std::move(url_infos_[it.second]);
}
}
url_infos.swap(url_infos_);
}
void WebsiteMetrics::AddObserver(WebsiteMetrics::Observer* observer) {
observers_.AddObserver(observer);
}
void WebsiteMetrics::RemoveObserver(WebsiteMetrics::Observer* observer) {
observers_.RemoveObserver(observer);
}
void WebsiteMetrics::MaybeObserveWindowActivationClient(aura::Window* window) {
auto* activation_client = GetActivationClient(window);
if (!activation_client) {
return;
}
activation_client_to_windows_[activation_client].insert(window);
if (!activation_client_observations_.IsObservingSource(activation_client)) {
activation_client_observations_.AddObservation(activation_client);
}
}
void WebsiteMetrics::MaybeRemoveObserveWindowActivationClient(
aura::Window* window) {
auto* activation_client = GetActivationClient(window);
if (!activation_client) {
return;
}
activation_client_to_windows_[activation_client].erase(window);
// If there are other windows share the `activation_client`, we can't remove
// the activation client observation. E.g. for browser windows in Ash, there
// is only 1 root window, and 1 activation_client. Only when all browser
// windows will be closed, we can remove the observation.
if (!activation_client_to_windows_[activation_client].empty()) {
return;
}
activation_client_to_windows_.erase(activation_client);
if (activation_client_observations_.IsObservingSource(activation_client)) {
activation_client_observations_.RemoveObservation(activation_client);
}
}
void WebsiteMetrics::OnTabStripModelChangeInsert(
aura::Window* window,
TabStripModel* tab_strip_model,
const TabStripModelChange::Insert& insert,
const TabStripSelectionChange& selection) {
if (insert.contents.size() == 0) {
return;
}
// First tab attached.
if (tab_strip_model->count() == static_cast<int>(insert.contents.size())) {
MaybeObserveWindowActivationClient(window);
}
for (const auto& inserted_tab : insert.contents) {
content::WebContents* contents = inserted_tab.contents;
// The tab is new.
if (!base::Contains(webcontents_to_observer_map_, contents)) {
webcontents_to_observer_map_[contents] =
std::make_unique<WebsiteMetrics::ActiveTabWebContentsObserver>(
contents, this);
}
}
}
void WebsiteMetrics::OnTabStripModelChangeRemove(
aura::Window* window,
TabStripModel* tab_strip_model,
const TabStripModelChange::Remove& remove,
const TabStripSelectionChange& selection) {
bool active_tab_removed = false;
const auto window_it = window_to_web_contents_.find(window);
for (const auto& removed_tab : remove.contents) {
::content::WebContents* const removed_contents = removed_tab.contents;
OnTabClosed(removed_contents);
if (window_it != window_to_web_contents_.end() &&
window_it->second == removed_contents) {
active_tab_removed = true;
}
}
// Last tab detached.
if (tab_strip_model->count() == 0) {
// The browser window will be closed, so remove the window and the web
// contents.
if (window_it != window_to_web_contents_.end()) {
// Only trigger `OnTabClosed` if it has not been already triggered.
if (!active_tab_removed && window_it->second) {
OnTabClosed(window_it->second);
}
window_to_web_contents_.erase(window_it);
}
MaybeRemoveObserveWindowActivationClient(window);
}
}
void WebsiteMetrics::OnTabStripModelChangeReplace(
const TabStripModelChange::Replace& replace) {
OnTabClosed(replace.old_contents);
}
void WebsiteMetrics::OnActiveTabChanged(aura::Window* window,
content::WebContents* old_contents,
content::WebContents* new_contents) {
if (old_contents) {
SetTabInActivated(old_contents);
// Clear `old_contents` from `window_to_web_contents_`.
auto it = window_to_web_contents_.find(window);
if (it != window_to_web_contents_.end()) {
it->second = nullptr;
}
}
if (new_contents) {
window_to_web_contents_[window] = new_contents;
// When the tab is drag to a new browser window, PrimaryPageChanged might
// not be called, so `webcontents_to_ukm_key_` doesn't include
// `new_contents`. So call PrimaryPageChanged to update web contents and add
// the website url.
if (!base::Contains(webcontents_to_ukm_key_, new_contents)) {
auto it = webcontents_to_observer_map_.find(new_contents);
if (it != webcontents_to_observer_map_.end()) {
it->second->OnPrimaryPageChanged();
auto* app_banner_manager =
webapps::AppBannerManager::FromWebContents(new_contents);
// In some test cases, AppBannerManager might be null.
if (app_banner_manager) {
it->second->OnInstallableWebAppStatusUpdated(
app_banner_manager->GetInstallableWebAppCheckResult(),
app_banner_manager->GetCurrentWebAppBannerData());
}
}
return;
}
if (wm::IsActiveWindow(window)) {
SetTabActivated(new_contents);
}
}
}
void WebsiteMetrics::OnTabClosed(content::WebContents* web_contents) {
SetTabInActivated(web_contents);
webcontents_to_ukm_key_.erase(web_contents);
webcontents_to_observer_map_.erase(web_contents);
// Also notify observers.
const GURL& url = web_contents->GetVisibleURL();
for (auto& observer : observers_) {
observer.OnUrlClosed(url, web_contents);
}
}
void WebsiteMetrics::OnWebContentsUpdated(content::WebContents* web_contents) {
// If there is an app for the url, we don't need to record the url, because
// the app metrics can record the usage time metrics. We need to ensure we
// notify observers of previous URL being closed if we happen to be tracking
// it.
if (GetInstanceAppIdForWebContents(web_contents).has_value()) {
if (const auto web_contents_it = webcontents_to_ukm_key_.find(web_contents);
web_contents_it != webcontents_to_ukm_key_.end()) {
for (auto& observer : observers_) {
observer.OnUrlClosed(web_contents_it->second, web_contents);
}
webcontents_to_ukm_key_.erase(web_contents);
}
return;
}
auto* const window =
GetWindowWithBrowser(chrome::FindBrowserWithTab(web_contents));
if (!window) {
return;
}
// When the primary page of `web_contents` is changed, call SetTabInActivated
// to calculate the usage time for the previous ukm key url.
SetTabInActivated(web_contents);
const GURL& url = web_contents->GetVisibleURL();
// User could have either opened the URL in a new `WebContents` or navigated
// from a different URL in a pre-existing `WebContents`. We check for both
// scenarios and notify observers accordingly.
const auto web_contents_it = webcontents_to_ukm_key_.find(web_contents);
if (web_contents_it == webcontents_to_ukm_key_.end() && IsSupportedUrl(url)) {
// URL opened in a new `WebContent`.
for (auto& observer : observers_) {
observer.OnUrlOpened(url, web_contents);
}
}
if (web_contents_it != webcontents_to_ukm_key_.end() &&
web_contents_it->second != url) {
// Content navigation in a pre-existing `WebContents`.
const GURL& previous_url = web_contents_it->second;
if (IsSupportedUrl(previous_url)) {
for (auto& observer : observers_) {
observer.OnUrlClosed(previous_url, web_contents);
}
}
if (IsSupportedUrl(url)) {
for (auto& observer : observers_) {
observer.OnUrlOpened(url, web_contents);
}
}
}
// When the primary page of `web_contents` is changed called by
// contents::WebContentsObserver::PrimaryPageChanged(), set the visible url as
// default value for the ukm key url.
webcontents_to_ukm_key_[web_contents] = url;
if (!IsSupportedUrl(url)) {
return;
}
auto it = window_to_web_contents_.find(window);
bool is_activated = wm::IsActiveWindow(window) &&
it != window_to_web_contents_.end() &&
it->second == web_contents;
AddUrlInfo(url, web_contents->GetPrimaryMainFrame()->GetPageUkmSourceId(),
base::TimeTicks::Now(), is_activated, /*promotable=*/false);
}
void WebsiteMetrics::OnInstallableWebAppStatusUpdated(
content::WebContents* web_contents,
webapps::InstallableWebAppCheckResult result,
const std::optional<webapps::WebAppBannerData>& data) {
auto it = webcontents_to_ukm_key_.find(web_contents);
if (it == webcontents_to_ukm_key_.end()) {
// If the `web_contents` has been removed or replaced, we don't need to set
// the url.
return;
}
// WebContents in app windows are filtered out in OnBrowserAdded. Installed
// web apps opened in tabs are filtered out too. So every WebContents here
// must be a website not installed.
if (result == webapps::InstallableWebAppCheckResult::kYes_Promotable) {
UpdateUrlInfo(it->second, /*promotable=*/true);
}
}
void WebsiteMetrics::AddUrlInfo(const GURL& url,
ukm::SourceId source_id,
const base::TimeTicks& start_time,
bool is_activated,
bool promotable) {
auto& url_info = url_infos_[url];
url_info.source_id = source_id;
url_info.start_time = start_time;
url_info.is_activated = is_activated;
url_info.promotable = promotable;
}
void WebsiteMetrics::UpdateUrlInfo(const GURL& url, bool promotable) {
auto it = url_infos_.find(url);
if (it != url_infos_.end()) {
it->second.promotable = promotable;
}
}
void WebsiteMetrics::SetWindowActivated(aura::Window* window) {
auto it = window_to_web_contents_.find(window);
if (it != window_to_web_contents_.end() && it->second) {
SetTabActivated(it->second);
}
}
void WebsiteMetrics::SetWindowInActivated(aura::Window* window) {
auto it = window_to_web_contents_.find(window);
if (it != window_to_web_contents_.end() && it->second) {
SetTabInActivated(it->second);
}
}
void WebsiteMetrics::SetTabActivated(content::WebContents* web_contents) {
auto web_contents_it = webcontents_to_ukm_key_.find(web_contents);
if (web_contents_it == webcontents_to_ukm_key_.end()) {
return;
}
auto url_it = url_infos_.find(web_contents_it->second);
if (url_it == url_infos_.end()) {
return;
}
url_it->second.start_time = base::TimeTicks::Now();
url_it->second.is_activated = true;
}
void WebsiteMetrics::SetTabInActivated(content::WebContents* web_contents) {
auto web_contents_it = webcontents_to_ukm_key_.find(web_contents);
if (web_contents_it == webcontents_to_ukm_key_.end()) {
return;
}
// Check whether `web_contents` is activated. If yes, calculate the running
// time based on the start time set when `web_contents` is activated.
auto it = url_infos_.find(web_contents_it->second);
if (it == url_infos_.end() || !it->second.is_activated) {
return;
}
const auto current_time = base::TimeTicks::Now();
DCHECK_GE(current_time, it->second.start_time);
it->second.running_time_in_five_minutes +=
current_time - it->second.start_time;
it->second.is_activated = false;
}
void WebsiteMetrics::SaveUsageTime() {
base::Value::Dict dict;
for (auto& it : url_infos_) {
if (it.second.is_activated) {
// Continued usage of active web content.
const auto current_time = base::TimeTicks::Now();
DCHECK_GE(current_time, it.second.start_time);
it.second.running_time_in_five_minutes +=
current_time - it.second.start_time;
it.second.start_time = current_time;
}
if (!it.second.running_time_in_five_minutes.is_zero()) {
// Notify observers before we normalize raw usage data.
for (auto& observer : observers_) {
observer.OnUrlUsage(it.first, it.second.running_time_in_five_minutes);
}
// Based on the privacy review result, randomly multiply a noise factor to
// the raw data collected in a 5 minutes slot.
it.second.running_time_in_two_hours +=
GetRandomNoise() * it.second.running_time_in_five_minutes;
it.second.running_time_in_five_minutes = base::TimeDelta();
}
// Save all urls running time in the past two hours to the user pref.
if (!it.second.running_time_in_two_hours.is_zero()) {
dict.Set(it.first.spec(), it.second.ConvertToDict());
}
}
profile_->GetPrefs()->SetDict(kWebsiteUsageTime, std::move(dict));
}
void WebsiteMetrics::RecordUsageTime() {
for (auto& it : url_infos_) {
if (!it.second.running_time_in_two_hours.is_zero()) {
EmitUkm(it.second.source_id,
it.second.running_time_in_two_hours.InMilliseconds(),
it.second.promotable,
/*is_from_last_login=*/false);
it.second.running_time_in_two_hours = base::TimeDelta();
}
}
// The app usage time AppKMs have been recorded, so clear the saved usage time
// in the user pref.
profile_->GetPrefs()->SetDict(kWebsiteUsageTime, base::Value::Dict());
}
void WebsiteMetrics::RecordUsageTimeFromPref() {
const base::Value::Dict& usage_time =
profile_->GetPrefs()->GetDict(kWebsiteUsageTime);
for (const auto [urlstr, url_info_value] : usage_time) {
if (urlstr.empty()) {
continue;
}
auto url = GURL(urlstr);
if (!url.SchemeIsHTTPOrHTTPS()) {
continue;
}
auto url_info = std::make_unique<UrlInfo>(url_info_value);
if (!url_info->running_time_in_two_hours.is_zero()) {
// For the URL records dump from the user pref, since the web_contents
// doesn't exist due to logout/login, we can't call GetPageUkmSourceId to
// get the source id with the web_contents. So call
// GetSourceIdForChromeOSWebsiteURL to generate the UKM source id to log
// saved URLs from the last login session.
auto source_id = ukm::UkmRecorder::GetSourceIdForChromeOSWebsiteURL(
base::PassKey<WebsiteMetrics>(), url);
EmitUkm(source_id, url_info->running_time_in_two_hours.InMilliseconds(),
url_info->promotable,
/*is_from_last_login=*/true);
}
}
}
void WebsiteMetrics::EmitUkm(ukm::SourceId source_id,
int64_t usage_time,
bool promotable,
bool is_from_last_login) {
if (source_id == ukm::kInvalidSourceId) {
DVLOG(1) << "WebsiteMetrics::EmitUkm source id is invalid.";
return;
}
ukm::builders::ChromeOS_WebsiteUsageTime builder(source_id);
builder.SetDuration(usage_time)
.SetIsFromLastLogin(is_from_last_login)
.SetPromotable(promotable)
.SetUserDeviceMatrix(user_type_by_device_type_)
.Record(ukm::UkmRecorder::Get());
}
} // namespace apps