blob: 99a7ef477d09db6e0c8454e59ab7ceb2f2e898ae [file] [log] [blame]
// Copyright (c) 2013 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.
'use strict';
/**
* @fileoverview The event page for Google Now for Chrome implementation.
* The Google Now event page gets Google Now cards from the server and shows
* them as Chrome notifications.
* The service performs periodic updating of Google Now cards.
* Each updating of the cards includes 4 steps:
* 1. Processing requests for cards dismissals that are not yet sent to the
* server.
* 2. Making a server request.
* 3. Showing the received cards as notifications.
*/
// TODO(robliao): Decide what to do in incognito mode.
/**
* Standard response code for successful HTTP requests. This is the only success
* code the server will send.
*/
var HTTP_OK = 200;
var HTTP_NOCONTENT = 204;
var HTTP_BAD_REQUEST = 400;
var HTTP_UNAUTHORIZED = 401;
var HTTP_FORBIDDEN = 403;
var HTTP_METHOD_NOT_ALLOWED = 405;
var MS_IN_SECOND = 1000;
var MS_IN_MINUTE = 60 * 1000;
/**
* Initial period for polling for Google Now Notifications cards to use when the
* period from the server is not available.
*/
var INITIAL_POLLING_PERIOD_SECONDS = 5 * 60; // 5 minutes
/**
* Mininal period for polling for Google Now Notifications cards.
*/
var MINIMUM_POLLING_PERIOD_SECONDS = 5 * 60; // 5 minutes
/**
* Maximal period for polling for Google Now Notifications cards to use when the
* period from the server is not available.
*/
var MAXIMUM_POLLING_PERIOD_SECONDS = 60 * 60; // 1 hour
/**
* Initial period for polling for Google Now optin notification after push
* messaging indicates Google Now is enabled.
*/
var INITIAL_OPTIN_RECHECK_PERIOD_SECONDS = 60; // 1 minute
/**
* Maximum period for polling for Google Now optin notification after push
* messaging indicates Google Now is enabled. It is expected that the alarm
* will be stopped after this.
*/
var MAXIMUM_OPTIN_RECHECK_PERIOD_SECONDS = 16 * 60; // 16 minutes
/**
* Initial period for retrying the server request for dismissing cards.
*/
var INITIAL_RETRY_DISMISS_PERIOD_SECONDS = 60; // 1 minute
/**
* Maximum period for retrying the server request for dismissing cards.
*/
var MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS = 60 * 60; // 1 hour
/**
* Time we keep retrying dismissals.
*/
var MAXIMUM_DISMISSAL_AGE_MS = 24 * 60 * 60 * 1000; // 1 day
/**
* Time we keep dismissals after successful server dismiss requests.
*/
var DISMISS_RETENTION_TIME_MS = 20 * 60 * 1000; // 20 minutes
/**
* Default period for checking whether the user is opted in to Google Now.
*/
var DEFAULT_OPTIN_CHECK_PERIOD_SECONDS = 60 * 60 * 24 * 7; // 1 week
/**
* URL to open when the user clicked on a link for the our notification
* settings.
*/
var SETTINGS_URL = 'https://support.google.com/chrome/?p=ib_google_now_welcome';
/**
* Number of cards that need an explanatory link.
*/
var EXPLANATORY_CARDS_LINK_THRESHOLD = 4;
/**
* Names for tasks that can be created by the extension.
*/
var UPDATE_CARDS_TASK_NAME = 'update-cards';
var DISMISS_CARD_TASK_NAME = 'dismiss-card';
var RETRY_DISMISS_TASK_NAME = 'retry-dismiss';
var STATE_CHANGED_TASK_NAME = 'state-changed';
var SHOW_ON_START_TASK_NAME = 'show-cards-on-start';
var ON_PUSH_MESSAGE_START_TASK_NAME = 'on-push-message';
/**
* Group as received from the server.
*
* @typedef {{
* nextPollSeconds: (string|undefined),
* rank: (number|undefined),
* requested: (boolean|undefined)
* }}
*/
var ReceivedGroup;
/**
* Server response with notifications and groups.
*
* @typedef {{
* googleNowDisabled: (boolean|undefined),
* groups: Object.<string, ReceivedGroup>,
* notifications: Array.<ReceivedNotification>
* }}
*/
var ServerResponse;
/**
* Notification group as the client stores it. |cardsTimestamp| and |rank| are
* defined if |cards| is non-empty. |nextPollTime| is undefined if the server
* (1) never sent 'nextPollSeconds' for the group or
* (2) didn't send 'nextPollSeconds' with the last group update containing a
* cards update and all the times after that.
*
* @typedef {{
* cards: Array.<ReceivedNotification>,
* cardsTimestamp: (number|undefined),
* nextPollTime: (number|undefined),
* rank: (number|undefined)
* }}
*/
var StoredNotificationGroup;
/**
* Pending (not yet successfully sent) dismissal for a received notification.
* |time| is the moment when the user requested dismissal.
*
* @typedef {{
* chromeNotificationId: ChromeNotificationId,
* time: number,
* dismissalData: DismissalData
* }}
*/
var PendingDismissal;
/**
* Checks if a new task can't be scheduled when another task is already
* scheduled.
* @param {string} newTaskName Name of the new task.
* @param {string} scheduledTaskName Name of the scheduled task.
* @return {boolean} Whether the new task conflicts with the existing task.
*/
function areTasksConflicting(newTaskName, scheduledTaskName) {
if (newTaskName == UPDATE_CARDS_TASK_NAME &&
scheduledTaskName == UPDATE_CARDS_TASK_NAME) {
// If a card update is requested while an old update is still scheduled, we
// don't need the new update.
return true;
}
if (newTaskName == RETRY_DISMISS_TASK_NAME &&
(scheduledTaskName == UPDATE_CARDS_TASK_NAME ||
scheduledTaskName == DISMISS_CARD_TASK_NAME ||
scheduledTaskName == RETRY_DISMISS_TASK_NAME)) {
// No need to schedule retry-dismiss action if another action that tries to
// send dismissals is scheduled.
return true;
}
return false;
}
var tasks = buildTaskManager(areTasksConflicting);
// Add error processing to API calls.
wrapper.instrumentChromeApiFunction('metricsPrivate.getVariationParams', 1);
wrapper.instrumentChromeApiFunction('notifications.clear', 1);
wrapper.instrumentChromeApiFunction('notifications.create', 2);
wrapper.instrumentChromeApiFunction('notifications.getPermissionLevel', 0);
wrapper.instrumentChromeApiFunction('notifications.update', 2);
wrapper.instrumentChromeApiFunction('notifications.getAll', 0);
wrapper.instrumentChromeApiFunction(
'notifications.onButtonClicked.addListener', 0);
wrapper.instrumentChromeApiFunction('notifications.onClicked.addListener', 0);
wrapper.instrumentChromeApiFunction('notifications.onClosed.addListener', 0);
wrapper.instrumentChromeApiFunction(
'notifications.onPermissionLevelChanged.addListener', 0);
wrapper.instrumentChromeApiFunction(
'notifications.onShowSettings.addListener', 0);
wrapper.instrumentChromeApiFunction('permissions.contains', 1);
wrapper.instrumentChromeApiFunction('pushMessaging.onMessage.addListener', 0);
wrapper.instrumentChromeApiFunction('storage.onChanged.addListener', 0);
wrapper.instrumentChromeApiFunction('runtime.onInstalled.addListener', 0);
wrapper.instrumentChromeApiFunction('runtime.onStartup.addListener', 0);
wrapper.instrumentChromeApiFunction('tabs.create', 1);
var updateCardsAttempts = buildAttemptManager(
'cards-update',
requestCards,
INITIAL_POLLING_PERIOD_SECONDS,
MAXIMUM_POLLING_PERIOD_SECONDS);
var optInPollAttempts = buildAttemptManager(
'optin',
pollOptedInNoImmediateRecheck,
INITIAL_POLLING_PERIOD_SECONDS,
MAXIMUM_POLLING_PERIOD_SECONDS);
var optInRecheckAttempts = buildAttemptManager(
'optin-recheck',
pollOptedInWithRecheck,
INITIAL_OPTIN_RECHECK_PERIOD_SECONDS,
MAXIMUM_OPTIN_RECHECK_PERIOD_SECONDS);
var dismissalAttempts = buildAttemptManager(
'dismiss',
retryPendingDismissals,
INITIAL_RETRY_DISMISS_PERIOD_SECONDS,
MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS);
var cardSet = buildCardSet();
var authenticationManager = buildAuthenticationManager();
/**
* Google Now UMA event identifier.
* @enum {number}
*/
var GoogleNowEvent = {
REQUEST_FOR_CARDS_TOTAL: 0,
REQUEST_FOR_CARDS_SUCCESS: 1,
CARDS_PARSE_SUCCESS: 2,
DISMISS_REQUEST_TOTAL: 3,
DISMISS_REQUEST_SUCCESS: 4,
LOCATION_REQUEST: 5,
DELETED_LOCATION_UPDATE: 6,
EXTENSION_START: 7,
DELETED_SHOW_WELCOME_TOAST: 8,
STOPPED: 9,
DELETED_USER_SUPPRESSED: 10,
SIGNED_OUT: 11,
NOTIFICATION_DISABLED: 12,
GOOGLE_NOW_DISABLED: 13,
EVENTS_TOTAL: 14 // EVENTS_TOTAL is not an event; all new events need to be
// added before it.
};
/**
* Records a Google Now Event.
* @param {GoogleNowEvent} event Event identifier.
*/
function recordEvent(event) {
var metricDescription = {
metricName: 'GoogleNow.Event',
type: 'histogram-linear',
min: 1,
max: GoogleNowEvent.EVENTS_TOTAL,
buckets: GoogleNowEvent.EVENTS_TOTAL + 1
};
chrome.metricsPrivate.recordValue(metricDescription, event);
}
/**
* Records a notification clicked event.
* @param {number|undefined} cardTypeId Card type ID.
*/
function recordNotificationClick(cardTypeId) {
if (cardTypeId !== undefined) {
chrome.metricsPrivate.recordSparseValue(
'GoogleNow.Card.Clicked', cardTypeId);
}
}
/**
* Records a button clicked event.
* @param {number|undefined} cardTypeId Card type ID.
* @param {number} buttonIndex Button Index
*/
function recordButtonClick(cardTypeId, buttonIndex) {
if (cardTypeId !== undefined) {
chrome.metricsPrivate.recordSparseValue(
'GoogleNow.Card.Button.Clicked' + buttonIndex, cardTypeId);
}
}
/**
* Checks the result of the HTTP Request and updates the authentication
* manager on any failure.
* @param {string} token Authentication token to validate against an
* XMLHttpRequest.
* @return {function(XMLHttpRequest)} Function that validates the token with the
* supplied XMLHttpRequest.
*/
function checkAuthenticationStatus(token) {
return function(request) {
if (request.status == HTTP_FORBIDDEN ||
request.status == HTTP_UNAUTHORIZED) {
authenticationManager.removeToken(token);
}
}
}
/**
* Builds and sends an authenticated request to the notification server.
* @param {string} method Request method.
* @param {string} handlerName Server handler to send the request to.
* @param {string=} opt_contentType Value for the Content-type header.
* @return {Promise} A promise to issue a request to the server.
* The promise rejects if the response is not within the HTTP 200 range.
*/
function requestFromServer(method, handlerName, opt_contentType) {
return authenticationManager.getAuthToken().then(function(token) {
var request = buildServerRequest(method, handlerName, opt_contentType);
request.setRequestHeader('Authorization', 'Bearer ' + token);
var requestPromise = new Promise(function(resolve, reject) {
request.addEventListener('loadend', function() {
if ((200 <= request.status) && (request.status < 300)) {
resolve(request);
} else {
reject(request);
}
}, false);
request.send();
});
requestPromise.catch(checkAuthenticationStatus(token));
return requestPromise;
});
}
/**
* Shows the notification groups as notification cards.
* @param {Object.<string, StoredNotificationGroup>} notificationGroups Map from
* group name to group information.
* @param {function(ReceivedNotification)=} opt_onCardShown Optional parameter
* called when each card is shown.
* @return {Promise} A promise to show the notification groups as cards.
*/
function showNotificationGroups(notificationGroups, opt_onCardShown) {
/** @type {Object.<ChromeNotificationId, CombinedCard>} */
var cards = combineCardsFromGroups(notificationGroups);
console.log('showNotificationGroups ' + JSON.stringify(cards));
return new Promise(function(resolve) {
instrumented.notifications.getAll(function(notifications) {
console.log('showNotificationGroups-getAll ' +
JSON.stringify(notifications));
notifications = notifications || {};
// Mark notifications that didn't receive an update as having received
// an empty update.
for (var chromeNotificationId in notifications) {
cards[chromeNotificationId] = cards[chromeNotificationId] || [];
}
/** @type {Object.<ChromeNotificationId, NotificationDataEntry>} */
var notificationsData = {};
// Create/update/delete notifications.
for (var chromeNotificationId in cards) {
notificationsData[chromeNotificationId] = cardSet.update(
chromeNotificationId,
cards[chromeNotificationId],
notificationGroups,
opt_onCardShown);
}
chrome.storage.local.set({notificationsData: notificationsData});
resolve();
});
});
}
/**
* Removes all cards and card state on Google Now close down.
*/
function removeAllCards() {
console.log('removeAllCards');
// TODO(robliao): Once Google Now clears its own checkbox in the
// notifications center and bug 260376 is fixed, the below clearing
// code is no longer necessary.
instrumented.notifications.getAll(function(notifications) {
notifications = notifications || {};
for (var chromeNotificationId in notifications) {
instrumented.notifications.clear(chromeNotificationId, function() {});
}
chrome.storage.local.remove(['notificationsData', 'notificationGroups']);
});
}
/**
* Adds a card group into a set of combined cards.
* @param {Object.<ChromeNotificationId, CombinedCard>} combinedCards Map from
* chromeNotificationId to a combined card.
* This is an input/output parameter.
* @param {StoredNotificationGroup} storedGroup Group to combine into the
* combined card set.
*/
function combineGroup(combinedCards, storedGroup) {
for (var i = 0; i < storedGroup.cards.length; i++) {
/** @type {ReceivedNotification} */
var receivedNotification = storedGroup.cards[i];
/** @type {UncombinedNotification} */
var uncombinedNotification = {
receivedNotification: receivedNotification,
showTime: receivedNotification.trigger.showTimeSec &&
(storedGroup.cardsTimestamp +
receivedNotification.trigger.showTimeSec * MS_IN_SECOND),
hideTime: storedGroup.cardsTimestamp +
receivedNotification.trigger.hideTimeSec * MS_IN_SECOND
};
var combinedCard =
combinedCards[receivedNotification.chromeNotificationId] || [];
combinedCard.push(uncombinedNotification);
combinedCards[receivedNotification.chromeNotificationId] = combinedCard;
}
}
/**
* Calculates the soonest poll time from a map of groups as an absolute time.
* @param {Object.<string, StoredNotificationGroup>} groups Map from group name
* to group information.
* @return {number} The next poll time based off of the groups.
*/
function calculateNextPollTimeMilliseconds(groups) {
var nextPollTime = null;
for (var groupName in groups) {
var group = groups[groupName];
if (group.nextPollTime !== undefined) {
nextPollTime = nextPollTime == null ?
group.nextPollTime : Math.min(group.nextPollTime, nextPollTime);
}
}
// At least one of the groups must have nextPollTime.
verify(nextPollTime != null, 'calculateNextPollTime: nextPollTime is null');
return nextPollTime;
}
/**
* Schedules next cards poll.
* @param {Object.<string, StoredNotificationGroup>} groups Map from group name
* to group information.
*/
function scheduleNextCardsPoll(groups) {
var nextPollTimeMs = calculateNextPollTimeMilliseconds(groups);
var nextPollDelaySeconds = Math.max(
(nextPollTimeMs - Date.now()) / MS_IN_SECOND,
MINIMUM_POLLING_PERIOD_SECONDS);
updateCardsAttempts.start(nextPollDelaySeconds);
}
/**
* Schedules the next opt-in check poll.
*/
function scheduleOptInCheckPoll() {
instrumented.metricsPrivate.getVariationParams(
'GoogleNow', function(params) {
var optinPollPeriodSeconds =
parseInt(params && params.optinPollPeriodSeconds, 10) ||
DEFAULT_OPTIN_CHECK_PERIOD_SECONDS;
optInPollAttempts.start(optinPollPeriodSeconds);
});
}
/**
* Combines notification groups into a set of Chrome notifications.
* @param {Object.<string, StoredNotificationGroup>} notificationGroups Map from
* group name to group information.
* @return {Object.<ChromeNotificationId, CombinedCard>} Cards to show.
*/
function combineCardsFromGroups(notificationGroups) {
console.log('combineCardsFromGroups ' + JSON.stringify(notificationGroups));
/** @type {Object.<ChromeNotificationId, CombinedCard>} */
var combinedCards = {};
for (var groupName in notificationGroups)
combineGroup(combinedCards, notificationGroups[groupName]);
return combinedCards;
}
/**
* Processes a server response for consumption by showNotificationGroups.
* @param {ServerResponse} response Server response.
* @return {Promise} A promise to process the server response and provide
* updated groups. Rejects if the server response shouldn't be processed.
*/
function processServerResponse(response) {
console.log('processServerResponse ' + JSON.stringify(response));
if (response.googleNowDisabled) {
chrome.storage.local.set({googleNowEnabled: false});
// Stop processing now. The state change will clear the cards.
return Promise.reject();
}
var receivedGroups = response.groups;
return fillFromChromeLocalStorage({
/** @type {Object.<string, StoredNotificationGroup>} */
notificationGroups: {},
/** @type {Object.<ServerNotificationId, number>} */
recentDismissals: {}
}).then(function(items) {
console.log('processServerResponse-get ' + JSON.stringify(items));
// Build a set of non-expired recent dismissals. It will be used for
// client-side filtering of cards.
/** @type {Object.<ServerNotificationId, number>} */
var updatedRecentDismissals = {};
var now = Date.now();
for (var serverNotificationId in items.recentDismissals) {
var dismissalAge = now - items.recentDismissals[serverNotificationId];
if (dismissalAge < DISMISS_RETENTION_TIME_MS) {
updatedRecentDismissals[serverNotificationId] =
items.recentDismissals[serverNotificationId];
}
}
// Populate groups with corresponding cards.
if (response.notifications) {
for (var i = 0; i < response.notifications.length; ++i) {
/** @type {ReceivedNotification} */
var card = response.notifications[i];
if (!(card.notificationId in updatedRecentDismissals)) {
var group = receivedGroups[card.groupName];
group.cards = group.cards || [];
group.cards.push(card);
}
}
}
// Build updated set of groups.
var updatedGroups = {};
for (var groupName in receivedGroups) {
var receivedGroup = receivedGroups[groupName];
var storedGroup = items.notificationGroups[groupName] || {
cards: [],
cardsTimestamp: undefined,
nextPollTime: undefined,
rank: undefined
};
if (receivedGroup.requested)
receivedGroup.cards = receivedGroup.cards || [];
if (receivedGroup.cards) {
// If the group contains a cards update, all its fields will get new
// values.
storedGroup.cards = receivedGroup.cards;
storedGroup.cardsTimestamp = now;
storedGroup.rank = receivedGroup.rank;
storedGroup.nextPollTime = undefined;
// The code below assigns nextPollTime a defined value if
// nextPollSeconds is specified in the received group.
// If the group's cards are not updated, and nextPollSeconds is
// unspecified, this method doesn't change group's nextPollTime.
}
// 'nextPollSeconds' may be sent even for groups that don't contain
// cards updates.
if (receivedGroup.nextPollSeconds !== undefined) {
storedGroup.nextPollTime =
now + receivedGroup.nextPollSeconds * MS_IN_SECOND;
}
updatedGroups[groupName] = storedGroup;
}
scheduleNextCardsPoll(updatedGroups);
return {
updatedGroups: updatedGroups,
recentDismissals: updatedRecentDismissals
};
});
}
/**
* Update the Explanatory Total Cards Shown Count.
*/
function countExplanatoryCard() {
localStorage['explanatoryCardsShown']++;
}
/**
* Determines if cards should have an explanation link.
* @return {boolean} true if an explanatory card should be shown.
*/
function shouldShowExplanatoryCard() {
var isBelowThreshold =
localStorage['explanatoryCardsShown'] < EXPLANATORY_CARDS_LINK_THRESHOLD;
return isBelowThreshold;
}
/**
* Requests notification cards from the server for specified groups.
* @param {Array.<string>} groupNames Names of groups that need to be refreshed.
* @return {Promise} A promise to request the specified notification groups.
*/
function requestNotificationGroupsFromServer(groupNames) {
console.log(
'requestNotificationGroupsFromServer from ' + NOTIFICATION_CARDS_URL +
', groupNames=' + JSON.stringify(groupNames));
recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_TOTAL);
var requestParameters = '?timeZoneOffsetMs=' +
(-new Date().getTimezoneOffset() * MS_IN_MINUTE);
if (shouldShowExplanatoryCard()) {
requestParameters += '&cardExplanation=true';
}
groupNames.forEach(function(groupName) {
requestParameters += ('&requestTypes=' + groupName);
});
requestParameters += '&uiLocale=' + navigator.language;
console.log(
'requestNotificationGroupsFromServer: request=' + requestParameters);
return requestFromServer('GET', 'notifications' + requestParameters).then(
function(request) {
console.log(
'requestNotificationGroupsFromServer-received ' + request.status);
if (request.status == HTTP_OK) {
recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_SUCCESS);
return JSON.parse(request.responseText);
}
});
}
/**
* Performs an opt-in poll without an immediate recheck.
* If the response is not opted-in, schedule an opt-in check poll.
*/
function pollOptedInNoImmediateRecheck() {
requestAndUpdateOptedIn()
.then(function(optedIn) {
if (!optedIn) {
// Request a repoll if we're not opted in.
return Promise.reject();
}
})
.catch(function() {
scheduleOptInCheckPoll();
});
}
/**
* Requests the account opted-in state from the server and updates any
* state as necessary.
* @return {Promise} A promise to request and update the opted-in state.
* The promise resolves with the opt-in state.
*/
function requestAndUpdateOptedIn() {
console.log('requestOptedIn from ' + NOTIFICATION_CARDS_URL);
return requestFromServer('GET', 'settings/optin').then(function(request) {
console.log(
'requestOptedIn-received ' + request.status + ' ' + request.response);
if (request.status == HTTP_OK) {
var parsedResponse = JSON.parse(request.responseText);
return parsedResponse.value;
}
}).then(function(optedIn) {
chrome.storage.local.set({googleNowEnabled: optedIn});
return optedIn;
});
}
/**
* Determines the groups that need to be requested right now.
* @return {Promise} A promise to determine the groups to request.
*/
function getGroupsToRequest() {
return fillFromChromeLocalStorage({
/** @type {Object.<string, StoredNotificationGroup>} */
notificationGroups: {}
}).then(function(items) {
console.log('getGroupsToRequest-storage-get ' + JSON.stringify(items));
var groupsToRequest = [];
var now = Date.now();
for (var groupName in items.notificationGroups) {
var group = items.notificationGroups[groupName];
if (group.nextPollTime !== undefined && group.nextPollTime <= now)
groupsToRequest.push(groupName);
}
return groupsToRequest;
});
}
/**
* Requests notification cards from the server.
* @return {Promise} A promise to request the notification cards.
* Rejects if the cards won't be requested.
*/
function requestNotificationCards() {
console.log('requestNotificationCards');
return getGroupsToRequest()
.then(requestNotificationGroupsFromServer)
.then(processServerResponse)
.then(function(processedResponse) {
var onCardShown =
shouldShowExplanatoryCard() ? countExplanatoryCard : undefined;
return showNotificationGroups(
processedResponse.updatedGroups, onCardShown).then(function() {
chrome.storage.local.set({
notificationGroups: processedResponse.updatedGroups,
recentDismissals: processedResponse.updatedRecentDismissals
});
recordEvent(GoogleNowEvent.CARDS_PARSE_SUCCESS);
}
);
});
}
/**
* Requests and shows notification cards.
*/
function requestCards() {
console.log('requestCards @' + new Date());
// LOCATION_REQUEST is a legacy histogram value when we requested location.
// This corresponds to the extension attempting to request for cards.
// We're keeping the name the same to keep our histograms in order.
recordEvent(GoogleNowEvent.LOCATION_REQUEST);
tasks.add(UPDATE_CARDS_TASK_NAME, function() {
console.log('requestCards-task-begin');
updateCardsAttempts.isRunning(function(running) {
if (running) {
// The cards are requested only if there are no unsent dismissals.
processPendingDismissals()
.then(requestNotificationCards)
.catch(updateCardsAttempts.scheduleRetry);
}
});
});
}
/**
* Sends a server request to dismiss a card.
* @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of
* the card.
* @param {number} dismissalTimeMs Time of the user's dismissal of the card in
* milliseconds since epoch.
* @param {DismissalData} dismissalData Data to build a dismissal request.
* @return {Promise} A promise to request the card dismissal, rejects on error.
*/
function requestCardDismissal(
chromeNotificationId, dismissalTimeMs, dismissalData) {
console.log('requestDismissingCard ' + chromeNotificationId +
' from ' + NOTIFICATION_CARDS_URL +
', dismissalData=' + JSON.stringify(dismissalData));
var dismissalAge = Date.now() - dismissalTimeMs;
if (dismissalAge > MAXIMUM_DISMISSAL_AGE_MS) {
return Promise.resolve();
}
recordEvent(GoogleNowEvent.DISMISS_REQUEST_TOTAL);
var requestParameters = 'notifications/' + dismissalData.notificationId +
'?age=' + dismissalAge +
'&chromeNotificationId=' + chromeNotificationId;
for (var paramField in dismissalData.parameters)
requestParameters += ('&' + paramField +
'=' + dismissalData.parameters[paramField]);
console.log('requestCardDismissal: requestParameters=' + requestParameters);
return requestFromServer('DELETE', requestParameters).then(function(request) {
console.log('requestDismissingCard-onloadend ' + request.status);
if (request.status == HTTP_NOCONTENT)
recordEvent(GoogleNowEvent.DISMISS_REQUEST_SUCCESS);
// A dismissal doesn't require further retries if it was successful or
// doesn't have a chance for successful completion.
return (request.status == HTTP_NOCONTENT) ?
Promise.resolve() :
Promise.reject();
}).catch(function(request) {
return (request.status == HTTP_BAD_REQUEST ||
request.status == HTTP_METHOD_NOT_ALLOWED) ?
Promise.resolve() :
Promise.reject();
});
}
/**
* Tries to send dismiss requests for all pending dismissals.
* @return {Promise} A promise to process the pending dismissals.
* The promise is rejected if a problem was encountered.
*/
function processPendingDismissals() {
return fillFromChromeLocalStorage({
/** @type {Array.<PendingDismissal>} */
pendingDismissals: [],
/** @type {Object.<ServerNotificationId, number>} */
recentDismissals: {}
}).then(function(items) {
console.log(
'processPendingDismissals-storage-get ' + JSON.stringify(items));
var dismissalsChanged = false;
function onFinish(success) {
if (dismissalsChanged) {
chrome.storage.local.set({
pendingDismissals: items.pendingDismissals,
recentDismissals: items.recentDismissals
});
}
return success ? Promise.resolve() : Promise.reject();
}
function doProcessDismissals() {
if (items.pendingDismissals.length == 0) {
dismissalAttempts.stop();
return onFinish(true);
}
// Send dismissal for the first card, and if successful, repeat
// recursively with the rest.
/** @type {PendingDismissal} */
var dismissal = items.pendingDismissals[0];
return requestCardDismissal(
dismissal.chromeNotificationId,
dismissal.time,
dismissal.dismissalData).then(function() {
dismissalsChanged = true;
items.pendingDismissals.splice(0, 1);
items.recentDismissals[dismissal.dismissalData.notificationId] =
Date.now();
return doProcessDismissals();
}).catch(function() {
return onFinish(false);
});
}
return doProcessDismissals();
});
}
/**
* Submits a task to send pending dismissals.
*/
function retryPendingDismissals() {
tasks.add(RETRY_DISMISS_TASK_NAME, function() {
processPendingDismissals().catch(dismissalAttempts.scheduleRetry);
});
}
/**
* Opens a URL in a new tab.
* @param {string} url URL to open.
*/
function openUrl(url) {
instrumented.tabs.create({url: url}, function(tab) {
if (tab)
chrome.windows.update(tab.windowId, {focused: true});
else
chrome.windows.create({url: url, focused: true});
});
}
/**
* Opens URL corresponding to the clicked part of the notification.
* @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of
* the card.
* @param {function(NotificationDataEntry): (string|undefined)} selector
* Function that extracts the url for the clicked area from the
* notification data entry.
*/
function onNotificationClicked(chromeNotificationId, selector) {
fillFromChromeLocalStorage({
/** @type {Object.<ChromeNotificationId, NotificationDataEntry>} */
notificationsData: {}
}).then(function(items) {
/** @type {(NotificationDataEntry|undefined)} */
var notificationDataEntry = items.notificationsData[chromeNotificationId];
if (!notificationDataEntry)
return;
var url = selector(notificationDataEntry);
if (!url)
return;
openUrl(url);
});
}
/**
* Callback for chrome.notifications.onClosed event.
* @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of
* the card.
* @param {boolean} byUser Whether the notification was closed by the user.
*/
function onNotificationClosed(chromeNotificationId, byUser) {
if (!byUser)
return;
// At this point we are guaranteed that the notification is a now card.
chrome.metricsPrivate.recordUserAction('GoogleNow.Dismissed');
tasks.add(DISMISS_CARD_TASK_NAME, function() {
dismissalAttempts.start();
fillFromChromeLocalStorage({
/** @type {Array.<PendingDismissal>} */
pendingDismissals: [],
/** @type {Object.<ChromeNotificationId, NotificationDataEntry>} */
notificationsData: {},
/** @type {Object.<string, StoredNotificationGroup>} */
notificationGroups: {}
}).then(function(items) {
/** @type {NotificationDataEntry} */
var notificationData =
items.notificationsData[chromeNotificationId] ||
{
timestamp: Date.now(),
combinedCard: []
};
var dismissalResult =
cardSet.onDismissal(
chromeNotificationId,
notificationData,
items.notificationGroups);
for (var i = 0; i < dismissalResult.dismissals.length; i++) {
/** @type {PendingDismissal} */
var dismissal = {
chromeNotificationId: chromeNotificationId,
time: Date.now(),
dismissalData: dismissalResult.dismissals[i]
};
items.pendingDismissals.push(dismissal);
}
items.notificationsData[chromeNotificationId] =
dismissalResult.notificationData;
chrome.storage.local.set(items);
processPendingDismissals();
});
});
}
/**
* Initializes the polling system to start fetching cards.
*/
function startPollingCards() {
console.log('startPollingCards');
// Create an update timer for a case when for some reason requesting
// cards gets stuck.
updateCardsAttempts.start(MAXIMUM_POLLING_PERIOD_SECONDS);
requestCards();
}
/**
* Stops all machinery in the polling system.
*/
function stopPollingCards() {
console.log('stopPollingCards');
updateCardsAttempts.stop();
// Since we're stopping everything, clear all runtime storage.
// We don't clear localStorage since those values are still relevant
// across Google Now start-stop events.
chrome.storage.local.clear();
}
/**
* Initializes the event page on install or on browser startup.
*/
function initialize() {
recordEvent(GoogleNowEvent.EXTENSION_START);
onStateChange();
}
/**
* Starts or stops the main pipeline for polling cards.
* @param {boolean} shouldPollCardsRequest true to start and
* false to stop polling cards.
*/
function setShouldPollCards(shouldPollCardsRequest) {
updateCardsAttempts.isRunning(function(currentValue) {
if (shouldPollCardsRequest != currentValue) {
console.log('Action Taken setShouldPollCards=' + shouldPollCardsRequest);
if (shouldPollCardsRequest)
startPollingCards();
else
stopPollingCards();
} else {
console.log(
'Action Ignored setShouldPollCards=' + shouldPollCardsRequest);
}
});
}
/**
* Starts or stops the optin check.
* @param {boolean} shouldPollOptInStatus true to start and false to stop
* polling the optin status.
*/
function setShouldPollOptInStatus(shouldPollOptInStatus) {
optInPollAttempts.isRunning(function(currentValue) {
if (shouldPollOptInStatus != currentValue) {
console.log(
'Action Taken setShouldPollOptInStatus=' + shouldPollOptInStatus);
if (shouldPollOptInStatus) {
pollOptedInNoImmediateRecheck();
} else {
optInPollAttempts.stop();
}
} else {
console.log(
'Action Ignored setShouldPollOptInStatus=' + shouldPollOptInStatus);
}
});
}
/**
* Enables or disables the Google Now background permission.
* @param {boolean} backgroundEnable true to run in the background.
* false to not run in the background.
*/
function setBackgroundEnable(backgroundEnable) {
instrumented.permissions.contains({permissions: ['background']},
function(hasPermission) {
if (backgroundEnable != hasPermission) {
console.log('Action Taken setBackgroundEnable=' + backgroundEnable);
if (backgroundEnable)
chrome.permissions.request({permissions: ['background']});
else
chrome.permissions.remove({permissions: ['background']});
} else {
console.log('Action Ignored setBackgroundEnable=' + backgroundEnable);
}
});
}
/**
* Record why this extension would not poll for cards.
* @param {boolean} signedIn true if the user is signed in.
* @param {boolean} notificationEnabled true if
* Google Now for Chrome is allowed to show notifications.
* @param {boolean} googleNowEnabled true if
* the Google Now is enabled for the user.
*/
function recordEventIfNoCards(signedIn, notificationEnabled, googleNowEnabled) {
if (!signedIn) {
recordEvent(GoogleNowEvent.SIGNED_OUT);
} else if (!notificationEnabled) {
recordEvent(GoogleNowEvent.NOTIFICATION_DISABLED);
} else if (!googleNowEnabled) {
recordEvent(GoogleNowEvent.GOOGLE_NOW_DISABLED);
}
}
/**
* Does the actual work of deciding what Google Now should do
* based off of the current state of Chrome.
* @param {boolean} signedIn true if the user is signed in.
* @param {boolean} canEnableBackground true if
* the background permission can be requested.
* @param {boolean} notificationEnabled true if
* Google Now for Chrome is allowed to show notifications.
* @param {boolean} googleNowEnabled true if
* the Google Now is enabled for the user.
*/
function updateRunningState(
signedIn,
canEnableBackground,
notificationEnabled,
googleNowEnabled) {
console.log(
'State Update signedIn=' + signedIn + ' ' +
'canEnableBackground=' + canEnableBackground + ' ' +
'notificationEnabled=' + notificationEnabled + ' ' +
'googleNowEnabled=' + googleNowEnabled);
var shouldPollCards = false;
var shouldPollOptInStatus = false;
var shouldSetBackground = false;
if (signedIn && notificationEnabled) {
shouldPollCards = googleNowEnabled;
shouldPollOptInStatus = !googleNowEnabled;
shouldSetBackground = canEnableBackground && googleNowEnabled;
} else {
recordEvent(GoogleNowEvent.STOPPED);
}
recordEventIfNoCards(signedIn, notificationEnabled, googleNowEnabled);
console.log(
'Requested Actions shouldSetBackground=' + shouldSetBackground + ' ' +
'setShouldPollCards=' + shouldPollCards + ' ' +
'shouldPollOptInStatus=' + shouldPollOptInStatus);
setBackgroundEnable(shouldSetBackground);
setShouldPollCards(shouldPollCards);
setShouldPollOptInStatus(shouldPollOptInStatus);
if (!shouldPollCards) {
removeAllCards();
}
}
/**
* Coordinates the behavior of Google Now for Chrome depending on
* Chrome and extension state.
*/
function onStateChange() {
tasks.add(STATE_CHANGED_TASK_NAME, function() {
Promise.all([
authenticationManager.isSignedIn(),
canEnableBackground(),
isNotificationsEnabled(),
isGoogleNowEnabled()])
.then(function(results) {
updateRunningState.apply(null, results);
});
});
}
/**
* Determines if background mode should be requested.
* @return {Promise} A promise to determine if background can be enabled.
*/
function canEnableBackground() {
return new Promise(function(resolve) {
instrumented.metricsPrivate.getVariationParams(
'GoogleNow',
function(response) {
resolve(!response || (response.canEnableBackground != 'false'));
});
});
}
/**
* Checks if Google Now is enabled in the notifications center.
* @return {Promise} A promise to determine if Google Now is enabled
* in the notifications center.
*/
function isNotificationsEnabled() {
return new Promise(function(resolve) {
instrumented.notifications.getPermissionLevel(function(level) {
resolve(level == 'granted');
});
});
}
/**
* Gets the previous Google Now opt-in state.
* @return {Promise} A promise to determine the previous Google Now
* opt-in state.
*/
function isGoogleNowEnabled() {
return fillFromChromeLocalStorage({googleNowEnabled: false})
.then(function(items) {
return items.googleNowEnabled;
});
}
/**
* Polls the optin state.
* Sometimes we get the response to the opted in result too soon during
* push messaging. We'll recheck the optin state a few times before giving up.
*/
function pollOptedInWithRecheck() {
/**
* Cleans up any state used to recheck the opt-in poll.
*/
function clearPollingState() {
localStorage.removeItem('optedInCheckCount');
optInRecheckAttempts.stop();
}
if (localStorage.optedInCheckCount === undefined) {
localStorage.optedInCheckCount = 0;
optInRecheckAttempts.start();
}
console.log(new Date() +
' checkOptedIn Attempt ' + localStorage.optedInCheckCount);
requestAndUpdateOptedIn().then(function(optedIn) {
if (optedIn) {
clearPollingState();
return Promise.resolve();
} else {
// If we're not opted in, reject to retry.
return Promise.reject();
}
}).catch(function() {
if (localStorage.optedInCheckCount < 5) {
localStorage.optedInCheckCount++;
optInRecheckAttempts.scheduleRetry();
} else {
clearPollingState();
}
});
}
instrumented.runtime.onInstalled.addListener(function(details) {
console.log('onInstalled ' + JSON.stringify(details));
if (details.reason != 'chrome_update') {
initialize();
}
});
instrumented.runtime.onStartup.addListener(function() {
console.log('onStartup');
// Show notifications received by earlier polls. Doing this as early as
// possible to reduce latency of showing first notifications. This mimics how
// persistent notifications will work.
tasks.add(SHOW_ON_START_TASK_NAME, function() {
fillFromChromeLocalStorage({
/** @type {Object.<string, StoredNotificationGroup>} */
notificationGroups: {}
}).then(function(items) {
console.log('onStartup-get ' + JSON.stringify(items));
showNotificationGroups(items.notificationGroups).then(function() {
chrome.storage.local.set(items);
});
});
});
initialize();
});
authenticationManager.addListener(function() {
console.log('signIn State Change');
onStateChange();
});
instrumented.notifications.onClicked.addListener(
function(chromeNotificationId) {
chrome.metricsPrivate.recordUserAction('GoogleNow.MessageClicked');
onNotificationClicked(chromeNotificationId,
function(notificationDataEntry) {
var actionUrls = notificationDataEntry.actionUrls;
var url = actionUrls && actionUrls.messageUrl;
if (url) {
recordNotificationClick(notificationDataEntry.cardTypeId);
}
return url;
});
});
instrumented.notifications.onButtonClicked.addListener(
function(chromeNotificationId, buttonIndex) {
chrome.metricsPrivate.recordUserAction(
'GoogleNow.ButtonClicked' + buttonIndex);
onNotificationClicked(chromeNotificationId,
function(notificationDataEntry) {
var actionUrls = notificationDataEntry.actionUrls;
var url = actionUrls.buttonUrls[buttonIndex];
if (url) {
recordButtonClick(notificationDataEntry.cardTypeId, buttonIndex);
} else {
verify(false, 'onButtonClicked: no url for a button');
console.log(
'buttonIndex=' + buttonIndex + ' ' +
'chromeNotificationId=' + chromeNotificationId + ' ' +
'notificationDataEntry=' +
JSON.stringify(notificationDataEntry));
}
return url;
});
});
instrumented.notifications.onClosed.addListener(onNotificationClosed);
instrumented.notifications.onPermissionLevelChanged.addListener(
function(permissionLevel) {
console.log('Notifications permissionLevel Change');
onStateChange();
});
instrumented.notifications.onShowSettings.addListener(function() {
openUrl(SETTINGS_URL);
});
// Handles state change notifications for the Google Now enabled bit.
instrumented.storage.onChanged.addListener(function(changes, areaName) {
if (areaName === 'local') {
if ('googleNowEnabled' in changes) {
onStateChange();
}
}
});
instrumented.pushMessaging.onMessage.addListener(function(message) {
// message.payload will be '' when the extension first starts.
// Each time after signing in, we'll get latest payload for all channels.
// So, we need to poll the server only when the payload is non-empty and has
// changed.
console.log('pushMessaging.onMessage ' + JSON.stringify(message));
if (message.payload.indexOf('REQUEST_CARDS') == 0) {
tasks.add(ON_PUSH_MESSAGE_START_TASK_NAME, function() {
// Accept promise rejection on failure since it's safer to do nothing,
// preventing polling the server when the payload really didn't change.
fillFromChromeLocalStorage({
lastPollNowPayloads: {},
/** @type {Object.<string, StoredNotificationGroup>} */
notificationGroups: {}
}, PromiseRejection.ALLOW).then(function(items) {
if (items.lastPollNowPayloads[message.subchannelId] !=
message.payload) {
items.lastPollNowPayloads[message.subchannelId] = message.payload;
items.notificationGroups['PUSH' + message.subchannelId] = {
cards: [],
nextPollTime: Date.now()
};
chrome.storage.local.set({
lastPollNowPayloads: items.lastPollNowPayloads,
notificationGroups: items.notificationGroups
});
pollOptedInWithRecheck();
}
});
});
}
});