| // 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(); |
| } |
| }); |
| }); |
| } |
| }); |