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