• 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. 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 */
28var HTTP_OK = 200;
29var HTTP_NOCONTENT = 204;
30
31var HTTP_BAD_REQUEST = 400;
32var HTTP_UNAUTHORIZED = 401;
33var HTTP_FORBIDDEN = 403;
34var HTTP_METHOD_NOT_ALLOWED = 405;
35
36var MS_IN_SECOND = 1000;
37var 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 */
43var INITIAL_POLLING_PERIOD_SECONDS = 5 * 60;  // 5 minutes
44
45/**
46 * Mininal period for polling for Google Now Notifications cards.
47 */
48var 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 */
54var MAXIMUM_POLLING_PERIOD_SECONDS = 60 * 60;  // 1 hour
55
56/**
57 * Initial period for retrying the server request for dismissing cards.
58 */
59var INITIAL_RETRY_DISMISS_PERIOD_SECONDS = 60;  // 1 minute
60
61/**
62 * Maximum period for retrying the server request for dismissing cards.
63 */
64var MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS = 60 * 60;  // 1 hour
65
66/**
67 * Time we keep retrying dismissals.
68 */
69var MAXIMUM_DISMISSAL_AGE_MS = 24 * 60 * 60 * 1000; // 1 day
70
71/**
72 * Time we keep dismissals after successful server dismiss requests.
73 */
74var 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 */
79var 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 */
85var 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 */
90var LOCATION_CARDS_LINK_THRESHOLD = 10;
91
92/**
93 * Names for tasks that can be created by the extension.
94 */
95var UPDATE_CARDS_TASK_NAME = 'update-cards';
96var DISMISS_CARD_TASK_NAME = 'dismiss-card';
97var RETRY_DISMISS_TASK_NAME = 'retry-dismiss';
98var STATE_CHANGED_TASK_NAME = 'state-changed';
99var SHOW_ON_START_TASK_NAME = 'show-cards-on-start';
100var ON_PUSH_MESSAGE_START_TASK_NAME = 'on-push-message';
101
102var 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 */
113var 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 */
124var 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 */
140var 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 */
152var 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 */
161function 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
181var tasks = buildTaskManager(areTasksConflicting);
182
183// Add error processing to API calls.
184wrapper.instrumentChromeApiFunction('location.onLocationUpdate.addListener', 0);
185wrapper.instrumentChromeApiFunction('metricsPrivate.getVariationParams', 1);
186wrapper.instrumentChromeApiFunction('notifications.clear', 1);
187wrapper.instrumentChromeApiFunction('notifications.create', 2);
188wrapper.instrumentChromeApiFunction('notifications.getPermissionLevel', 0);
189wrapper.instrumentChromeApiFunction('notifications.update', 2);
190wrapper.instrumentChromeApiFunction('notifications.getAll', 0);
191wrapper.instrumentChromeApiFunction(
192    'notifications.onButtonClicked.addListener', 0);
193wrapper.instrumentChromeApiFunction('notifications.onClicked.addListener', 0);
194wrapper.instrumentChromeApiFunction('notifications.onClosed.addListener', 0);
195wrapper.instrumentChromeApiFunction(
196    'notifications.onPermissionLevelChanged.addListener', 0);
197wrapper.instrumentChromeApiFunction(
198    'notifications.onShowSettings.addListener', 0);
199wrapper.instrumentChromeApiFunction(
200    'preferencesPrivate.googleGeolocationAccessEnabled.get',
201    1);
202wrapper.instrumentChromeApiFunction(
203    'preferencesPrivate.googleGeolocationAccessEnabled.onChange.addListener',
204    0);
205wrapper.instrumentChromeApiFunction('permissions.contains', 1);
206wrapper.instrumentChromeApiFunction('pushMessaging.onMessage.addListener', 0);
207wrapper.instrumentChromeApiFunction('runtime.onInstalled.addListener', 0);
208wrapper.instrumentChromeApiFunction('runtime.onStartup.addListener', 0);
209wrapper.instrumentChromeApiFunction('tabs.create', 1);
210wrapper.instrumentChromeApiFunction('storage.local.get', 1);
211
212var updateCardsAttempts = buildAttemptManager(
213    'cards-update',
214    requestLocation,
215    INITIAL_POLLING_PERIOD_SECONDS,
216    MAXIMUM_POLLING_PERIOD_SECONDS);
217var dismissalAttempts = buildAttemptManager(
218    'dismiss',
219    retryPendingDismissals,
220    INITIAL_RETRY_DISMISS_PERIOD_SECONDS,
221    MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS);
222var cardSet = buildCardSet();
223
224var authenticationManager = buildAuthenticationManager();
225
226/**
227 * Google Now UMA event identifier.
228 * @enum {number}
229 */
230var 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 */
250function 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 */
268function 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 */
304function 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 */
340function 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 */
363function 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 */
391function 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 */
430function 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 */
451function 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 */
561function 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 */
571function 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 */
613function 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 */
644function 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 */
678function 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 */
706function 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 */
716function 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 */
746function 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 */
798function 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 */
857function 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 */
869function 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 */
886function 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 */
910function 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 */
969function 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 */
980function 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 */
992function 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 */
1002function 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 */
1022function 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 */
1050function 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 */
1092function 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
1126instrumented.runtime.onInstalled.addListener(function(details) {
1127  console.log('onInstalled ' + JSON.stringify(details));
1128  if (details.reason != 'chrome_update') {
1129    initialize();
1130  }
1131});
1132
1133instrumented.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
1155instrumented.
1156    preferencesPrivate.
1157    googleGeolocationAccessEnabled.
1158    onChange.
1159    addListener(function(prefValue) {
1160      console.log('googleGeolocationAccessEnabled Pref onChange ' +
1161          prefValue.value);
1162      onStateChange();
1163});
1164
1165authenticationManager.addListener(function() {
1166  console.log('signIn State Change');
1167  onStateChange();
1168});
1169
1170instrumented.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
1178instrumented.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
1189instrumented.notifications.onClosed.addListener(onNotificationClosed);
1190
1191instrumented.notifications.onPermissionLevelChanged.addListener(
1192    function(permissionLevel) {
1193      console.log('Notifications permissionLevel Change');
1194      onStateChange();
1195    });
1196
1197instrumented.notifications.onShowSettings.addListener(function() {
1198  openUrl(SETTINGS_URL);
1199});
1200
1201instrumented.location.onLocationUpdate.addListener(function(position) {
1202  recordEvent(GoogleNowEvent.LOCATION_UPDATE);
1203  updateNotificationsCards(position);
1204});
1205
1206instrumented.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