// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_search_field/cr_search_field.js';
import '../shared_style.css.js';
import './activity_log_history_item.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {getTemplate} from './activity_log_history.html.js';
import type {ActivityGroup} from './activity_log_history_item.js';
/**
* The different states the activity log page can be in. Initial state is
* LOADING because we call the activity log API whenever a user navigates to
* the page. LOADED is the state where the API call has returned a successful
* result.
*/
export enum ActivityLogPageState {
LOADING = 'loading',
LOADED = 'loaded',
}
export interface ActivityLogDelegate {
getExtensionActivityLog(extensionId: string):
Promise<chrome.activityLogPrivate.ActivityResultSet>;
getFilteredExtensionActivityLog(extensionId: string, searchTerm: string):
Promise<chrome.activityLogPrivate.ActivityResultSet>;
deleteActivitiesById(activityIds: string[]): Promise<void>;
deleteActivitiesFromExtension(extensionId: string): Promise<void>;
downloadActivities(rawActivityData: string, fileName: string): void;
}
/**
* Content scripts activities do not have an API call, so we use the names of
* the scripts executed (specified as a stringified JSON array in the args
* field) as the keys for an activity group instead.
*/
function getActivityGroupKeysForContentScript(
activity: chrome.activityLogPrivate.ExtensionActivity): string[] {
assert(
activity.activityType ===
chrome.activityLogPrivate.ExtensionActivityType.CONTENT_SCRIPT);
if (!activity.args) {
return [];
}
const parsedArgs = JSON.parse(activity.args);
assert(Array.isArray(parsedArgs), 'Invalid API data.');
return parsedArgs;
}
/**
* Web request activities can have extra information which describes what the
* web request does in more detail than just the api_call. This information
* is in activity.other.webRequest and we use this to generate more activity
* group keys if possible.
*/
function getActivityGroupKeysForWebRequest(
activity: chrome.activityLogPrivate.ExtensionActivity): string[] {
assert(
activity.activityType ===
chrome.activityLogPrivate.ExtensionActivityType.WEB_REQUEST);
const apiCall = activity.apiCall;
const other = activity.other;
if (!other || !other.webRequest) {
return [apiCall!];
}
const webRequest = JSON.parse(other.webRequest);
assert(typeof webRequest === 'object', 'Invalid API data');
// If there is extra information in the other.webRequest object,
// construct a group for each consisting of the API call and each object key
// in other.webRequest. Otherwise we default to just the API call.
return Object.keys(webRequest).length === 0 ?
[apiCall!] :
Object.keys(webRequest).map(field => `${apiCall} (${field})`);
}
/**
* Group activity log entries by a key determined from each entry. Usually
* this would be the activity's API call though content script and web
* requests have different keys. We currently assume that every API call
* matches to one activity type.
*/
function groupActivities(
activityData: chrome.activityLogPrivate.ExtensionActivity[]):
Map<string, ActivityGroup> {
const groupedActivities = new Map();
for (const activity of activityData) {
const activityId = activity.activityId;
const activityType = activity.activityType;
const count = activity.count;
const pageUrl = activity.pageUrl;
const isContentScript = activityType ===
chrome.activityLogPrivate.ExtensionActivityType.CONTENT_SCRIPT;
const isWebRequest = activityType ===
chrome.activityLogPrivate.ExtensionActivityType.WEB_REQUEST;
let activityGroupKeys = [activity.apiCall];
if (isContentScript) {
activityGroupKeys = getActivityGroupKeysForContentScript(activity);
} else if (isWebRequest) {
activityGroupKeys = getActivityGroupKeysForWebRequest(activity);
}
for (const key of activityGroupKeys) {
if (!groupedActivities.has(key)) {
const activityGroup = {
activityIds: new Set([activityId]),
key,
count,
activityType,
countsByUrl: pageUrl ? new Map([[pageUrl, count]]) : new Map(),
expanded: false,
};
groupedActivities.set(key, activityGroup);
} else {
const activityGroup = groupedActivities.get(key);
activityGroup.activityIds.add(activityId);
activityGroup.count += count;
if (pageUrl) {
const currentCount = activityGroup.countsByUrl.get(pageUrl) || 0;
activityGroup.countsByUrl.set(pageUrl, currentCount + count);
}
}
}
}
return groupedActivities;
}
/**
* Sort activities by the total count for each activity group key. Resolve
* ties by the alphabetical order of the key.
*/
function sortActivitiesByCallCount(
groupedActivities: Map<string, ActivityGroup>): ActivityGroup[] {
return Array.from(groupedActivities.values()).sort((a, b) => {
if (a.count !== b.count) {
return b.count - a.count;
}
if (a.key < b.key) {
return -1;
}
if (a.key > b.key) {
return 1;
}
return 0;
});
}
declare global {
interface HTMLElementEventMap {
'delete-activity-log-item': CustomEvent<string[]>;
}
}
export class ActivityLogHistoryElement extends PolymerElement {
static get is() {
return 'activity-log-history';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
extensionId: String,
delegate: Object,
/**
* An array representing the activity log. Stores activities grouped by
* API call or content script name sorted in descending order of the call
* count.
*/
activityData_: {
type: Array,
value: () => [],
},
pageState_: {
type: String,
value: ActivityLogPageState.LOADING,
},
lastSearch_: {
type: String,
value: '',
},
};
}
extensionId: string;
delegate: ActivityLogDelegate;
private activityData_: ActivityGroup[];
private pageState_: ActivityLogPageState;
private lastSearch_: string;
private dataFetchedResolver_: PromiseResolver<void>|null;
private rawActivities_: string;
constructor() {
super();
/**
* A promise resolver for any external files waiting for the
* GetExtensionActivity API call to finish.
* Currently only used for extension_settings_browsertest.cc
*/
this.dataFetchedResolver_ = null;
/**
* The stringified API response from the activityLogPrivate API with
* individual activities sorted in ascending order by timestamp; used for
* exporting the activity log.
*/
this.rawActivities_ = '';
}
override ready() {
super.ready();
this.addEventListener('delete-activity-log-item', e => this.deleteItem_(e));
}
setPageStateForTest(state: ActivityLogPageState) {
this.pageState_ = state;
}
/**
* Expose only the promise of dataFetchedResolver_.
*/
whenDataFetched(): Promise<void> {
return this.dataFetchedResolver_!.promise;
}
override connectedCallback() {
super.connectedCallback();
this.dataFetchedResolver_ = new PromiseResolver();
this.refreshActivities_();
}
private shouldShowEmptyActivityLogMessage_(): boolean {
return this.pageState_ === ActivityLogPageState.LOADED &&
this.activityData_.length === 0;
}
private shouldShowLoadingMessage_(): boolean {
return this.pageState_ === ActivityLogPageState.LOADING;
}
private shouldShowActivities_(): boolean {
return this.pageState_ === ActivityLogPageState.LOADED &&
this.activityData_.length > 0;
}
private onClearActivitiesClick_() {
this.delegate.deleteActivitiesFromExtension(this.extensionId).then(() => {
this.processActivities_([]);
});
}
private onMoreActionsClick_() {
const moreButton = this.shadowRoot!.querySelector('cr-icon-button');
assert(moreButton);
this.shadowRoot!.querySelector('cr-action-menu')!.showAt(moreButton);
}
private expandItems_(expanded: boolean) {
// Do not use .filter here as we need the original index of the item
// in |activityData_|.
this.activityData_.forEach((item, index) => {
if (item.countsByUrl.size > 0) {
this.set(`activityData_.${index}.expanded`, expanded);
}
});
this.shadowRoot!.querySelector('cr-action-menu')!.close();
}
private onExpandAllClick_() {
this.expandItems_(true);
}
private onCollapseAllClick_() {
this.expandItems_(false);
}
private onExportClick_() {
const fileName = `exported_activity_log_${this.extensionId}.json`;
this.delegate.downloadActivities(this.rawActivities_, fileName);
}
private deleteItem_(e: CustomEvent<string[]>) {
const activityIds = e.detail;
this.delegate.deleteActivitiesById(activityIds).then(() => {
// It is possible for multiple activities displayed to have the same
// underlying activity ID. This happens when we split content script and
// web request activities by fields other than their API call. For
// consistency, we will re-fetch the activity log.
this.refreshActivities_();
});
}
private processActivities_(
activityData: chrome.activityLogPrivate.ExtensionActivity[]) {
this.pageState_ = ActivityLogPageState.LOADED;
// Sort |activityData| in ascending order based on the activity's
// timestamp; Used for |this.encodedRawActivities|.
activityData.sort((a, b) => a.time! - b.time!);
this.rawActivities_ = JSON.stringify(activityData);
this.activityData_ =
sortActivitiesByCallCount(groupActivities(activityData));
if (!this.dataFetchedResolver_!.isFulfilled) {
this.dataFetchedResolver_!.resolve();
}
}
private refreshActivities_(): Promise<void> {
if (this.lastSearch_ === '') {
return this.getActivityLog_();
}
return this.getFilteredActivityLog_(this.lastSearch_);
}
private getActivityLog_(): Promise<void> {
this.pageState_ = ActivityLogPageState.LOADING;
return this.delegate.getExtensionActivityLog(this.extensionId)
.then(result => {
this.processActivities_(result.activities);
});
}
private getFilteredActivityLog_(searchTerm: string): Promise<void> {
this.pageState_ = ActivityLogPageState.LOADING;
return this.delegate
.getFilteredExtensionActivityLog(this.extensionId, searchTerm)
.then(result => {
this.processActivities_(result.activities);
});
}
private onSearchChanged_(e: CustomEvent<string>) {
// Remove all whitespaces from the search term, as API call names and
// urls should not contain any whitespace. As of now, only single term
// search queries are allowed.
const searchTerm = e.detail.replace(/\s+/g, '');
if (searchTerm === this.lastSearch_) {
return;
}
this.lastSearch_ = searchTerm;
this.refreshActivities_();
}
}
declare global {
interface HTMLElementTagNameMap {
'activity-log-history': ActivityLogHistoryElement;
}
}
customElements.define(ActivityLogHistoryElement.is, ActivityLogHistoryElement);