// 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., * notifications: Array. * }} */ 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., * 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.} 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.} */ 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.} */ 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.} 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.} 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.} 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.} notificationGroups Map from * group name to group information. * @return {Object.} Cards to show. */ function combineCardsFromGroups(notificationGroups) { console.log('combineCardsFromGroups ' + JSON.stringify(notificationGroups)); /** @type {Object.} */ 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.} */ notificationGroups: {}, /** @type {Object.} */ 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.} */ 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.} 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.} */ 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.} */ pendingDismissals: [], /** @type {Object.} */ 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.} */ 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.} */ pendingDismissals: [], /** @type {Object.} */ notificationsData: {}, /** @type {Object.} */ 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.} */ 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.} */ 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(); } }); }); } });