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