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): Use background permission to show notifications even when all
21// browser windows are closed.
22// TODO(vadimt): Decide what to do in incognito mode.
23// TODO(vadimt): Honor the flag the enables Google Now integration.
24// TODO(vadimt): Figure out the final values of the constants.
25// TODO(vadimt): Remove 'console' calls.
26// TODO(vadimt): Consider sending JS stacks for chrome.* API errors and
27// malformed server responses.
28
29/**
30 * Standard response code for successful HTTP requests. This is the only success
31 * code the server will send.
32 */
33var HTTP_OK = 200;
34
35var HTTP_BAD_REQUEST = 400;
36var HTTP_UNAUTHORIZED = 401;
37var HTTP_FORBIDDEN = 403;
38var HTTP_METHOD_NOT_ALLOWED = 405;
39
40/**
41 * Initial period for polling for Google Now Notifications cards to use when the
42 * period from the server is not available.
43 */
44var INITIAL_POLLING_PERIOD_SECONDS = 5 * 60;  // 5 minutes
45
46/**
47 * Maximal period for polling for Google Now Notifications cards to use when the
48 * period from the server is not available.
49 */
50var MAXIMUM_POLLING_PERIOD_SECONDS = 60 * 60;  // 1 hour
51
52/**
53 * Initial period for retrying the server request for dismissing cards.
54 */
55var INITIAL_RETRY_DISMISS_PERIOD_SECONDS = 60;  // 1 minute
56
57/**
58 * Maximum period for retrying the server request for dismissing cards.
59 */
60var MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS = 60 * 60;  // 1 hour
61
62/**
63 * Time we keep retrying dismissals.
64 */
65var MAXIMUM_DISMISSAL_AGE_MS = 24 * 60 * 60 * 1000; // 1 day
66
67/**
68 * Time we keep dismissals after successful server dismiss requests.
69 */
70var DISMISS_RETENTION_TIME_MS = 20 * 60 * 1000;  // 20 minutes
71
72/**
73 * Names for tasks that can be created by the extension.
74 */
75var UPDATE_CARDS_TASK_NAME = 'update-cards';
76var DISMISS_CARD_TASK_NAME = 'dismiss-card';
77var RETRY_DISMISS_TASK_NAME = 'retry-dismiss';
78var STATE_CHANGED_TASK_NAME = 'state-changed';
79
80var LOCATION_WATCH_NAME = 'location-watch';
81
82var WELCOME_TOAST_NOTIFICATION_ID = 'enable-now-toast';
83
84/**
85 * The indices of the buttons that are displayed on the welcome toast.
86 * @enum {number}
87 */
88var ToastButtonIndex = {YES: 0, NO: 1};
89
90/**
91 * Checks if a new task can't be scheduled when another task is already
92 * scheduled.
93 * @param {string} newTaskName Name of the new task.
94 * @param {string} scheduledTaskName Name of the scheduled task.
95 * @return {boolean} Whether the new task conflicts with the existing task.
96 */
97function areTasksConflicting(newTaskName, scheduledTaskName) {
98  if (newTaskName == UPDATE_CARDS_TASK_NAME &&
99      scheduledTaskName == UPDATE_CARDS_TASK_NAME) {
100    // If a card update is requested while an old update is still scheduled, we
101    // don't need the new update.
102    return true;
103  }
104
105  if (newTaskName == RETRY_DISMISS_TASK_NAME &&
106      (scheduledTaskName == UPDATE_CARDS_TASK_NAME ||
107       scheduledTaskName == DISMISS_CARD_TASK_NAME ||
108       scheduledTaskName == RETRY_DISMISS_TASK_NAME)) {
109    // No need to schedule retry-dismiss action if another action that tries to
110    // send dismissals is scheduled.
111    return true;
112  }
113
114  return false;
115}
116
117var tasks = buildTaskManager(areTasksConflicting);
118
119// Add error processing to API calls.
120tasks.instrumentChromeApiFunction('location.onLocationUpdate.addListener', 0);
121tasks.instrumentChromeApiFunction('metricsPrivate.getVariationParams', 1);
122tasks.instrumentChromeApiFunction('notifications.create', 2);
123tasks.instrumentChromeApiFunction('notifications.update', 2);
124tasks.instrumentChromeApiFunction('notifications.getAll', 0);
125tasks.instrumentChromeApiFunction(
126    'notifications.onButtonClicked.addListener', 0);
127tasks.instrumentChromeApiFunction('notifications.onClicked.addListener', 0);
128tasks.instrumentChromeApiFunction('notifications.onClosed.addListener', 0);
129tasks.instrumentChromeApiFunction('omnibox.onInputEntered.addListener', 0);
130tasks.instrumentChromeApiFunction(
131    'preferencesPrivate.googleGeolocationAccessEnabled.get',
132    1);
133tasks.instrumentChromeApiFunction(
134    'preferencesPrivate.googleGeolocationAccessEnabled.onChange.addListener',
135    0);
136tasks.instrumentChromeApiFunction('runtime.onInstalled.addListener', 0);
137tasks.instrumentChromeApiFunction('runtime.onStartup.addListener', 0);
138tasks.instrumentChromeApiFunction('tabs.create', 1);
139tasks.instrumentChromeApiFunction('storage.local.get', 1);
140
141var updateCardsAttempts = buildAttemptManager(
142    'cards-update',
143    requestLocation,
144    INITIAL_POLLING_PERIOD_SECONDS,
145    MAXIMUM_POLLING_PERIOD_SECONDS);
146var dismissalAttempts = buildAttemptManager(
147    'dismiss',
148    retryPendingDismissals,
149    INITIAL_RETRY_DISMISS_PERIOD_SECONDS,
150    MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS);
151var cardSet = buildCardSet();
152
153var authenticationManager = buildAuthenticationManager();
154
155/**
156 * Google Now UMA event identifier.
157 * @enum {number}
158 */
159var GoogleNowEvent = {
160  REQUEST_FOR_CARDS_TOTAL: 0,
161  REQUEST_FOR_CARDS_SUCCESS: 1,
162  CARDS_PARSE_SUCCESS: 2,
163  DISMISS_REQUEST_TOTAL: 3,
164  DISMISS_REQUEST_SUCCESS: 4,
165  LOCATION_REQUEST: 5,
166  LOCATION_UPDATE: 6,
167  EXTENSION_START: 7,
168  SHOW_WELCOME_TOAST: 8,
169  STOPPED: 9,
170  USER_SUPPRESSED: 10,
171  EVENTS_TOTAL: 11  // EVENTS_TOTAL is not an event; all new events need to be
172                    // added before it.
173};
174
175/**
176 * Records a Google Now Event.
177 * @param {GoogleNowEvent} event Event identifier.
178 */
179function recordEvent(event) {
180  var metricDescription = {
181    metricName: 'GoogleNow.Event',
182    type: 'histogram-linear',
183    min: 1,
184    max: GoogleNowEvent.EVENTS_TOTAL,
185    buckets: GoogleNowEvent.EVENTS_TOTAL + 1
186  };
187
188  chrome.metricsPrivate.recordValue(metricDescription, event);
189}
190
191/**
192 * Adds authorization behavior to the request.
193 * @param {XMLHttpRequest} request Server request.
194 * @param {function(boolean)} callbackBoolean Completion callback with 'success'
195 *     parameter.
196 */
197function setAuthorization(request, callbackBoolean) {
198  tasks.debugSetStepName('setAuthorization-isSignedIn');
199  authenticationManager.isSignedIn(function(token) {
200    if (!token) {
201      callbackBoolean(false);
202      return;
203    }
204
205    request.setRequestHeader('Authorization', 'Bearer ' + token);
206
207    // Instrument onloadend to remove stale auth tokens.
208    var originalOnLoadEnd = request.onloadend;
209    request.onloadend = tasks.wrapCallback(function(event) {
210      if (request.status == HTTP_FORBIDDEN ||
211          request.status == HTTP_UNAUTHORIZED) {
212        tasks.debugSetStepName('setAuthorization-removeToken');
213        authenticationManager.removeToken(token, function() {
214          originalOnLoadEnd(event);
215        });
216      } else {
217        originalOnLoadEnd(event);
218      }
219    });
220
221    callbackBoolean(true);
222  });
223}
224
225/**
226 * Parses JSON response from the notification server, show notifications and
227 * schedule next update.
228 * @param {string} response Server response.
229 * @param {function()} callback Completion callback.
230 */
231function parseAndShowNotificationCards(response, callback) {
232  console.log('parseAndShowNotificationCards ' + response);
233  try {
234    var parsedResponse = JSON.parse(response);
235  } catch (error) {
236    console.error('parseAndShowNotificationCards parse error: ' + error);
237    callback();
238    return;
239  }
240
241  var cards = parsedResponse.cards;
242
243  if (!(cards instanceof Array)) {
244    callback();
245    return;
246  }
247
248  if (typeof parsedResponse.next_poll_seconds != 'number') {
249    callback();
250    return;
251  }
252
253  tasks.debugSetStepName('parseAndShowNotificationCards-storage-get');
254  instrumented.storage.local.get(['notificationsData', 'recentDismissals'],
255      function(items) {
256        console.log('parseAndShowNotificationCards-get ' +
257            JSON.stringify(items));
258        items.notificationsData = items.notificationsData || {};
259        items.recentDismissals = items.recentDismissals || {};
260
261        tasks.debugSetStepName(
262            'parseAndShowNotificationCards-notifications-getAll');
263        instrumented.notifications.getAll(function(notifications) {
264          console.log('parseAndShowNotificationCards-getAll ' +
265              JSON.stringify(notifications));
266          // TODO(vadimt): Figure out what to do when notifications are
267          // disabled for our extension.
268          notifications = notifications || {};
269
270          // Build a set of non-expired recent dismissals. It will be used for
271          // client-side filtering of cards.
272          var updatedRecentDismissals = {};
273          var currentTimeMs = Date.now();
274          for (var notificationId in items.recentDismissals) {
275            if (currentTimeMs - items.recentDismissals[notificationId] <
276                DISMISS_RETENTION_TIME_MS) {
277              updatedRecentDismissals[notificationId] =
278                  items.recentDismissals[notificationId];
279            }
280          }
281
282          // Mark existing notifications that received an update in this server
283          // response.
284          var updatedNotifications = {};
285
286          for (var i = 0; i < cards.length; ++i) {
287            var notificationId = cards[i].notificationId;
288            if (!(notificationId in updatedRecentDismissals) &&
289                notificationId in notifications) {
290              updatedNotifications[notificationId] = true;
291            }
292          }
293
294          // Delete notifications that didn't receive an update.
295          for (var notificationId in notifications) {
296            console.log('parseAndShowNotificationCards-delete-check ' +
297                        notificationId);
298            if (!(notificationId in updatedNotifications)) {
299              console.log('parseAndShowNotificationCards-delete ' +
300                  notificationId);
301              cardSet.clear(notificationId);
302            }
303          }
304
305          recordEvent(GoogleNowEvent.CARDS_PARSE_SUCCESS);
306
307          // Create/update notifications and store their new properties.
308          var newNotificationsData = {};
309          for (var i = 0; i < cards.length; ++i) {
310            var card = cards[i];
311            if (!(card.notificationId in updatedRecentDismissals)) {
312              var notificationData =
313                  items.notificationsData[card.notificationId];
314              var previousVersion = notifications[card.notificationId] &&
315                                    notificationData &&
316                                    notificationData.cardCreateInfo &&
317                                    notificationData.cardCreateInfo.version;
318              newNotificationsData[card.notificationId] =
319                  cardSet.update(card, previousVersion);
320            }
321          }
322
323          updateCardsAttempts.start(parsedResponse.next_poll_seconds);
324
325          chrome.storage.local.set({
326            notificationsData: newNotificationsData,
327            recentDismissals: updatedRecentDismissals
328          });
329          callback();
330        });
331      });
332}
333
334/**
335 * Removes all cards and card state on Google Now close down.
336 * For example, this occurs when the geolocation preference is unchecked in the
337 * content settings.
338 */
339function removeAllCards() {
340  console.log('removeAllCards');
341
342  // TODO(robliao): Once Google Now clears its own checkbox in the
343  // notifications center and bug 260376 is fixed, the below clearing
344  // code is no longer necessary.
345  instrumented.notifications.getAll(function(notifications) {
346    for (var notificationId in notifications) {
347      chrome.notifications.clear(notificationId, function() {});
348    }
349    chrome.storage.local.set({notificationsData: {}});
350  });
351}
352
353/**
354 * Requests notification cards from the server.
355 * @param {Location} position Location of this computer.
356 * @param {function()} callback Completion callback.
357 */
358function requestNotificationCards(position, callback) {
359  console.log('requestNotificationCards ' + JSON.stringify(position) +
360      ' from ' + NOTIFICATION_CARDS_URL);
361
362  if (!NOTIFICATION_CARDS_URL) {
363    callback();
364    return;
365  }
366
367  recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_TOTAL);
368
369  // TODO(vadimt): Should we use 'q' as the parameter name?
370  var requestParameters =
371      'q=' + position.coords.latitude +
372      ',' + position.coords.longitude +
373      ',' + position.coords.accuracy;
374
375  var request = buildServerRequest('notifications',
376                                   'application/x-www-form-urlencoded');
377
378  request.onloadend = function(event) {
379    console.log('requestNotificationCards-onloadend ' + request.status);
380    if (request.status == HTTP_OK) {
381      recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_SUCCESS);
382      parseAndShowNotificationCards(request.response, callback);
383    } else {
384      callback();
385    }
386  };
387
388  setAuthorization(request, function(success) {
389    if (success) {
390      tasks.debugSetStepName('requestNotificationCards-send-request');
391      request.send(requestParameters);
392    } else {
393      callback();
394    }
395  });
396}
397
398/**
399 * Starts getting location for a cards update.
400 */
401function requestLocation() {
402  console.log('requestLocation');
403  recordEvent(GoogleNowEvent.LOCATION_REQUEST);
404  // TODO(vadimt): Figure out location request options.
405  chrome.metricsPrivate.getVariationParams('GoogleNow', function(params) {
406    var minDistanceInMeters =
407        parseInt(params && params.minDistanceInMeters, 10) ||
408        100;
409    var minTimeInMilliseconds =
410        parseInt(params && params.minTimeInMilliseconds, 10) ||
411        180000;  // 3 minutes.
412
413    chrome.location.watchLocation(LOCATION_WATCH_NAME, {
414      minDistanceInMeters: minDistanceInMeters,
415      minTimeInMilliseconds: minTimeInMilliseconds
416    });
417  });
418}
419
420/**
421 * Stops getting the location.
422 */
423function stopRequestLocation() {
424  console.log('stopRequestLocation');
425  chrome.location.clearWatch(LOCATION_WATCH_NAME);
426}
427
428/**
429 * Obtains new location; requests and shows notification cards based on this
430 * location.
431 * @param {Location} position Location of this computer.
432 */
433function updateNotificationsCards(position) {
434  console.log('updateNotificationsCards ' + JSON.stringify(position) +
435      ' @' + new Date());
436  tasks.add(UPDATE_CARDS_TASK_NAME, function(callback) {
437    console.log('updateNotificationsCards-task-begin');
438    updateCardsAttempts.isRunning(function(running) {
439      if (running) {
440        updateCardsAttempts.planForNext(function() {
441          processPendingDismissals(function(success) {
442            if (success) {
443              // The cards are requested only if there are no unsent dismissals.
444              requestNotificationCards(position, callback);
445            } else {
446              callback();
447            }
448          });
449        });
450      }
451    });
452  });
453}
454
455/**
456 * Sends a server request to dismiss a card.
457 * @param {string} notificationId Unique identifier of the card.
458 * @param {number} dismissalTimeMs Time of the user's dismissal of the card in
459 *     milliseconds since epoch.
460 * @param {Object} dismissalParameters Dismissal parameters.
461 * @param {function(boolean)} callbackBoolean Completion callback with 'done'
462 *     parameter.
463 */
464function requestCardDismissal(
465    notificationId, dismissalTimeMs, dismissalParameters, callbackBoolean) {
466  console.log('requestDismissingCard ' + notificationId + ' from ' +
467      NOTIFICATION_CARDS_URL);
468
469  var dismissalAge = Date.now() - dismissalTimeMs;
470
471  if (dismissalAge > MAXIMUM_DISMISSAL_AGE_MS) {
472    callbackBoolean(true);
473    return;
474  }
475
476  recordEvent(GoogleNowEvent.DISMISS_REQUEST_TOTAL);
477  var request = buildServerRequest('dismiss', 'application/json');
478  request.onloadend = function(event) {
479    console.log('requestDismissingCard-onloadend ' + request.status);
480    if (request.status == HTTP_OK)
481      recordEvent(GoogleNowEvent.DISMISS_REQUEST_SUCCESS);
482
483    // A dismissal doesn't require further retries if it was successful or
484    // doesn't have a chance for successful completion.
485    var done = request.status == HTTP_OK ||
486        request.status == HTTP_BAD_REQUEST ||
487        request.status == HTTP_METHOD_NOT_ALLOWED;
488    callbackBoolean(done);
489  };
490
491  setAuthorization(request, function(success) {
492    if (success) {
493      tasks.debugSetStepName('requestCardDismissal-send-request');
494
495      var dismissalObject = {
496        id: notificationId,
497        age: dismissalAge,
498        dismissal: dismissalParameters
499      };
500      request.send(JSON.stringify(dismissalObject));
501    } else {
502      callbackBoolean(false);
503    }
504  });
505}
506
507/**
508 * Tries to send dismiss requests for all pending dismissals.
509 * @param {function(boolean)} callbackBoolean Completion callback with 'success'
510 *     parameter. Success means that no pending dismissals are left.
511 */
512function processPendingDismissals(callbackBoolean) {
513  tasks.debugSetStepName('processPendingDismissals-storage-get');
514  instrumented.storage.local.get(['pendingDismissals', 'recentDismissals'],
515      function(items) {
516        console.log('processPendingDismissals-storage-get ' +
517                    JSON.stringify(items));
518        items.pendingDismissals = items.pendingDismissals || [];
519        items.recentDismissals = items.recentDismissals || {};
520
521        var dismissalsChanged = false;
522
523        function onFinish(success) {
524          if (dismissalsChanged) {
525            chrome.storage.local.set({
526              pendingDismissals: items.pendingDismissals,
527              recentDismissals: items.recentDismissals
528            });
529          }
530          callbackBoolean(success);
531        }
532
533        function doProcessDismissals() {
534          if (items.pendingDismissals.length == 0) {
535            dismissalAttempts.stop();
536            onFinish(true);
537            return;
538          }
539
540          // Send dismissal for the first card, and if successful, repeat
541          // recursively with the rest.
542          var dismissal = items.pendingDismissals[0];
543          requestCardDismissal(
544              dismissal.notificationId,
545              dismissal.time,
546              dismissal.parameters,
547              function(done) {
548                if (done) {
549                  dismissalsChanged = true;
550                  items.pendingDismissals.splice(0, 1);
551                  items.recentDismissals[dismissal.notificationId] = Date.now();
552                  doProcessDismissals();
553                } else {
554                  onFinish(false);
555                }
556              });
557        }
558
559        doProcessDismissals();
560      });
561}
562
563/**
564 * Submits a task to send pending dismissals.
565 */
566function retryPendingDismissals() {
567  tasks.add(RETRY_DISMISS_TASK_NAME, function(callback) {
568    dismissalAttempts.planForNext(function() {
569      processPendingDismissals(function(success) { callback(); });
570     });
571  });
572}
573
574/**
575 * Opens URL corresponding to the clicked part of the notification.
576 * @param {string} notificationId Unique identifier of the notification.
577 * @param {function(Object): string} selector Function that extracts the url for
578 *     the clicked area from the button action URLs info.
579 */
580function onNotificationClicked(notificationId, selector) {
581  instrumented.storage.local.get('notificationsData', function(items) {
582    items.notificationsData = items.notificationsData || {};
583
584    var notificationData = items.notificationsData[notificationId];
585
586    if (!notificationData) {
587      // 'notificationsData' in storage may not match the actual list of
588      // notifications.
589      return;
590    }
591
592    var actionUrls = notificationData.actionUrls;
593    if (typeof actionUrls != 'object') {
594      return;
595    }
596
597    var url = selector(actionUrls);
598
599    if (typeof url != 'string')
600      return;
601
602    instrumented.tabs.create({url: url}, function(tab) {
603      if (!tab)
604        chrome.windows.create({url: url});
605    });
606  });
607}
608
609/**
610 * Responds to a click of one of the buttons on the welcome toast.
611 * @param {number} buttonIndex The index of the button which was clicked.
612 */
613function onToastNotificationClicked(buttonIndex) {
614  chrome.storage.local.set({userRespondedToToast: true});
615
616  if (buttonIndex == ToastButtonIndex.YES) {
617    chrome.metricsPrivate.recordUserAction('GoogleNow.WelcomeToastClickedYes');
618    chrome.preferencesPrivate.googleGeolocationAccessEnabled.set({value: true});
619    // The googlegeolocationaccessenabled preference change callback
620    // will take care of starting the poll for cards.
621  } else {
622    chrome.metricsPrivate.recordUserAction('GoogleNow.WelcomeToastClickedNo');
623    onStateChange();
624  }
625}
626
627/**
628 * Callback for chrome.notifications.onClosed event.
629 * @param {string} notificationId Unique identifier of the notification.
630 * @param {boolean} byUser Whether the notification was closed by the user.
631 */
632function onNotificationClosed(notificationId, byUser) {
633  if (!byUser)
634    return;
635
636  if (notificationId == WELCOME_TOAST_NOTIFICATION_ID) {
637    // Even though they only closed the notification without clicking no, treat
638    // it as though they clicked No anwyay, and don't show the toast again.
639    chrome.metricsPrivate.recordUserAction('GoogleNow.WelcomeToastDismissed');
640    chrome.storage.local.set({userRespondedToToast: true});
641    return;
642  }
643
644  // At this point we are guaranteed that the notification is a now card.
645  chrome.metricsPrivate.recordUserAction('GoogleNow.Dismissed');
646
647  tasks.add(DISMISS_CARD_TASK_NAME, function(callback) {
648    dismissalAttempts.start();
649
650    // Deleting the notification in case it was re-added while this task was
651    // scheduled, waiting for execution.
652    cardSet.clear(notificationId);
653
654    tasks.debugSetStepName('onNotificationClosed-storage-get');
655    instrumented.storage.local.get(['pendingDismissals', 'notificationsData'],
656        function(items) {
657          items.pendingDismissals = items.pendingDismissals || [];
658          items.notificationsData = items.notificationsData || {};
659
660          var notificationData = items.notificationsData[notificationId];
661
662          var dismissal = {
663            notificationId: notificationId,
664            time: Date.now(),
665            parameters: notificationData && notificationData.dismissalParameters
666          };
667          items.pendingDismissals.push(dismissal);
668          chrome.storage.local.set(
669              {pendingDismissals: items.pendingDismissals});
670          processPendingDismissals(function(success) { callback(); });
671        });
672  });
673}
674
675/**
676 * Initializes the polling system to start monitoring location and fetching
677 * cards.
678 */
679function startPollingCards() {
680  // Create an update timer for a case when for some reason location request
681  // gets stuck.
682  updateCardsAttempts.start(MAXIMUM_POLLING_PERIOD_SECONDS);
683
684  requestLocation();
685}
686
687/**
688 * Stops all machinery in the polling system.
689 */
690function stopPollingCards() {
691  stopRequestLocation();
692
693  updateCardsAttempts.stop();
694
695  removeAllCards();
696}
697
698/**
699 * Initializes the event page on install or on browser startup.
700 */
701function initialize() {
702  recordEvent(GoogleNowEvent.EXTENSION_START);
703
704  // Alarms persist across chrome restarts. This is undesirable since it
705  // prevents us from starting up everything (alarms are a heuristic to
706  // determine if we are already running). To mitigate this, we will
707  // shut everything down on initialize before starting everything up.
708  stopPollingCards();
709  onStateChange();
710}
711
712/**
713 * Starts or stops the polling of cards.
714 * @param {boolean} shouldPollCardsRequest true to start and
715 *     false to stop polling cards.
716 * @param {function} callback Called on completion.
717 */
718function setShouldPollCards(shouldPollCardsRequest, callback) {
719  tasks.debugSetStepName(
720        'setShouldRun-shouldRun-updateCardsAttemptsIsRunning');
721  updateCardsAttempts.isRunning(function(currentValue) {
722    if (shouldPollCardsRequest != currentValue) {
723      console.log('Action Taken setShouldPollCards=' + shouldPollCardsRequest);
724      if (shouldPollCardsRequest)
725        startPollingCards();
726      else
727        stopPollingCards();
728    } else {
729      console.log(
730          'Action Ignored setShouldPollCards=' + shouldPollCardsRequest);
731    }
732    callback();
733  });
734}
735
736/**
737 * Shows or hides the toast.
738 * @param {boolean} visibleRequest true to show the toast and
739 *     false to hide the toast.
740 * @param {function} callback Called on completion.
741 */
742function setToastVisible(visibleRequest, callback) {
743  tasks.debugSetStepName(
744      'setToastVisible-shouldSetToastVisible-getAllNotifications');
745  instrumented.notifications.getAll(function(notifications) {
746    // TODO(vadimt): Figure out what to do when notifications are disabled for
747    // our extension.
748    notifications = notifications || {};
749
750    if (visibleRequest != !!notifications[WELCOME_TOAST_NOTIFICATION_ID]) {
751      console.log('Action Taken setToastVisible=' + visibleRequest);
752      if (visibleRequest)
753        showWelcomeToast();
754      else
755        hideWelcomeToast();
756    } else {
757      console.log('Action Ignored setToastVisible=' + visibleRequest);
758    }
759
760    callback();
761  });
762}
763
764/**
765 * Does the actual work of deciding what Google Now should do
766 * based off of the current state of Chrome.
767 * @param {boolean} signedIn true if the user is signed in.
768 * @param {boolean} geolocationEnabled true if
769 *     the geolocation option is enabled.
770 * @param {boolean} userRespondedToToast true if
771 *     the user has responded to the toast.
772 * @param {function()} callback Call this function on completion.
773 */
774function updateRunningState(
775    signedIn,
776    geolocationEnabled,
777    userRespondedToToast,
778    callback) {
779
780  console.log(
781      'State Update signedIn=' + signedIn + ' ' +
782      'geolocationEnabled=' + geolocationEnabled + ' ' +
783      'userRespondedToToast=' + userRespondedToToast);
784
785  var shouldSetToastVisible = false;
786  var shouldPollCards = false;
787
788  if (signedIn) {
789    if (geolocationEnabled) {
790      if (!userRespondedToToast) {
791        // If the user enabled geolocation independently of Google Now,
792        // the user has implicitly responded to the toast.
793        // We do not want to show it again.
794        chrome.storage.local.set({userRespondedToToast: true});
795      }
796
797      shouldPollCards = true;
798    } else {
799      if (userRespondedToToast) {
800        recordEvent(GoogleNowEvent.USER_SUPPRESSED);
801      } else {
802        shouldSetToastVisible = true;
803      }
804    }
805  } else {
806    recordEvent(GoogleNowEvent.STOPPED);
807  }
808
809  console.log(
810      'Requested Actions setToastVisible=' + shouldSetToastVisible + ' ' +
811      'setShouldPollCards=' + shouldPollCards);
812
813  setToastVisible(shouldSetToastVisible, function() {
814    setShouldPollCards(shouldPollCards, callback);
815  });
816}
817
818/**
819 * Coordinates the behavior of Google Now for Chrome depending on
820 * Chrome and extension state.
821 */
822function onStateChange() {
823  tasks.add(STATE_CHANGED_TASK_NAME, function(callback) {
824    tasks.debugSetStepName('onStateChange-isSignedIn');
825    authenticationManager.isSignedIn(function(token) {
826      var signedIn = !!token && !!NOTIFICATION_CARDS_URL;
827      tasks.debugSetStepName(
828          'onStateChange-get-googleGeolocationAccessEnabledPref');
829      instrumented.
830          preferencesPrivate.
831          googleGeolocationAccessEnabled.
832          get({}, function(prefValue) {
833            var geolocationEnabled = !!prefValue.value;
834            tasks.debugSetStepName(
835              'onStateChange-get-userRespondedToToast');
836            instrumented.storage.local.get(
837                'userRespondedToToast',
838                function(items) {
839                  var userRespondedToToast = !!items.userRespondedToToast;
840                  updateRunningState(
841                      signedIn,
842                      geolocationEnabled,
843                      userRespondedToToast,
844                      callback);
845                });
846          });
847    });
848  });
849}
850
851/**
852 * Displays a toast to the user asking if they want to opt in to receiving
853 * Google Now cards.
854 */
855function showWelcomeToast() {
856  recordEvent(GoogleNowEvent.SHOW_WELCOME_TOAST);
857  // TODO(zturner): Localize this once the component extension localization
858  // api is complete.
859  // TODO(zturner): Add icons.
860  var buttons = [{title: 'Yes'}, {title: 'No'}];
861  var options = {
862    type: 'basic',
863    title: 'Enable Google Now Cards',
864    message: 'Would you like to be shown Google Now cards?',
865    iconUrl: 'http://www.gstatic.com/googlenow/chrome/default.png',
866    priority: 2,
867    buttons: buttons
868  };
869  instrumented.notifications.create(WELCOME_TOAST_NOTIFICATION_ID, options,
870      function(notificationId) {});
871}
872
873/**
874 * Hides the welcome toast.
875 */
876function hideWelcomeToast() {
877  chrome.notifications.clear(
878      WELCOME_TOAST_NOTIFICATION_ID,
879      function() {});
880}
881
882instrumented.runtime.onInstalled.addListener(function(details) {
883  console.log('onInstalled ' + JSON.stringify(details));
884  if (details.reason != 'chrome_update') {
885    initialize();
886  }
887});
888
889instrumented.runtime.onStartup.addListener(function() {
890  console.log('onStartup');
891  initialize();
892});
893
894instrumented.
895    preferencesPrivate.
896    googleGeolocationAccessEnabled.
897    onChange.
898    addListener(function(prefValue) {
899      console.log('googleGeolocationAccessEnabled Pref onChange ' +
900          prefValue.value);
901      onStateChange();
902});
903
904authenticationManager.addListener(function() {
905  console.log('signIn State Change');
906  onStateChange();
907});
908
909instrumented.notifications.onClicked.addListener(
910    function(notificationId) {
911      chrome.metricsPrivate.recordUserAction('GoogleNow.MessageClicked');
912      onNotificationClicked(notificationId, function(actionUrls) {
913        return actionUrls.messageUrl;
914      });
915    });
916
917instrumented.notifications.onButtonClicked.addListener(
918    function(notificationId, buttonIndex) {
919      if (notificationId == WELCOME_TOAST_NOTIFICATION_ID) {
920        onToastNotificationClicked(buttonIndex);
921      } else {
922        chrome.metricsPrivate.recordUserAction(
923            'GoogleNow.ButtonClicked' + buttonIndex);
924        onNotificationClicked(notificationId, function(actionUrls) {
925          if (!Array.isArray(actionUrls.buttonUrls))
926            return undefined;
927
928          return actionUrls.buttonUrls[buttonIndex];
929        });
930      }
931    });
932
933instrumented.notifications.onClosed.addListener(onNotificationClosed);
934
935instrumented.location.onLocationUpdate.addListener(function(position) {
936  recordEvent(GoogleNowEvent.LOCATION_UPDATE);
937  updateNotificationsCards(position);
938});
939
940instrumented.omnibox.onInputEntered.addListener(function(text) {
941  localStorage['server_url'] = NOTIFICATION_CARDS_URL = text;
942  initialize();
943});
944