blob: 74b4819d3757d6546d4f05dcd0e281877bc4d620 [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
cr.exportPath('extensions');
/**
* 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.
* @enum {string}
*/
const ActivityLogPageState = {
LOADING: 'loading',
LOADED: 'loaded'
};
cr.define('extensions', function() {
'use strict';
/** @interface */
class ActivityLogDelegate {
/**
* @param {string} extensionId
* @return {!Promise<!chrome.activityLogPrivate.ActivityResultSet>}
*/
getExtensionActivityLog(extensionId) {}
/**
* @param {string} extensionId
* @param {string} searchTerm
* @return {!Promise<!chrome.activityLogPrivate.ActivityResultSet>}
*/
getFilteredExtensionActivityLog(extensionId, searchTerm) {}
/**
* @param {!Array<string>} activityIds
* @return {!Promise<void>}
*/
deleteActivitiesById(activityIds) {}
/**
* @param {string} extensionId
* @return {!Promise<void>}
*/
deleteActivitiesFromExtension(extensionId) {}
}
/**
* 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.
* @private
* @param {!chrome.activityLogPrivate.ExtensionActivity} activity
* @return {!Array<string>}
*/
function getActivityGroupKeysForContentScript_(activity) {
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 /** @type {!Array<string>} */ (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.
* @private
* @param {!chrome.activityLogPrivate.ExtensionActivity} activity
* @return {!Array<string>}
*/
function getActivityGroupKeysForWebRequest_(activity) {
assert(
activity.activityType ===
chrome.activityLogPrivate.ExtensionActivityType.WEB_REQUEST);
const apiCall = activity.apiCall;
const other = activity.other;
if (!other || !other.webRequest) {
return [apiCall];
}
const webRequest = /** @type {!Object} */ (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.
* @param {!Array<!chrome.activityLogPrivate.ExtensionActivity>}
* activityData
* @return {!Map<string, !extensions.ActivityGroup>}
*/
function groupActivities(activityData) {
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()
};
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.
* @param {!Map<string, !extensions.ActivityGroup>} groupedActivities
* @return {!Array<!extensions.ActivityGroup>}
*/
function sortActivitiesByCallCount(groupedActivities) {
return Array.from(groupedActivities.values()).sort(function(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;
});
}
const ActivityLog = Polymer({
is: 'extensions-activity-log',
behaviors: [
CrContainerShadowBehavior,
],
properties: {
/** @type {!string} */
extensionId: String,
/** @type {!extensions.ActivityLogDelegate} */
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.
* @private
* @type {!Array<!extensions.ActivityGroup>}
*/
activityData_: {
type: Array,
value: () => [],
},
/**
* @private
* @type {ActivityLogPageState}
*/
pageState_: {
type: String,
value: ActivityLogPageState.LOADING,
},
/**
* A promise resolver for any external files waiting for the
* GetExtensionActivity API call to finish.
* Currently only used for extension_settings_browsertest.cc
* @type {!PromiseResolver}
*/
onDataFetched: {type: Object, value: new PromiseResolver()},
/** @private */
lastSearch_: {
type: String,
value: '',
},
},
/** @private {?number} */
navigationListener_: null,
listeners: {
'delete-activity-log-item': 'deleteItem_',
'view-enter-start': 'onViewEnterStart_',
},
/** @override */
attached: function() {
// Fetch the activity log for the extension when this page is attached.
// This is necesary as the listener below is not fired if the user
// navigates directly to the activity log page using url.
this.getActivityLog_();
// Add a listener here so we fetch the activity log whenever a user
// navigates to the activity log from another page. This is needed since
// this component already exists in the background if a user navigates
// away from this page so attached may not be called when a user navigates
// back.
this.navigationListener_ = extensions.navigation.addListener(newPage => {
if (newPage.page === Page.ACTIVITY_LOG) {
this.getActivityLog_();
}
});
},
/** @override */
detached: function() {
assert(extensions.navigation.removeListener(this.navigationListener_));
this.navigationListener_ = null;
},
/**
* Focuses the back button when page is loaded.
* @private
*/
onViewEnterStart_: function() {
Polymer.RenderStatus.afterNextRender(
this, () => cr.ui.focusWithoutInk(this.$.closeButton));
},
/**
* @private
* @return {boolean}
*/
shouldShowEmptyActivityLogMessage_: function() {
return this.pageState_ === ActivityLogPageState.LOADED &&
this.activityData_.length === 0;
},
/**
* @private
* @return {boolean}
*/
shouldShowLoadingMessage_: function() {
return this.pageState_ === ActivityLogPageState.LOADING;
},
/**
* @private
* @return {boolean}
*/
shouldShowActivities_: function() {
return this.pageState_ === ActivityLogPageState.LOADED &&
this.activityData_.length > 0;
},
/** @private */
onClearButtonTap_: function() {
this.delegate.deleteActivitiesFromExtension(this.extensionId).then(() => {
this.processActivities_([]);
});
},
/** @private */
deleteItem_: function(e) {
const activityIds = e.detail.activityIds;
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 */
onCloseButtonTap_: function() {
extensions.navigation.navigateTo(
{page: Page.DETAILS, extensionId: this.extensionId});
},
/**
* @private
* @param {!Array<!chrome.activityLogPrivate.ExtensionActivity>}
* activityData
*/
processActivities_: function(activityData) {
this.pageState_ = ActivityLogPageState.LOADED;
this.activityData_ =
sortActivitiesByCallCount(groupActivities(activityData));
if (!this.onDataFetched.isFulfilled) {
this.onDataFetched.resolve();
}
},
/** @return {!Promise<void>} */
refreshActivities: function() {
if (this.lastSearch_ === '') {
return this.getActivityLog_();
}
return this.getFilteredActivityLog_(this.lastSearch_);
},
/**
* @private
* @return {!Promise<void>}
*/
getActivityLog_: function() {
this.pageState_ = ActivityLogPageState.LOADING;
return this.delegate.getExtensionActivityLog(this.extensionId)
.then(result => {
this.processActivities_(result.activities);
});
},
/**
* @private
* @param {string} searchTerm
* @return {!Promise<void>}
*/
getFilteredActivityLog_: function(searchTerm) {
this.pageState_ = ActivityLogPageState.LOADING;
return this.delegate
.getFilteredExtensionActivityLog(this.extensionId, searchTerm)
.then(result => {
this.processActivities_(result.activities);
});
},
/** @private */
onSearchChanged_: function(e) {
// 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();
},
});
return {
ActivityLog: ActivityLog,
ActivityLogDelegate: ActivityLogDelegate,
};
});