// 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. Obtaining the location of the machine; * 2. Processing requests for cards dismissals that are not yet sent to the * server; * 3. Making a server request based on that location; * 4. Showing the received cards as notifications. */ // TODO(vadimt): Decide what to do in incognito mode. // TODO(vadimt): Figure out the final values of the constants. // TODO(vadimt): Remove 'console' calls. /** * 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 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 location cards that need an explanatory link. */ var LOCATION_CARDS_LINK_THRESHOLD = 10; /** * 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'; var LOCATION_WATCH_NAME = 'location-watch'; /** * 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('location.onLocationUpdate.addListener', 0); 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( 'preferencesPrivate.googleGeolocationAccessEnabled.get', 1); wrapper.instrumentChromeApiFunction( 'preferencesPrivate.googleGeolocationAccessEnabled.onChange.addListener', 0); wrapper.instrumentChromeApiFunction('permissions.contains', 1); wrapper.instrumentChromeApiFunction('pushMessaging.onMessage.addListener', 0); wrapper.instrumentChromeApiFunction('runtime.onInstalled.addListener', 0); wrapper.instrumentChromeApiFunction('runtime.onStartup.addListener', 0); wrapper.instrumentChromeApiFunction('tabs.create', 1); wrapper.instrumentChromeApiFunction('storage.local.get', 1); var updateCardsAttempts = buildAttemptManager( 'cards-update', requestLocation, INITIAL_POLLING_PERIOD_SECONDS, MAXIMUM_POLLING_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, LOCATION_UPDATE: 6, EXTENSION_START: 7, DELETED_SHOW_WELCOME_TOAST: 8, STOPPED: 9, DELETED_USER_SUPPRESSED: 10, EVENTS_TOTAL: 11 // 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); } /** * Adds authorization behavior to the request. * @param {XMLHttpRequest} request Server request. * @param {function(boolean)} callbackBoolean Completion callback with 'success' * parameter. */ function setAuthorization(request, callbackBoolean) { authenticationManager.getAuthToken(function(token) { if (!token) { callbackBoolean(false); return; } request.setRequestHeader('Authorization', 'Bearer ' + token); // Instrument onloadend to remove stale auth tokens. var originalOnLoadEnd = request.onloadend; request.onloadend = wrapper.wrapCallback(function(event) { if (request.status == HTTP_FORBIDDEN || request.status == HTTP_UNAUTHORIZED) { authenticationManager.removeToken(token, function() { originalOnLoadEnd(event); }); } else { originalOnLoadEnd(event); } }); callbackBoolean(true); }); } /** * Shows parsed and combined cards as notifications. * @param {Object.<string, StoredNotificationGroup>} notificationGroups Map from * group name to group information. * @param {Object.<ChromeNotificationId, CombinedCard>} cards Map from * chromeNotificationId to the combined card, containing cards to show. * @param {function()} onSuccess Called on success. * @param {function(ReceivedNotification)=} onCardShown Optional parameter * called when each card is shown. */ function showNotificationCards( notificationGroups, cards, onSuccess, onCardShown) { console.log('showNotificationCards ' + JSON.stringify(cards)); instrumented.notifications.getAll(function(notifications) { console.log('showNotificationCards-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.<string, NotificationDataEntry>} */ var notificationsData = {}; // Create/update/delete notifications. for (var chromeNotificationId in cards) { notificationsData[chromeNotificationId] = cardSet.update( chromeNotificationId, cards[chromeNotificationId], notificationGroups, onCardShown); } chrome.storage.local.set({notificationsData: notificationsData}); onSuccess(); }); } /** * Removes all cards and card state on Google Now close down. * For example, this occurs when the geolocation preference is unchecked in the * content settings. */ 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; } } /** * Schedules next cards poll. * @param {Object.<string, StoredNotificationGroup>} groups Map from group name * to group information. * @param {boolean} isOptedIn True if the user is opted in to Google Now. */ function scheduleNextPoll(groups, isOptedIn) { if (isOptedIn) { 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, 'scheduleNextPoll: nextPollTime is null'); var nextPollDelaySeconds = Math.max( (nextPollTime - Date.now()) / MS_IN_SECOND, MINIMUM_POLLING_PERIOD_SECONDS); updateCardsAttempts.start(nextPollDelaySeconds); } else { instrumented.metricsPrivate.getVariationParams( 'GoogleNow', function(params) { var optinPollPeriodSeconds = parseInt(params && params.optinPollPeriodSeconds, 10) || DEFAULT_OPTIN_CHECK_PERIOD_SECONDS; updateCardsAttempts.start(optinPollPeriodSeconds); }); } } /** * Combines notification groups into a set of Chrome notifications and shows * them. * @param {Object.<string, StoredNotificationGroup>} notificationGroups Map from * group name to group information. * @param {function()} onSuccess Called on success. * @param {function(ReceivedNotification)=} onCardShown Optional parameter * called when each card is shown. */ function combineAndShowNotificationCards( notificationGroups, onSuccess, onCardShown) { console.log('combineAndShowNotificationCards ' + JSON.stringify(notificationGroups)); /** @type {Object.<ChromeNotificationId, CombinedCard>} */ var combinedCards = {}; for (var groupName in notificationGroups) combineGroup(combinedCards, notificationGroups[groupName]); showNotificationCards( notificationGroups, combinedCards, onSuccess, onCardShown); } /** * Parses JSON response from the notification server, shows notifications and * schedules next update. * @param {string} response Server response. * @param {function(ReceivedNotification)=} onCardShown Optional parameter * called when each card is shown. */ function parseAndShowNotificationCards(response, onCardShown) { console.log('parseAndShowNotificationCards ' + response); /** @type {ServerResponse} */ var parsedResponse = JSON.parse(response); if (parsedResponse.googleNowDisabled) { chrome.storage.local.set({googleNowEnabled: false}); // TODO(vadimt): Remove the line below once the server stops sending groups // with 'googleNowDisabled' responses. parsedResponse.groups = {}; // Google Now was enabled; now it's disabled. This is a state change. onStateChange(); } var receivedGroups = parsedResponse.groups; instrumented.storage.local.get( ['notificationGroups', 'recentDismissals'], function(items) { console.log( 'parseAndShowNotificationCards-get ' + JSON.stringify(items)); items = items || {}; /** @type {Object.<string, StoredNotificationGroup>} */ items.notificationGroups = items.notificationGroups || {}; /** @type {Object.<NotificationId, number>} */ items.recentDismissals = items.recentDismissals || {}; // Build a set of non-expired recent dismissals. It will be used for // client-side filtering of cards. /** @type {Object.<NotificationId, number>} */ var updatedRecentDismissals = {}; var now = Date.now(); for (var notificationId in items.recentDismissals) { var dismissalAge = now - items.recentDismissals[notificationId]; if (dismissalAge < DISMISS_RETENTION_TIME_MS) { updatedRecentDismissals[notificationId] = items.recentDismissals[notificationId]; } } // Populate groups with corresponding cards. if (parsedResponse.notifications) { for (var i = 0; i < parsedResponse.notifications.length; ++i) { /** @type {ReceivedNotification} */ var card = parsedResponse.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; } scheduleNextPoll(updatedGroups, !parsedResponse.googleNowDisabled); combineAndShowNotificationCards( updatedGroups, function() { chrome.storage.local.set({ notificationGroups: updatedGroups, recentDismissals: updatedRecentDismissals }); recordEvent(GoogleNowEvent.CARDS_PARSE_SUCCESS); }, onCardShown); }); } /** * Update Location Cards Shown Count. * @param {ReceivedNotification} receivedNotification Notification as it was * received from the server. */ function countLocationCard(receivedNotification) { if (receivedNotification.locationBased) { localStorage['locationCardsShown']++; } } /** * Requests notification cards from the server for specified groups. * @param {Array.<string>} groupNames Names of groups that need to be refreshed. */ function requestNotificationGroups(groupNames) { console.log('requestNotificationGroups from ' + NOTIFICATION_CARDS_URL + ', groupNames=' + JSON.stringify(groupNames)); recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_TOTAL); var requestParameters = '?timeZoneOffsetMs=' + (-new Date().getTimezoneOffset() * MS_IN_MINUTE); var cardShownCallback = undefined; if (localStorage['locationCardsShown'] < LOCATION_CARDS_LINK_THRESHOLD) { requestParameters += '&locationExplanation=true'; cardShownCallback = countLocationCard; } groupNames.forEach(function(groupName) { requestParameters += ('&requestTypes=' + groupName); }); console.log('requestNotificationGroups: request=' + requestParameters); var request = buildServerRequest('GET', 'notifications' + requestParameters); request.onloadend = function(event) { console.log('requestNotificationGroups-onloadend ' + request.status); if (request.status == HTTP_OK) { recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_SUCCESS); parseAndShowNotificationCards(request.responseText, cardShownCallback); } }; setAuthorization(request, function(success) { if (success) request.send(); }); } /** * Requests the account opted-in state from the server. * @param {function()} optedInCallback Function that will be called if * opted-in state is 'true'. */ function requestOptedIn(optedInCallback) { console.log('requestOptedIn from ' + NOTIFICATION_CARDS_URL); var request = buildServerRequest('GET', 'settings/optin'); request.onloadend = function(event) { console.log( 'requestOptedIn-onloadend ' + request.status + ' ' + request.response); if (request.status == HTTP_OK) { var parsedResponse = JSON.parse(request.responseText); if (parsedResponse.value) { chrome.storage.local.set({googleNowEnabled: true}); optedInCallback(); // Google Now was disabled, now it's enabled. This is a state change. onStateChange(); } else { scheduleNextPoll({}, false); } } }; setAuthorization(request, function(success) { if (success) request.send(); }); } /** * Requests notification cards from the server. * @param {Location=} position Location of this computer. */ function requestNotificationCards(position) { console.log('requestNotificationCards ' + JSON.stringify(position)); instrumented.storage.local.get( ['notificationGroups', 'googleNowEnabled'], function(items) { console.log('requestNotificationCards-storage-get ' + JSON.stringify(items)); items = items || {}; /** @type {Object.<string, StoredNotificationGroup>} */ items.notificationGroups = items.notificationGroups || {}; 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); } if (items.googleNowEnabled) { requestNotificationGroups(groupsToRequest); } else { requestOptedIn(function() { requestNotificationGroups(groupsToRequest); }); } }); } /** * Starts getting location for a cards update. */ function requestLocation() { console.log('requestLocation'); recordEvent(GoogleNowEvent.LOCATION_REQUEST); // TODO(vadimt): Figure out location request options. instrumented.metricsPrivate.getVariationParams('GoogleNow', function(params) { var minDistanceInMeters = parseInt(params && params.minDistanceInMeters, 10) || 100; var minTimeInMilliseconds = parseInt(params && params.minTimeInMilliseconds, 10) || 180000; // 3 minutes. // TODO(vadimt): Uncomment/remove watchLocation and remove invoking // updateNotificationsCards once state machine design is finalized. // chrome.location.watchLocation(LOCATION_WATCH_NAME, { // minDistanceInMeters: minDistanceInMeters, // minTimeInMilliseconds: minTimeInMilliseconds // }); // We need setTimeout to avoid recursive task creation. This is a temporary // code, and it will be removed once we finally decide to send or not send // client location to the server. setTimeout(wrapper.wrapCallback(updateNotificationsCards, true), 0); }); } /** * Stops getting the location. */ function stopRequestLocation() { console.log('stopRequestLocation'); chrome.location.clearWatch(LOCATION_WATCH_NAME); } /** * Obtains new location; requests and shows notification cards based on this * location. * @param {Location=} position Location of this computer. */ function updateNotificationsCards(position) { console.log('updateNotificationsCards ' + JSON.stringify(position) + ' @' + new Date()); tasks.add(UPDATE_CARDS_TASK_NAME, function() { console.log('updateNotificationsCards-task-begin'); updateCardsAttempts.isRunning(function(running) { if (running) { updateCardsAttempts.planForNext(function() { processPendingDismissals(function(success) { if (success) { // The cards are requested only if there are no unsent dismissals. requestNotificationCards(position); } }); }); } }); }); } /** * 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. * @param {function(boolean)} callbackBoolean Completion callback with 'done' * parameter. */ function requestCardDismissal( chromeNotificationId, dismissalTimeMs, dismissalData, callbackBoolean) { console.log('requestDismissingCard ' + chromeNotificationId + ' from ' + NOTIFICATION_CARDS_URL + ', dismissalData=' + JSON.stringify(dismissalData)); var dismissalAge = Date.now() - dismissalTimeMs; if (dismissalAge > MAXIMUM_DISMISSAL_AGE_MS) { callbackBoolean(true); return; } 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); var request = buildServerRequest('DELETE', requestParameters); request.onloadend = function(event) { 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. var done = request.status == HTTP_NOCONTENT || request.status == HTTP_BAD_REQUEST || request.status == HTTP_METHOD_NOT_ALLOWED; callbackBoolean(done); }; setAuthorization(request, function(success) { if (success) request.send(); else callbackBoolean(false); }); } /** * Tries to send dismiss requests for all pending dismissals. * @param {function(boolean)} callbackBoolean Completion callback with 'success' * parameter. Success means that no pending dismissals are left. */ function processPendingDismissals(callbackBoolean) { instrumented.storage.local.get(['pendingDismissals', 'recentDismissals'], function(items) { console.log('processPendingDismissals-storage-get ' + JSON.stringify(items)); items = items || {}; /** @type {Array.<PendingDismissal>} */ items.pendingDismissals = items.pendingDismissals || []; /** @type {Object.<NotificationId, number>} */ items.recentDismissals = items.recentDismissals || {}; var dismissalsChanged = false; function onFinish(success) { if (dismissalsChanged) { chrome.storage.local.set({ pendingDismissals: items.pendingDismissals, recentDismissals: items.recentDismissals }); } callbackBoolean(success); } function doProcessDismissals() { if (items.pendingDismissals.length == 0) { dismissalAttempts.stop(); onFinish(true); return; } // Send dismissal for the first card, and if successful, repeat // recursively with the rest. /** @type {PendingDismissal} */ var dismissal = items.pendingDismissals[0]; requestCardDismissal( dismissal.chromeNotificationId, dismissal.time, dismissal.dismissalData, function(done) { if (done) { dismissalsChanged = true; items.pendingDismissals.splice(0, 1); items.recentDismissals[ dismissal.dismissalData.notificationId] = Date.now(); doProcessDismissals(); } else { onFinish(false); } }); } doProcessDismissals(); }); } /** * Submits a task to send pending dismissals. */ function retryPendingDismissals() { tasks.add(RETRY_DISMISS_TASK_NAME, function() { dismissalAttempts.planForNext(function() { processPendingDismissals(function(success) {}); }); }); } /** * 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((ActionUrls|undefined)): (string|undefined)} selector * Function that extracts the url for the clicked area from the button * action URLs info. */ function onNotificationClicked(chromeNotificationId, selector) { instrumented.storage.local.get('notificationsData', function(items) { /** @type {(NotificationDataEntry|undefined)} */ var notificationData = items && items.notificationsData && items.notificationsData[chromeNotificationId]; if (!notificationData) return; var url = selector(notificationData.actionUrls); 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(); instrumented.storage.local.get( ['pendingDismissals', 'notificationsData', 'notificationGroups'], function(items) { items = items || {}; /** @type {Array.<PendingDismissal>} */ items.pendingDismissals = items.pendingDismissals || []; /** @type {Object.<string, NotificationDataEntry>} */ items.notificationsData = items.notificationsData || {}; /** @type {Object.<string, StoredNotificationGroup>} */ items.notificationGroups = items.notificationGroups || {}; /** @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(function(success) {}); }); }); } /** * Initializes the polling system to start monitoring location and fetching * cards. */ function startPollingCards() { // Create an update timer for a case when for some reason location request // gets stuck. updateCardsAttempts.start(MAXIMUM_POLLING_PERIOD_SECONDS); requestLocation(); } /** * Stops all machinery in the polling system. */ function stopPollingCards() { stopRequestLocation(); updateCardsAttempts.stop(); removeAllCards(); // Mark the Google Now as disabled to start with checking the opt-in state // next time startPollingCards() is called. chrome.storage.local.set({googleNowEnabled: false}); } /** * Initializes the event page on install or on browser startup. */ function initialize() { recordEvent(GoogleNowEvent.EXTENSION_START); onStateChange(); } /** * Starts or stops the polling of 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); } }); } /** * 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); } }); } /** * 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} geolocationEnabled true if * the geolocation option is enabled. * @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, geolocationEnabled, canEnableBackground, notificationEnabled, googleNowEnabled) { console.log( 'State Update signedIn=' + signedIn + ' ' + 'geolocationEnabled=' + geolocationEnabled + ' ' + 'canEnableBackground=' + canEnableBackground + ' ' + 'notificationEnabled=' + notificationEnabled + ' ' + 'googleNowEnabled=' + googleNowEnabled); // TODO(vadimt): Remove this line once state machine design is finalized. geolocationEnabled = true; var shouldPollCards = false; var shouldSetBackground = false; if (signedIn && notificationEnabled) { if (geolocationEnabled) { if (canEnableBackground && googleNowEnabled) shouldSetBackground = true; shouldPollCards = true; } } else { recordEvent(GoogleNowEvent.STOPPED); } console.log( 'Requested Actions shouldSetBackground=' + shouldSetBackground + ' ' + 'setShouldPollCards=' + shouldPollCards); setBackgroundEnable(shouldSetBackground); setShouldPollCards(shouldPollCards); } /** * Coordinates the behavior of Google Now for Chrome depending on * Chrome and extension state. */ function onStateChange() { tasks.add(STATE_CHANGED_TASK_NAME, function() { authenticationManager.isSignedIn(function(signedIn) { instrumented.metricsPrivate.getVariationParams( 'GoogleNow', function(response) { var canEnableBackground = (!response || (response.canEnableBackground != 'false')); instrumented.notifications.getPermissionLevel(function(level) { var notificationEnabled = (level == 'granted'); instrumented. preferencesPrivate. googleGeolocationAccessEnabled. get({}, function(prefValue) { var geolocationEnabled = !!prefValue.value; instrumented.storage.local.get( 'googleNowEnabled', function(items) { var googleNowEnabled = items && !!items.googleNowEnabled; updateRunningState( signedIn, geolocationEnabled, canEnableBackground, notificationEnabled, googleNowEnabled); }); }); }); }); }); }); } 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() { instrumented.storage.local.get('notificationGroups', function(items) { console.log('onStartup-get ' + JSON.stringify(items)); items = items || {}; /** @type {Object.<string, StoredNotificationGroup>} */ items.notificationGroups = items.notificationGroups || {}; combineAndShowNotificationCards(items.notificationGroups, function() { chrome.storage.local.set(items); }); }); }); initialize(); }); instrumented. preferencesPrivate. googleGeolocationAccessEnabled. onChange. addListener(function(prefValue) { console.log('googleGeolocationAccessEnabled Pref onChange ' + prefValue.value); onStateChange(); }); authenticationManager.addListener(function() { console.log('signIn State Change'); onStateChange(); }); instrumented.notifications.onClicked.addListener( function(chromeNotificationId) { chrome.metricsPrivate.recordUserAction('GoogleNow.MessageClicked'); onNotificationClicked(chromeNotificationId, function(actionUrls) { return actionUrls && actionUrls.messageUrl; }); }); instrumented.notifications.onButtonClicked.addListener( function(chromeNotificationId, buttonIndex) { chrome.metricsPrivate.recordUserAction( 'GoogleNow.ButtonClicked' + buttonIndex); onNotificationClicked(chromeNotificationId, function(actionUrls) { var url = actionUrls.buttonUrls[buttonIndex]; verify(url !== undefined, 'onButtonClicked: no url for a button'); 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); }); instrumented.location.onLocationUpdate.addListener(function(position) { recordEvent(GoogleNowEvent.LOCATION_UPDATE); updateNotificationsCards(position); }); 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() { instrumented.storage.local.get( ['lastPollNowPayloads', 'notificationGroups'], function(items) { // If storage.get fails, it's safer to do nothing, preventing polling // the server when the payload really didn't change. if (!items) return; // If this is the first time we get lastPollNowPayloads, initialize it. items.lastPollNowPayloads = items.lastPollNowPayloads || {}; if (items.lastPollNowPayloads[message.subchannelId] != message.payload) { items.lastPollNowPayloads[message.subchannelId] = message.payload; /** @type {Object.<string, StoredNotificationGroup>} */ items.notificationGroups = items.notificationGroups || {}; items.notificationGroups['PUSH' + message.subchannelId] = { cards: [], nextPollTime: Date.now() }; chrome.storage.local.set({ lastPollNowPayloads: items.lastPollNowPayloads, notificationGroups: items.notificationGroups }); updateNotificationsCards(); } }); }); } });