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