Home | History | Annotate | Download | only in google_now
      1 // Copyright (c) 2013 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 'use strict';
      6 
      7 /**
      8  * @fileoverview The event page for Google Now for Chrome implementation.
      9  * The Google Now event page gets Google Now cards from the server and shows
     10  * them as Chrome notifications.
     11  * The service performs periodic updating of Google Now cards.
     12  * Each updating of the cards includes 4 steps:
     13  * 1. Obtaining the location of the machine;
     14  * 2. Processing requests for cards dismissals that are not yet sent to the
     15  *    server;
     16  * 3. Making a server request based on that location;
     17  * 4. Showing the received cards as notifications.
     18  */
     19 
     20 // TODO(vadimt): Decide what to do in incognito mode.
     21 // TODO(vadimt): Figure out the final values of the constants.
     22 // TODO(vadimt): Remove 'console' calls.
     23 
     24 /**
     25  * Standard response code for successful HTTP requests. This is the only success
     26  * code the server will send.
     27  */
     28 var HTTP_OK = 200;
     29 var HTTP_NOCONTENT = 204;
     30 
     31 var HTTP_BAD_REQUEST = 400;
     32 var HTTP_UNAUTHORIZED = 401;
     33 var HTTP_FORBIDDEN = 403;
     34 var HTTP_METHOD_NOT_ALLOWED = 405;
     35 
     36 var MS_IN_SECOND = 1000;
     37 var MS_IN_MINUTE = 60 * 1000;
     38 
     39 /**
     40  * Initial period for polling for Google Now Notifications cards to use when the
     41  * period from the server is not available.
     42  */
     43 var INITIAL_POLLING_PERIOD_SECONDS = 5 * 60;  // 5 minutes
     44 
     45 /**
     46  * Mininal period for polling for Google Now Notifications cards.
     47  */
     48 var MINIMUM_POLLING_PERIOD_SECONDS = 5 * 60;  // 5 minutes
     49 
     50 /**
     51  * Maximal period for polling for Google Now Notifications cards to use when the
     52  * period from the server is not available.
     53  */
     54 var MAXIMUM_POLLING_PERIOD_SECONDS = 60 * 60;  // 1 hour
     55 
     56 /**
     57  * Initial period for retrying the server request for dismissing cards.
     58  */
     59 var INITIAL_RETRY_DISMISS_PERIOD_SECONDS = 60;  // 1 minute
     60 
     61 /**
     62  * Maximum period for retrying the server request for dismissing cards.
     63  */
     64 var MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS = 60 * 60;  // 1 hour
     65 
     66 /**
     67  * Time we keep retrying dismissals.
     68  */
     69 var MAXIMUM_DISMISSAL_AGE_MS = 24 * 60 * 60 * 1000; // 1 day
     70 
     71 /**
     72  * Time we keep dismissals after successful server dismiss requests.
     73  */
     74 var DISMISS_RETENTION_TIME_MS = 20 * 60 * 1000;  // 20 minutes
     75 
     76 /**
     77  * Default period for checking whether the user is opted in to Google Now.
     78  */
     79 var DEFAULT_OPTIN_CHECK_PERIOD_SECONDS = 60 * 60 * 24 * 7; // 1 week
     80 
     81 /**
     82  * URL to open when the user clicked on a link for the our notification
     83  * settings.
     84  */
     85 var SETTINGS_URL = 'https://support.google.com/chrome/?p=ib_google_now_welcome';
     86 
     87 /**
     88  * Number of location cards that need an explanatory link.
     89  */
     90 var LOCATION_CARDS_LINK_THRESHOLD = 10;
     91 
     92 /**
     93  * Names for tasks that can be created by the extension.
     94  */
     95 var UPDATE_CARDS_TASK_NAME = 'update-cards';
     96 var DISMISS_CARD_TASK_NAME = 'dismiss-card';
     97 var RETRY_DISMISS_TASK_NAME = 'retry-dismiss';
     98 var STATE_CHANGED_TASK_NAME = 'state-changed';
     99 var SHOW_ON_START_TASK_NAME = 'show-cards-on-start';
    100 var ON_PUSH_MESSAGE_START_TASK_NAME = 'on-push-message';
    101 
    102 var LOCATION_WATCH_NAME = 'location-watch';
    103 
    104 /**
    105  * Group as received from the server.
    106  *
    107  * @typedef {{
    108  *   nextPollSeconds: (string|undefined),
    109  *   rank: (number|undefined),
    110  *   requested: (boolean|undefined)
    111  * }}
    112  */
    113 var ReceivedGroup;
    114 
    115 /**
    116  * Server response with notifications and groups.
    117  *
    118  * @typedef {{
    119  *   googleNowDisabled: (boolean|undefined),
    120  *   groups: Object.<string, ReceivedGroup>,
    121  *   notifications: Array.<ReceivedNotification>
    122  * }}
    123  */
    124 var ServerResponse;
    125 
    126 /**
    127  * Notification group as the client stores it. |cardsTimestamp| and |rank| are
    128  * defined if |cards| is non-empty. |nextPollTime| is undefined if the server
    129  * (1) never sent 'nextPollSeconds' for the group or
    130  * (2) didn't send 'nextPollSeconds' with the last group update containing a
    131  *     cards update and all the times after that.
    132  *
    133  * @typedef {{
    134  *   cards: Array.<ReceivedNotification>,
    135  *   cardsTimestamp: (number|undefined),
    136  *   nextPollTime: (number|undefined),
    137  *   rank: (number|undefined)
    138  * }}
    139  */
    140 var StoredNotificationGroup;
    141 
    142 /**
    143  * Pending (not yet successfully sent) dismissal for a received notification.
    144  * |time| is the moment when the user requested dismissal.
    145  *
    146  * @typedef {{
    147  *   chromeNotificationId: ChromeNotificationId,
    148  *   time: number,
    149  *   dismissalData: DismissalData
    150  * }}
    151  */
    152 var PendingDismissal;
    153 
    154 /**
    155  * Checks if a new task can't be scheduled when another task is already
    156  * scheduled.
    157  * @param {string} newTaskName Name of the new task.
    158  * @param {string} scheduledTaskName Name of the scheduled task.
    159  * @return {boolean} Whether the new task conflicts with the existing task.
    160  */
    161 function areTasksConflicting(newTaskName, scheduledTaskName) {
    162   if (newTaskName == UPDATE_CARDS_TASK_NAME &&
    163       scheduledTaskName == UPDATE_CARDS_TASK_NAME) {
    164     // If a card update is requested while an old update is still scheduled, we
    165     // don't need the new update.
    166     return true;
    167   }
    168 
    169   if (newTaskName == RETRY_DISMISS_TASK_NAME &&
    170       (scheduledTaskName == UPDATE_CARDS_TASK_NAME ||
    171        scheduledTaskName == DISMISS_CARD_TASK_NAME ||
    172        scheduledTaskName == RETRY_DISMISS_TASK_NAME)) {
    173     // No need to schedule retry-dismiss action if another action that tries to
    174     // send dismissals is scheduled.
    175     return true;
    176   }
    177 
    178   return false;
    179 }
    180 
    181 var tasks = buildTaskManager(areTasksConflicting);
    182 
    183 // Add error processing to API calls.
    184 wrapper.instrumentChromeApiFunction('location.onLocationUpdate.addListener', 0);
    185 wrapper.instrumentChromeApiFunction('metricsPrivate.getVariationParams', 1);
    186 wrapper.instrumentChromeApiFunction('notifications.clear', 1);
    187 wrapper.instrumentChromeApiFunction('notifications.create', 2);
    188 wrapper.instrumentChromeApiFunction('notifications.getPermissionLevel', 0);
    189 wrapper.instrumentChromeApiFunction('notifications.update', 2);
    190 wrapper.instrumentChromeApiFunction('notifications.getAll', 0);
    191 wrapper.instrumentChromeApiFunction(
    192     'notifications.onButtonClicked.addListener', 0);
    193 wrapper.instrumentChromeApiFunction('notifications.onClicked.addListener', 0);
    194 wrapper.instrumentChromeApiFunction('notifications.onClosed.addListener', 0);
    195 wrapper.instrumentChromeApiFunction(
    196     'notifications.onPermissionLevelChanged.addListener', 0);
    197 wrapper.instrumentChromeApiFunction(
    198     'notifications.onShowSettings.addListener', 0);
    199 wrapper.instrumentChromeApiFunction(
    200     'preferencesPrivate.googleGeolocationAccessEnabled.get',
    201     1);
    202 wrapper.instrumentChromeApiFunction(
    203     'preferencesPrivate.googleGeolocationAccessEnabled.onChange.addListener',
    204     0);
    205 wrapper.instrumentChromeApiFunction('permissions.contains', 1);
    206 wrapper.instrumentChromeApiFunction('pushMessaging.onMessage.addListener', 0);
    207 wrapper.instrumentChromeApiFunction('runtime.onInstalled.addListener', 0);
    208 wrapper.instrumentChromeApiFunction('runtime.onStartup.addListener', 0);
    209 wrapper.instrumentChromeApiFunction('tabs.create', 1);
    210 wrapper.instrumentChromeApiFunction('storage.local.get', 1);
    211 
    212 var updateCardsAttempts = buildAttemptManager(
    213     'cards-update',
    214     requestLocation,
    215     INITIAL_POLLING_PERIOD_SECONDS,
    216     MAXIMUM_POLLING_PERIOD_SECONDS);
    217 var dismissalAttempts = buildAttemptManager(
    218     'dismiss',
    219     retryPendingDismissals,
    220     INITIAL_RETRY_DISMISS_PERIOD_SECONDS,
    221     MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS);
    222 var cardSet = buildCardSet();
    223 
    224 var authenticationManager = buildAuthenticationManager();
    225 
    226 /**
    227  * Google Now UMA event identifier.
    228  * @enum {number}
    229  */
    230 var GoogleNowEvent = {
    231   REQUEST_FOR_CARDS_TOTAL: 0,
    232   REQUEST_FOR_CARDS_SUCCESS: 1,
    233   CARDS_PARSE_SUCCESS: 2,
    234   DISMISS_REQUEST_TOTAL: 3,
    235   DISMISS_REQUEST_SUCCESS: 4,
    236   LOCATION_REQUEST: 5,
    237   LOCATION_UPDATE: 6,
    238   EXTENSION_START: 7,
    239   DELETED_SHOW_WELCOME_TOAST: 8,
    240   STOPPED: 9,
    241   DELETED_USER_SUPPRESSED: 10,
    242   EVENTS_TOTAL: 11  // EVENTS_TOTAL is not an event; all new events need to be
    243                     // added before it.
    244 };
    245 
    246 /**
    247  * Records a Google Now Event.
    248  * @param {GoogleNowEvent} event Event identifier.
    249  */
    250 function recordEvent(event) {
    251   var metricDescription = {
    252     metricName: 'GoogleNow.Event',
    253     type: 'histogram-linear',
    254     min: 1,
    255     max: GoogleNowEvent.EVENTS_TOTAL,
    256     buckets: GoogleNowEvent.EVENTS_TOTAL + 1
    257   };
    258 
    259   chrome.metricsPrivate.recordValue(metricDescription, event);
    260 }
    261 
    262 /**
    263  * Adds authorization behavior to the request.
    264  * @param {XMLHttpRequest} request Server request.
    265  * @param {function(boolean)} callbackBoolean Completion callback with 'success'
    266  *     parameter.
    267  */
    268 function setAuthorization(request, callbackBoolean) {
    269   authenticationManager.getAuthToken(function(token) {
    270     if (!token) {
    271       callbackBoolean(false);
    272       return;
    273     }
    274 
    275     request.setRequestHeader('Authorization', 'Bearer ' + token);
    276 
    277     // Instrument onloadend to remove stale auth tokens.
    278     var originalOnLoadEnd = request.onloadend;
    279     request.onloadend = wrapper.wrapCallback(function(event) {
    280       if (request.status == HTTP_FORBIDDEN ||
    281           request.status == HTTP_UNAUTHORIZED) {
    282         authenticationManager.removeToken(token, function() {
    283           originalOnLoadEnd(event);
    284         });
    285       } else {
    286         originalOnLoadEnd(event);
    287       }
    288     });
    289 
    290     callbackBoolean(true);
    291   });
    292 }
    293 
    294 /**
    295  * Shows parsed and combined cards as notifications.
    296  * @param {Object.<string, StoredNotificationGroup>} notificationGroups Map from
    297  *     group name to group information.
    298  * @param {Object.<ChromeNotificationId, CombinedCard>} cards Map from
    299  *     chromeNotificationId to the combined card, containing cards to show.
    300  * @param {function()} onSuccess Called on success.
    301  * @param {function(ReceivedNotification)=} onCardShown Optional parameter
    302  *     called when each card is shown.
    303  */
    304 function showNotificationCards(
    305     notificationGroups, cards, onSuccess, onCardShown) {
    306   console.log('showNotificationCards ' + JSON.stringify(cards));
    307 
    308   instrumented.notifications.getAll(function(notifications) {
    309     console.log('showNotificationCards-getAll ' +
    310         JSON.stringify(notifications));
    311     notifications = notifications || {};
    312 
    313     // Mark notifications that didn't receive an update as having received
    314     // an empty update.
    315     for (var chromeNotificationId in notifications) {
    316       cards[chromeNotificationId] = cards[chromeNotificationId] || [];
    317     }
    318 
    319     /** @type {Object.<string, NotificationDataEntry>} */
    320     var notificationsData = {};
    321 
    322     // Create/update/delete notifications.
    323     for (var chromeNotificationId in cards) {
    324       notificationsData[chromeNotificationId] = cardSet.update(
    325           chromeNotificationId,
    326           cards[chromeNotificationId],
    327           notificationGroups,
    328           onCardShown);
    329     }
    330     chrome.storage.local.set({notificationsData: notificationsData});
    331     onSuccess();
    332   });
    333 }
    334 
    335 /**
    336  * Removes all cards and card state on Google Now close down.
    337  * For example, this occurs when the geolocation preference is unchecked in the
    338  * content settings.
    339  */
    340 function removeAllCards() {
    341   console.log('removeAllCards');
    342 
    343   // TODO(robliao): Once Google Now clears its own checkbox in the
    344   // notifications center and bug 260376 is fixed, the below clearing
    345   // code is no longer necessary.
    346   instrumented.notifications.getAll(function(notifications) {
    347     notifications = notifications || {};
    348     for (var chromeNotificationId in notifications) {
    349       instrumented.notifications.clear(chromeNotificationId, function() {});
    350     }
    351     chrome.storage.local.remove(['notificationsData', 'notificationGroups']);
    352   });
    353 }
    354 
    355 /**
    356  * Adds a card group into a set of combined cards.
    357  * @param {Object.<ChromeNotificationId, CombinedCard>} combinedCards Map from
    358  *     chromeNotificationId to a combined card.
    359  *     This is an input/output parameter.
    360  * @param {StoredNotificationGroup} storedGroup Group to combine into the
    361  *     combined card set.
    362  */
    363 function combineGroup(combinedCards, storedGroup) {
    364   for (var i = 0; i < storedGroup.cards.length; i++) {
    365     /** @type {ReceivedNotification} */
    366     var receivedNotification = storedGroup.cards[i];
    367 
    368     /** @type {UncombinedNotification} */
    369     var uncombinedNotification = {
    370       receivedNotification: receivedNotification,
    371       showTime: receivedNotification.trigger.showTimeSec &&
    372                 (storedGroup.cardsTimestamp +
    373                  receivedNotification.trigger.showTimeSec * MS_IN_SECOND),
    374       hideTime: storedGroup.cardsTimestamp +
    375                 receivedNotification.trigger.hideTimeSec * MS_IN_SECOND
    376     };
    377 
    378     var combinedCard =
    379         combinedCards[receivedNotification.chromeNotificationId] || [];
    380     combinedCard.push(uncombinedNotification);
    381     combinedCards[receivedNotification.chromeNotificationId] = combinedCard;
    382   }
    383 }
    384 
    385 /**
    386  * Schedules next cards poll.
    387  * @param {Object.<string, StoredNotificationGroup>} groups Map from group name
    388  *     to group information.
    389  * @param {boolean} isOptedIn True if the user is opted in to Google Now.
    390  */
    391 function scheduleNextPoll(groups, isOptedIn) {
    392   if (isOptedIn) {
    393     var nextPollTime = null;
    394 
    395     for (var groupName in groups) {
    396       var group = groups[groupName];
    397       if (group.nextPollTime !== undefined) {
    398         nextPollTime = nextPollTime == null ?
    399             group.nextPollTime : Math.min(group.nextPollTime, nextPollTime);
    400       }
    401     }
    402 
    403     // At least one of the groups must have nextPollTime.
    404     verify(nextPollTime != null, 'scheduleNextPoll: nextPollTime is null');
    405 
    406     var nextPollDelaySeconds = Math.max(
    407         (nextPollTime - Date.now()) / MS_IN_SECOND,
    408         MINIMUM_POLLING_PERIOD_SECONDS);
    409     updateCardsAttempts.start(nextPollDelaySeconds);
    410   } else {
    411     instrumented.metricsPrivate.getVariationParams(
    412         'GoogleNow', function(params) {
    413       var optinPollPeriodSeconds =
    414           parseInt(params && params.optinPollPeriodSeconds, 10) ||
    415           DEFAULT_OPTIN_CHECK_PERIOD_SECONDS;
    416       updateCardsAttempts.start(optinPollPeriodSeconds);
    417     });
    418   }
    419 }
    420 
    421 /**
    422  * Combines notification groups into a set of Chrome notifications and shows
    423  * them.
    424  * @param {Object.<string, StoredNotificationGroup>} notificationGroups Map from
    425  *     group name to group information.
    426  * @param {function()} onSuccess Called on success.
    427  * @param {function(ReceivedNotification)=} onCardShown Optional parameter
    428  *     called when each card is shown.
    429  */
    430 function combineAndShowNotificationCards(
    431     notificationGroups, onSuccess, onCardShown) {
    432   console.log('combineAndShowNotificationCards ' +
    433       JSON.stringify(notificationGroups));
    434   /** @type {Object.<ChromeNotificationId, CombinedCard>} */
    435   var combinedCards = {};
    436 
    437   for (var groupName in notificationGroups)
    438     combineGroup(combinedCards, notificationGroups[groupName]);
    439 
    440   showNotificationCards(
    441       notificationGroups, combinedCards, onSuccess, onCardShown);
    442 }
    443 
    444 /**
    445  * Parses JSON response from the notification server, shows notifications and
    446  * schedules next update.
    447  * @param {string} response Server response.
    448  * @param {function(ReceivedNotification)=} onCardShown Optional parameter
    449  *     called when each card is shown.
    450  */
    451 function parseAndShowNotificationCards(response, onCardShown) {
    452   console.log('parseAndShowNotificationCards ' + response);
    453   /** @type {ServerResponse} */
    454   var parsedResponse = JSON.parse(response);
    455 
    456   if (parsedResponse.googleNowDisabled) {
    457     chrome.storage.local.set({googleNowEnabled: false});
    458     // TODO(vadimt): Remove the line below once the server stops sending groups
    459     // with 'googleNowDisabled' responses.
    460     parsedResponse.groups = {};
    461     // Google Now was enabled; now it's disabled. This is a state change.
    462     onStateChange();
    463   }
    464 
    465   var receivedGroups = parsedResponse.groups;
    466 
    467   instrumented.storage.local.get(
    468       ['notificationGroups', 'recentDismissals'],
    469       function(items) {
    470         console.log(
    471             'parseAndShowNotificationCards-get ' + JSON.stringify(items));
    472         items = items || {};
    473         /** @type {Object.<string, StoredNotificationGroup>} */
    474         items.notificationGroups = items.notificationGroups || {};
    475         /** @type {Object.<NotificationId, number>} */
    476         items.recentDismissals = items.recentDismissals || {};
    477 
    478         // Build a set of non-expired recent dismissals. It will be used for
    479         // client-side filtering of cards.
    480         /** @type {Object.<NotificationId, number>} */
    481         var updatedRecentDismissals = {};
    482         var now = Date.now();
    483         for (var notificationId in items.recentDismissals) {
    484           var dismissalAge = now - items.recentDismissals[notificationId];
    485           if (dismissalAge < DISMISS_RETENTION_TIME_MS) {
    486             updatedRecentDismissals[notificationId] =
    487                 items.recentDismissals[notificationId];
    488           }
    489         }
    490 
    491         // Populate groups with corresponding cards.
    492         if (parsedResponse.notifications) {
    493           for (var i = 0; i < parsedResponse.notifications.length; ++i) {
    494             /** @type {ReceivedNotification} */
    495             var card = parsedResponse.notifications[i];
    496             if (!(card.notificationId in updatedRecentDismissals)) {
    497               var group = receivedGroups[card.groupName];
    498               group.cards = group.cards || [];
    499               group.cards.push(card);
    500             }
    501           }
    502         }
    503 
    504         // Build updated set of groups.
    505         var updatedGroups = {};
    506 
    507         for (var groupName in receivedGroups) {
    508           var receivedGroup = receivedGroups[groupName];
    509           var storedGroup = items.notificationGroups[groupName] || {
    510             cards: [],
    511             cardsTimestamp: undefined,
    512             nextPollTime: undefined,
    513             rank: undefined
    514           };
    515 
    516           if (receivedGroup.requested)
    517             receivedGroup.cards = receivedGroup.cards || [];
    518 
    519           if (receivedGroup.cards) {
    520             // If the group contains a cards update, all its fields will get new
    521             // values.
    522             storedGroup.cards = receivedGroup.cards;
    523             storedGroup.cardsTimestamp = now;
    524             storedGroup.rank = receivedGroup.rank;
    525             storedGroup.nextPollTime = undefined;
    526             // The code below assigns nextPollTime a defined value if
    527             // nextPollSeconds is specified in the received group.
    528             // If the group's cards are not updated, and nextPollSeconds is
    529             // unspecified, this method doesn't change group's nextPollTime.
    530           }
    531 
    532           // 'nextPollSeconds' may be sent even for groups that don't contain
    533           // cards updates.
    534           if (receivedGroup.nextPollSeconds !== undefined) {
    535             storedGroup.nextPollTime =
    536                 now + receivedGroup.nextPollSeconds * MS_IN_SECOND;
    537           }
    538 
    539           updatedGroups[groupName] = storedGroup;
    540         }
    541 
    542         scheduleNextPoll(updatedGroups, !parsedResponse.googleNowDisabled);
    543         combineAndShowNotificationCards(
    544             updatedGroups,
    545             function() {
    546               chrome.storage.local.set({
    547                 notificationGroups: updatedGroups,
    548                 recentDismissals: updatedRecentDismissals
    549               });
    550               recordEvent(GoogleNowEvent.CARDS_PARSE_SUCCESS);
    551             },
    552             onCardShown);
    553       });
    554 }
    555 
    556 /**
    557  * Update Location Cards Shown Count.
    558  * @param {ReceivedNotification} receivedNotification Notification as it was
    559  *     received from the server.
    560  */
    561 function countLocationCard(receivedNotification) {
    562   if (receivedNotification.locationBased) {
    563     localStorage['locationCardsShown']++;
    564   }
    565 }
    566 
    567 /**
    568  * Requests notification cards from the server for specified groups.
    569  * @param {Array.<string>} groupNames Names of groups that need to be refreshed.
    570  */
    571 function requestNotificationGroups(groupNames) {
    572   console.log('requestNotificationGroups from ' + NOTIFICATION_CARDS_URL +
    573       ', groupNames=' + JSON.stringify(groupNames));
    574 
    575   recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_TOTAL);
    576 
    577   var requestParameters = '?timeZoneOffsetMs=' +
    578     (-new Date().getTimezoneOffset() * MS_IN_MINUTE);
    579 
    580   var cardShownCallback = undefined;
    581   if (localStorage['locationCardsShown'] < LOCATION_CARDS_LINK_THRESHOLD) {
    582     requestParameters += '&locationExplanation=true';
    583     cardShownCallback = countLocationCard;
    584   }
    585 
    586   groupNames.forEach(function(groupName) {
    587     requestParameters += ('&requestTypes=' + groupName);
    588   });
    589 
    590   console.log('requestNotificationGroups: request=' + requestParameters);
    591 
    592   var request = buildServerRequest('GET', 'notifications' + requestParameters);
    593 
    594   request.onloadend = function(event) {
    595     console.log('requestNotificationGroups-onloadend ' + request.status);
    596     if (request.status == HTTP_OK) {
    597       recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_SUCCESS);
    598       parseAndShowNotificationCards(request.responseText, cardShownCallback);
    599     }
    600   };
    601 
    602   setAuthorization(request, function(success) {
    603     if (success)
    604       request.send();
    605   });
    606 }
    607 
    608 /**
    609  * Requests the account opted-in state from the server.
    610  * @param {function()} optedInCallback Function that will be called if
    611  *     opted-in state is 'true'.
    612  */
    613 function requestOptedIn(optedInCallback) {
    614   console.log('requestOptedIn from ' + NOTIFICATION_CARDS_URL);
    615 
    616   var request = buildServerRequest('GET', 'settings/optin');
    617 
    618   request.onloadend = function(event) {
    619     console.log(
    620         'requestOptedIn-onloadend ' + request.status + ' ' + request.response);
    621     if (request.status == HTTP_OK) {
    622       var parsedResponse = JSON.parse(request.responseText);
    623       if (parsedResponse.value) {
    624         chrome.storage.local.set({googleNowEnabled: true});
    625         optedInCallback();
    626         // Google Now was disabled, now it's enabled. This is a state change.
    627         onStateChange();
    628       } else {
    629         scheduleNextPoll({}, false);
    630       }
    631     }
    632   };
    633 
    634   setAuthorization(request, function(success) {
    635     if (success)
    636       request.send();
    637   });
    638 }
    639 
    640 /**
    641  * Requests notification cards from the server.
    642  * @param {Location=} position Location of this computer.
    643  */
    644 function requestNotificationCards(position) {
    645   console.log('requestNotificationCards ' + JSON.stringify(position));
    646 
    647   instrumented.storage.local.get(
    648       ['notificationGroups', 'googleNowEnabled'], function(items) {
    649     console.log('requestNotificationCards-storage-get ' +
    650                 JSON.stringify(items));
    651     items = items || {};
    652     /** @type {Object.<string, StoredNotificationGroup>} */
    653     items.notificationGroups = items.notificationGroups || {};
    654 
    655     var groupsToRequest = [];
    656 
    657     var now = Date.now();
    658 
    659     for (var groupName in items.notificationGroups) {
    660       var group = items.notificationGroups[groupName];
    661       if (group.nextPollTime !== undefined && group.nextPollTime <= now)
    662         groupsToRequest.push(groupName);
    663     }
    664 
    665     if (items.googleNowEnabled) {
    666       requestNotificationGroups(groupsToRequest);
    667     } else {
    668       requestOptedIn(function() {
    669         requestNotificationGroups(groupsToRequest);
    670       });
    671     }
    672   });
    673 }
    674 
    675 /**
    676  * Starts getting location for a cards update.
    677  */
    678 function requestLocation() {
    679   console.log('requestLocation');
    680   recordEvent(GoogleNowEvent.LOCATION_REQUEST);
    681   // TODO(vadimt): Figure out location request options.
    682   instrumented.metricsPrivate.getVariationParams('GoogleNow', function(params) {
    683     var minDistanceInMeters =
    684         parseInt(params && params.minDistanceInMeters, 10) ||
    685         100;
    686     var minTimeInMilliseconds =
    687         parseInt(params && params.minTimeInMilliseconds, 10) ||
    688         180000;  // 3 minutes.
    689 
    690     // TODO(vadimt): Uncomment/remove watchLocation and remove invoking
    691     // updateNotificationsCards once state machine design is finalized.
    692 //    chrome.location.watchLocation(LOCATION_WATCH_NAME, {
    693 //      minDistanceInMeters: minDistanceInMeters,
    694 //      minTimeInMilliseconds: minTimeInMilliseconds
    695 //    });
    696     // We need setTimeout to avoid recursive task creation. This is a temporary
    697     // code, and it will be removed once we finally decide to send or not send
    698     // client location to the server.
    699     setTimeout(wrapper.wrapCallback(updateNotificationsCards, true), 0);
    700   });
    701 }
    702 
    703 /**
    704  * Stops getting the location.
    705  */
    706 function stopRequestLocation() {
    707   console.log('stopRequestLocation');
    708   chrome.location.clearWatch(LOCATION_WATCH_NAME);
    709 }
    710 
    711 /**
    712  * Obtains new location; requests and shows notification cards based on this
    713  * location.
    714  * @param {Location=} position Location of this computer.
    715  */
    716 function updateNotificationsCards(position) {
    717   console.log('updateNotificationsCards ' + JSON.stringify(position) +
    718       ' @' + new Date());
    719   tasks.add(UPDATE_CARDS_TASK_NAME, function() {
    720     console.log('updateNotificationsCards-task-begin');
    721     updateCardsAttempts.isRunning(function(running) {
    722       if (running) {
    723         updateCardsAttempts.planForNext(function() {
    724           processPendingDismissals(function(success) {
    725             if (success) {
    726               // The cards are requested only if there are no unsent dismissals.
    727               requestNotificationCards(position);
    728             }
    729           });
    730         });
    731       }
    732     });
    733   });
    734 }
    735 
    736 /**
    737  * Sends a server request to dismiss a card.
    738  * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of
    739  *     the card.
    740  * @param {number} dismissalTimeMs Time of the user's dismissal of the card in
    741  *     milliseconds since epoch.
    742  * @param {DismissalData} dismissalData Data to build a dismissal request.
    743  * @param {function(boolean)} callbackBoolean Completion callback with 'done'
    744  *     parameter.
    745  */
    746 function requestCardDismissal(
    747     chromeNotificationId, dismissalTimeMs, dismissalData, callbackBoolean) {
    748   console.log('requestDismissingCard ' + chromeNotificationId +
    749       ' from ' + NOTIFICATION_CARDS_URL +
    750       ', dismissalData=' + JSON.stringify(dismissalData));
    751 
    752   var dismissalAge = Date.now() - dismissalTimeMs;
    753 
    754   if (dismissalAge > MAXIMUM_DISMISSAL_AGE_MS) {
    755     callbackBoolean(true);
    756     return;
    757   }
    758 
    759   recordEvent(GoogleNowEvent.DISMISS_REQUEST_TOTAL);
    760 
    761   var requestParameters = 'notifications/' + dismissalData.notificationId +
    762       '?age=' + dismissalAge +
    763       '&chromeNotificationId=' + chromeNotificationId;
    764 
    765   for (var paramField in dismissalData.parameters)
    766     requestParameters += ('&' + paramField +
    767     '=' + dismissalData.parameters[paramField]);
    768 
    769   console.log('requestCardDismissal: requestParameters=' + requestParameters);
    770 
    771   var request = buildServerRequest('DELETE', requestParameters);
    772   request.onloadend = function(event) {
    773     console.log('requestDismissingCard-onloadend ' + request.status);
    774     if (request.status == HTTP_NOCONTENT)
    775       recordEvent(GoogleNowEvent.DISMISS_REQUEST_SUCCESS);
    776 
    777     // A dismissal doesn't require further retries if it was successful or
    778     // doesn't have a chance for successful completion.
    779     var done = request.status == HTTP_NOCONTENT ||
    780         request.status == HTTP_BAD_REQUEST ||
    781         request.status == HTTP_METHOD_NOT_ALLOWED;
    782     callbackBoolean(done);
    783   };
    784 
    785   setAuthorization(request, function(success) {
    786     if (success)
    787       request.send();
    788     else
    789       callbackBoolean(false);
    790   });
    791 }
    792 
    793 /**
    794  * Tries to send dismiss requests for all pending dismissals.
    795  * @param {function(boolean)} callbackBoolean Completion callback with 'success'
    796  *     parameter. Success means that no pending dismissals are left.
    797  */
    798 function processPendingDismissals(callbackBoolean) {
    799   instrumented.storage.local.get(['pendingDismissals', 'recentDismissals'],
    800       function(items) {
    801         console.log('processPendingDismissals-storage-get ' +
    802                     JSON.stringify(items));
    803         items = items || {};
    804         /** @type {Array.<PendingDismissal>} */
    805         items.pendingDismissals = items.pendingDismissals || [];
    806         /** @type {Object.<NotificationId, number>} */
    807         items.recentDismissals = items.recentDismissals || {};
    808 
    809         var dismissalsChanged = false;
    810 
    811         function onFinish(success) {
    812           if (dismissalsChanged) {
    813             chrome.storage.local.set({
    814               pendingDismissals: items.pendingDismissals,
    815               recentDismissals: items.recentDismissals
    816             });
    817           }
    818           callbackBoolean(success);
    819         }
    820 
    821         function doProcessDismissals() {
    822           if (items.pendingDismissals.length == 0) {
    823             dismissalAttempts.stop();
    824             onFinish(true);
    825             return;
    826           }
    827 
    828           // Send dismissal for the first card, and if successful, repeat
    829           // recursively with the rest.
    830           /** @type {PendingDismissal} */
    831           var dismissal = items.pendingDismissals[0];
    832           requestCardDismissal(
    833               dismissal.chromeNotificationId,
    834               dismissal.time,
    835               dismissal.dismissalData,
    836               function(done) {
    837                 if (done) {
    838                   dismissalsChanged = true;
    839                   items.pendingDismissals.splice(0, 1);
    840                   items.recentDismissals[
    841                       dismissal.dismissalData.notificationId] =
    842                       Date.now();
    843                   doProcessDismissals();
    844                 } else {
    845                   onFinish(false);
    846                 }
    847               });
    848         }
    849 
    850         doProcessDismissals();
    851       });
    852 }
    853 
    854 /**
    855  * Submits a task to send pending dismissals.
    856  */
    857 function retryPendingDismissals() {
    858   tasks.add(RETRY_DISMISS_TASK_NAME, function() {
    859     dismissalAttempts.planForNext(function() {
    860       processPendingDismissals(function(success) {});
    861      });
    862   });
    863 }
    864 
    865 /**
    866  * Opens a URL in a new tab.
    867  * @param {string} url URL to open.
    868  */
    869 function openUrl(url) {
    870   instrumented.tabs.create({url: url}, function(tab) {
    871     if (tab)
    872       chrome.windows.update(tab.windowId, {focused: true});
    873     else
    874       chrome.windows.create({url: url, focused: true});
    875   });
    876 }
    877 
    878 /**
    879  * Opens URL corresponding to the clicked part of the notification.
    880  * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of
    881  *     the card.
    882  * @param {function((ActionUrls|undefined)): (string|undefined)} selector
    883  *     Function that extracts the url for the clicked area from the button
    884  *     action URLs info.
    885  */
    886 function onNotificationClicked(chromeNotificationId, selector) {
    887   instrumented.storage.local.get('notificationsData', function(items) {
    888     /** @type {(NotificationDataEntry|undefined)} */
    889     var notificationData = items &&
    890         items.notificationsData &&
    891         items.notificationsData[chromeNotificationId];
    892 
    893     if (!notificationData)
    894       return;
    895 
    896     var url = selector(notificationData.actionUrls);
    897     if (!url)
    898       return;
    899 
    900     openUrl(url);
    901   });
    902 }
    903 
    904 /**
    905  * Callback for chrome.notifications.onClosed event.
    906  * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of
    907  *     the card.
    908  * @param {boolean} byUser Whether the notification was closed by the user.
    909  */
    910 function onNotificationClosed(chromeNotificationId, byUser) {
    911   if (!byUser)
    912     return;
    913 
    914   // At this point we are guaranteed that the notification is a now card.
    915   chrome.metricsPrivate.recordUserAction('GoogleNow.Dismissed');
    916 
    917   tasks.add(DISMISS_CARD_TASK_NAME, function() {
    918     dismissalAttempts.start();
    919 
    920     instrumented.storage.local.get(
    921         ['pendingDismissals', 'notificationsData', 'notificationGroups'],
    922         function(items) {
    923           items = items || {};
    924           /** @type {Array.<PendingDismissal>} */
    925           items.pendingDismissals = items.pendingDismissals || [];
    926           /** @type {Object.<string, NotificationDataEntry>} */
    927           items.notificationsData = items.notificationsData || {};
    928           /** @type {Object.<string, StoredNotificationGroup>} */
    929           items.notificationGroups = items.notificationGroups || {};
    930 
    931           /** @type {NotificationDataEntry} */
    932           var notificationData =
    933               items.notificationsData[chromeNotificationId] ||
    934               {
    935                 timestamp: Date.now(),
    936                 combinedCard: []
    937               };
    938 
    939           var dismissalResult =
    940               cardSet.onDismissal(
    941                   chromeNotificationId,
    942                   notificationData,
    943                   items.notificationGroups);
    944 
    945           for (var i = 0; i < dismissalResult.dismissals.length; i++) {
    946             /** @type {PendingDismissal} */
    947             var dismissal = {
    948               chromeNotificationId: chromeNotificationId,
    949               time: Date.now(),
    950               dismissalData: dismissalResult.dismissals[i]
    951             };
    952             items.pendingDismissals.push(dismissal);
    953           }
    954 
    955           items.notificationsData[chromeNotificationId] =
    956               dismissalResult.notificationData;
    957 
    958           chrome.storage.local.set(items);
    959 
    960           processPendingDismissals(function(success) {});
    961         });
    962   });
    963 }
    964 
    965 /**
    966  * Initializes the polling system to start monitoring location and fetching
    967  * cards.
    968  */
    969 function startPollingCards() {
    970   // Create an update timer for a case when for some reason location request
    971   // gets stuck.
    972   updateCardsAttempts.start(MAXIMUM_POLLING_PERIOD_SECONDS);
    973 
    974   requestLocation();
    975 }
    976 
    977 /**
    978  * Stops all machinery in the polling system.
    979  */
    980 function stopPollingCards() {
    981   stopRequestLocation();
    982   updateCardsAttempts.stop();
    983   removeAllCards();
    984   // Mark the Google Now as disabled to start with checking the opt-in state
    985   // next time startPollingCards() is called.
    986   chrome.storage.local.set({googleNowEnabled: false});
    987 }
    988 
    989 /**
    990  * Initializes the event page on install or on browser startup.
    991  */
    992 function initialize() {
    993   recordEvent(GoogleNowEvent.EXTENSION_START);
    994   onStateChange();
    995 }
    996 
    997 /**
    998  * Starts or stops the polling of cards.
    999  * @param {boolean} shouldPollCardsRequest true to start and
   1000  *     false to stop polling cards.
   1001  */
   1002 function setShouldPollCards(shouldPollCardsRequest) {
   1003   updateCardsAttempts.isRunning(function(currentValue) {
   1004     if (shouldPollCardsRequest != currentValue) {
   1005       console.log('Action Taken setShouldPollCards=' + shouldPollCardsRequest);
   1006       if (shouldPollCardsRequest)
   1007         startPollingCards();
   1008       else
   1009         stopPollingCards();
   1010     } else {
   1011       console.log(
   1012           'Action Ignored setShouldPollCards=' + shouldPollCardsRequest);
   1013     }
   1014   });
   1015 }
   1016 
   1017 /**
   1018  * Enables or disables the Google Now background permission.
   1019  * @param {boolean} backgroundEnable true to run in the background.
   1020  *     false to not run in the background.
   1021  */
   1022 function setBackgroundEnable(backgroundEnable) {
   1023   instrumented.permissions.contains({permissions: ['background']},
   1024       function(hasPermission) {
   1025         if (backgroundEnable != hasPermission) {
   1026           console.log('Action Taken setBackgroundEnable=' + backgroundEnable);
   1027           if (backgroundEnable)
   1028             chrome.permissions.request({permissions: ['background']});
   1029           else
   1030             chrome.permissions.remove({permissions: ['background']});
   1031         } else {
   1032           console.log('Action Ignored setBackgroundEnable=' + backgroundEnable);
   1033         }
   1034       });
   1035 }
   1036 
   1037 /**
   1038  * Does the actual work of deciding what Google Now should do
   1039  * based off of the current state of Chrome.
   1040  * @param {boolean} signedIn true if the user is signed in.
   1041  * @param {boolean} geolocationEnabled true if
   1042  *     the geolocation option is enabled.
   1043  * @param {boolean} canEnableBackground true if
   1044  *     the background permission can be requested.
   1045  * @param {boolean} notificationEnabled true if
   1046  *     Google Now for Chrome is allowed to show notifications.
   1047  * @param {boolean} googleNowEnabled true if
   1048  *     the Google Now is enabled for the user.
   1049  */
   1050 function updateRunningState(
   1051     signedIn,
   1052     geolocationEnabled,
   1053     canEnableBackground,
   1054     notificationEnabled,
   1055     googleNowEnabled) {
   1056   console.log(
   1057       'State Update signedIn=' + signedIn + ' ' +
   1058       'geolocationEnabled=' + geolocationEnabled + ' ' +
   1059       'canEnableBackground=' + canEnableBackground + ' ' +
   1060       'notificationEnabled=' + notificationEnabled + ' ' +
   1061       'googleNowEnabled=' + googleNowEnabled);
   1062 
   1063   // TODO(vadimt): Remove this line once state machine design is finalized.
   1064   geolocationEnabled = true;
   1065 
   1066   var shouldPollCards = false;
   1067   var shouldSetBackground = false;
   1068 
   1069   if (signedIn && notificationEnabled) {
   1070     if (geolocationEnabled) {
   1071       if (canEnableBackground && googleNowEnabled)
   1072         shouldSetBackground = true;
   1073 
   1074       shouldPollCards = true;
   1075     }
   1076   } else {
   1077     recordEvent(GoogleNowEvent.STOPPED);
   1078   }
   1079 
   1080   console.log(
   1081       'Requested Actions shouldSetBackground=' + shouldSetBackground + ' ' +
   1082       'setShouldPollCards=' + shouldPollCards);
   1083 
   1084   setBackgroundEnable(shouldSetBackground);
   1085   setShouldPollCards(shouldPollCards);
   1086 }
   1087 
   1088 /**
   1089  * Coordinates the behavior of Google Now for Chrome depending on
   1090  * Chrome and extension state.
   1091  */
   1092 function onStateChange() {
   1093   tasks.add(STATE_CHANGED_TASK_NAME, function() {
   1094     authenticationManager.isSignedIn(function(signedIn) {
   1095       instrumented.metricsPrivate.getVariationParams(
   1096           'GoogleNow',
   1097           function(response) {
   1098             var canEnableBackground =
   1099                 (!response || (response.canEnableBackground != 'false'));
   1100             instrumented.notifications.getPermissionLevel(function(level) {
   1101               var notificationEnabled = (level == 'granted');
   1102               instrumented.
   1103                 preferencesPrivate.
   1104                 googleGeolocationAccessEnabled.
   1105                 get({}, function(prefValue) {
   1106                   var geolocationEnabled = !!prefValue.value;
   1107                   instrumented.storage.local.get(
   1108                       'googleNowEnabled',
   1109                       function(items) {
   1110                         var googleNowEnabled =
   1111                             items && !!items.googleNowEnabled;
   1112                         updateRunningState(
   1113                             signedIn,
   1114                             geolocationEnabled,
   1115                             canEnableBackground,
   1116                             notificationEnabled,
   1117                             googleNowEnabled);
   1118                       });
   1119                 });
   1120             });
   1121           });
   1122     });
   1123   });
   1124 }
   1125 
   1126 instrumented.runtime.onInstalled.addListener(function(details) {
   1127   console.log('onInstalled ' + JSON.stringify(details));
   1128   if (details.reason != 'chrome_update') {
   1129     initialize();
   1130   }
   1131 });
   1132 
   1133 instrumented.runtime.onStartup.addListener(function() {
   1134   console.log('onStartup');
   1135 
   1136   // Show notifications received by earlier polls. Doing this as early as
   1137   // possible to reduce latency of showing first notifications. This mimics how
   1138   // persistent notifications will work.
   1139   tasks.add(SHOW_ON_START_TASK_NAME, function() {
   1140     instrumented.storage.local.get('notificationGroups', function(items) {
   1141       console.log('onStartup-get ' + JSON.stringify(items));
   1142       items = items || {};
   1143       /** @type {Object.<string, StoredNotificationGroup>} */
   1144       items.notificationGroups = items.notificationGroups || {};
   1145 
   1146       combineAndShowNotificationCards(items.notificationGroups, function() {
   1147         chrome.storage.local.set(items);
   1148       });
   1149     });
   1150   });
   1151 
   1152   initialize();
   1153 });
   1154 
   1155 instrumented.
   1156     preferencesPrivate.
   1157     googleGeolocationAccessEnabled.
   1158     onChange.
   1159     addListener(function(prefValue) {
   1160       console.log('googleGeolocationAccessEnabled Pref onChange ' +
   1161           prefValue.value);
   1162       onStateChange();
   1163 });
   1164 
   1165 authenticationManager.addListener(function() {
   1166   console.log('signIn State Change');
   1167   onStateChange();
   1168 });
   1169 
   1170 instrumented.notifications.onClicked.addListener(
   1171     function(chromeNotificationId) {
   1172       chrome.metricsPrivate.recordUserAction('GoogleNow.MessageClicked');
   1173       onNotificationClicked(chromeNotificationId, function(actionUrls) {
   1174         return actionUrls && actionUrls.messageUrl;
   1175       });
   1176     });
   1177 
   1178 instrumented.notifications.onButtonClicked.addListener(
   1179     function(chromeNotificationId, buttonIndex) {
   1180       chrome.metricsPrivate.recordUserAction(
   1181           'GoogleNow.ButtonClicked' + buttonIndex);
   1182       onNotificationClicked(chromeNotificationId, function(actionUrls) {
   1183         var url = actionUrls.buttonUrls[buttonIndex];
   1184         verify(url !== undefined, 'onButtonClicked: no url for a button');
   1185         return url;
   1186       });
   1187     });
   1188 
   1189 instrumented.notifications.onClosed.addListener(onNotificationClosed);
   1190 
   1191 instrumented.notifications.onPermissionLevelChanged.addListener(
   1192     function(permissionLevel) {
   1193       console.log('Notifications permissionLevel Change');
   1194       onStateChange();
   1195     });
   1196 
   1197 instrumented.notifications.onShowSettings.addListener(function() {
   1198   openUrl(SETTINGS_URL);
   1199 });
   1200 
   1201 instrumented.location.onLocationUpdate.addListener(function(position) {
   1202   recordEvent(GoogleNowEvent.LOCATION_UPDATE);
   1203   updateNotificationsCards(position);
   1204 });
   1205 
   1206 instrumented.pushMessaging.onMessage.addListener(function(message) {
   1207   // message.payload will be '' when the extension first starts.
   1208   // Each time after signing in, we'll get latest payload for all channels.
   1209   // So, we need to poll the server only when the payload is non-empty and has
   1210   // changed.
   1211   console.log('pushMessaging.onMessage ' + JSON.stringify(message));
   1212   if (message.payload.indexOf('REQUEST_CARDS') == 0) {
   1213     tasks.add(ON_PUSH_MESSAGE_START_TASK_NAME, function() {
   1214       instrumented.storage.local.get(
   1215           ['lastPollNowPayloads', 'notificationGroups'], function(items) {
   1216         // If storage.get fails, it's safer to do nothing, preventing polling
   1217         // the server when the payload really didn't change.
   1218         if (!items)
   1219           return;
   1220 
   1221         // If this is the first time we get lastPollNowPayloads, initialize it.
   1222         items.lastPollNowPayloads = items.lastPollNowPayloads || {};
   1223 
   1224         if (items.lastPollNowPayloads[message.subchannelId] !=
   1225             message.payload) {
   1226           items.lastPollNowPayloads[message.subchannelId] = message.payload;
   1227 
   1228           /** @type {Object.<string, StoredNotificationGroup>} */
   1229           items.notificationGroups = items.notificationGroups || {};
   1230           items.notificationGroups['PUSH' + message.subchannelId] = {
   1231             cards: [],
   1232             nextPollTime: Date.now()
   1233           };
   1234 
   1235           chrome.storage.local.set({
   1236             lastPollNowPayloads: items.lastPollNowPayloads,
   1237             notificationGroups: items.notificationGroups
   1238           });
   1239 
   1240           updateNotificationsCards();
   1241         }
   1242       });
   1243     });
   1244   }
   1245 });
   1246