background.js revision 7d4cd473f85ac64c3747c96c277f9e506a0d2246
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
35/**
36 * Initial period for polling for Google Now Notifications cards to use when the
37 * period from the server is not available.
38 */
39var INITIAL_POLLING_PERIOD_SECONDS = 5 * 60;  // 5 minutes
40
41/**
42 * Maximal period for polling for Google Now Notifications cards to use when the
43 * period from the server is not available.
44 */
45var MAXIMUM_POLLING_PERIOD_SECONDS = 60 * 60;  // 1 hour
46
47/**
48 * Initial period for retrying the server request for dismissing cards.
49 */
50var INITIAL_RETRY_DISMISS_PERIOD_SECONDS = 60;  // 1 minute
51
52/**
53 * Maximum period for retrying the server request for dismissing cards.
54 */
55var MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS = 60 * 60;  // 1 hour
56
57/**
58 * Time we keep dismissals after successful server dismiss requests.
59 */
60var DISMISS_RETENTION_TIME_MS = 20 * 60 * 1000;  // 20 minutes
61
62/**
63 * Names for tasks that can be created by the extension.
64 */
65var UPDATE_CARDS_TASK_NAME = 'update-cards';
66var DISMISS_CARD_TASK_NAME = 'dismiss-card';
67var RETRY_DISMISS_TASK_NAME = 'retry-dismiss';
68
69var LOCATION_WATCH_NAME = 'location-watch';
70
71/**
72 * Checks if a new task can't be scheduled when another task is already
73 * scheduled.
74 * @param {string} newTaskName Name of the new task.
75 * @param {string} scheduledTaskName Name of the scheduled task.
76 * @return {boolean} Whether the new task conflicts with the existing task.
77 */
78function areTasksConflicting(newTaskName, scheduledTaskName) {
79  if (newTaskName == UPDATE_CARDS_TASK_NAME &&
80      scheduledTaskName == UPDATE_CARDS_TASK_NAME) {
81    // If a card update is requested while an old update is still scheduled, we
82    // don't need the new update.
83    return true;
84  }
85
86  if (newTaskName == RETRY_DISMISS_TASK_NAME &&
87      (scheduledTaskName == UPDATE_CARDS_TASK_NAME ||
88       scheduledTaskName == DISMISS_CARD_TASK_NAME ||
89       scheduledTaskName == RETRY_DISMISS_TASK_NAME)) {
90    // No need to schedule retry-dismiss action if another action that tries to
91    // send dismissals is scheduled.
92    return true;
93  }
94
95  return false;
96}
97
98var tasks = buildTaskManager(areTasksConflicting);
99
100// Add error processing to API calls.
101tasks.instrumentApiFunction(chrome.location.onLocationUpdate, 'addListener', 0);
102tasks.instrumentApiFunction(chrome.notifications, 'create', 2);
103tasks.instrumentApiFunction(chrome.notifications, 'update', 2);
104tasks.instrumentApiFunction(chrome.notifications, 'getAll', 0);
105tasks.instrumentApiFunction(
106    chrome.notifications.onButtonClicked, 'addListener', 0);
107tasks.instrumentApiFunction(chrome.notifications.onClicked, 'addListener', 0);
108tasks.instrumentApiFunction(chrome.notifications.onClosed, 'addListener', 0);
109tasks.instrumentApiFunction(chrome.runtime.onInstalled, 'addListener', 0);
110tasks.instrumentApiFunction(chrome.runtime.onStartup, 'addListener', 0);
111tasks.instrumentApiFunction(chrome.tabs, 'create', 1);
112tasks.instrumentApiFunction(storage, 'get', 1);
113
114var updateCardsAttempts = buildAttemptManager(
115    'cards-update',
116    requestLocation,
117    INITIAL_POLLING_PERIOD_SECONDS,
118    MAXIMUM_POLLING_PERIOD_SECONDS);
119var dismissalAttempts = buildAttemptManager(
120    'dismiss',
121    retryPendingDismissals,
122    INITIAL_RETRY_DISMISS_PERIOD_SECONDS,
123    MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS);
124
125/**
126 * Diagnostic event identifier.
127 * @enum {number}
128 */
129var DiagnosticEvent = {
130  REQUEST_FOR_CARDS_TOTAL: 0,
131  REQUEST_FOR_CARDS_SUCCESS: 1,
132  CARDS_PARSE_SUCCESS: 2,
133  DISMISS_REQUEST_TOTAL: 3,
134  DISMISS_REQUEST_SUCCESS: 4,
135  LOCATION_REQUEST: 5,
136  LOCATION_UPDATE: 6,
137  EVENTS_TOTAL: 7  // EVENTS_TOTAL is not an event; all new events need to be
138                   // added before it.
139};
140
141/**
142 * Records a diagnostic event.
143 * @param {DiagnosticEvent} event Event identifier.
144 */
145function recordEvent(event) {
146  var metricDescription = {
147    metricName: 'GoogleNow.Event',
148    type: 'histogram-linear',
149    min: 1,
150    max: DiagnosticEvent.EVENTS_TOTAL,
151    buckets: DiagnosticEvent.EVENTS_TOTAL + 1
152  };
153
154  chrome.metricsPrivate.recordValue(metricDescription, event);
155}
156
157/**
158 * Shows a notification and remembers information associated with it.
159 * @param {Object} card Google Now card represented as a set of parameters for
160 *     showing a Chrome notification.
161 * @param {Object} notificationsData Map from notification id to the data
162 *     associated with a notification.
163 * @param {number=} opt_previousVersion The version of the shown card with this
164 *     id, if it exists, undefined otherwise.
165 */
166function showNotification(card, notificationsData, opt_previousVersion) {
167  console.log('showNotification ' + JSON.stringify(card) + ' ' +
168             opt_previousVersion);
169
170  if (typeof card.version != 'number') {
171    console.error('card.version is not a number');
172    // Fix card version.
173    card.version = opt_previousVersion !== undefined ? opt_previousVersion : 0;
174  }
175
176  if (opt_previousVersion !== card.version) {
177    try {
178      // Delete a notification with the specified id if it already exists, and
179      // then create a notification.
180      chrome.notifications.create(
181          card.notificationId,
182          card.notification,
183          function(notificationId) {
184            if (!notificationId || chrome.runtime.lastError) {
185              var errorMessage =
186                  chrome.runtime.lastError && chrome.runtime.lastError.message;
187              console.error('notifications.create: ID=' + notificationId +
188                            ', ERROR=' + errorMessage);
189            }
190          });
191    } catch (error) {
192      console.error('Error in notifications.create: ' + error);
193    }
194  } else {
195    try {
196      // Update existing notification.
197      chrome.notifications.update(
198          card.notificationId,
199          card.notification,
200          function(wasUpdated) {
201            if (!wasUpdated || chrome.runtime.lastError) {
202              var errorMessage =
203                  chrome.runtime.lastError && chrome.runtime.lastError.message;
204              console.error('notifications.update: UPDATED=' + wasUpdated +
205                            ', ERROR=' + errorMessage);
206            }
207          });
208    } catch (error) {
209      console.error('Error in notifications.update: ' + error);
210    }
211  }
212
213  notificationsData[card.notificationId] = {
214    actionUrls: card.actionUrls,
215    version: card.version
216  };
217}
218
219/**
220 * Parses JSON response from the notification server, show notifications and
221 * schedule next update.
222 * @param {string} response Server response.
223 * @param {function()} callback Completion callback.
224 */
225function parseAndShowNotificationCards(response, callback) {
226  console.log('parseAndShowNotificationCards ' + response);
227  try {
228    var parsedResponse = JSON.parse(response);
229  } catch (error) {
230    console.error('parseAndShowNotificationCards parse error: ' + error);
231    callback();
232    return;
233  }
234
235  var cards = parsedResponse.cards;
236
237  if (!(cards instanceof Array)) {
238    callback();
239    return;
240  }
241
242  if (typeof parsedResponse.expiration_timestamp_seconds != 'number') {
243    callback();
244    return;
245  }
246
247  tasks.debugSetStepName('parseAndShowNotificationCards-storage-get');
248  storage.get(['notificationsData', 'recentDismissals'], function(items) {
249    console.log('parseAndShowNotificationCards-get ' + JSON.stringify(items));
250    items.notificationsData = items.notificationsData || {};
251    items.recentDismissals = items.recentDismissals || {};
252
253    tasks.debugSetStepName(
254        'parseAndShowNotificationCards-notifications-getAll');
255    chrome.notifications.getAll(function(notifications) {
256      console.log('parseAndShowNotificationCards-getAll ' +
257          JSON.stringify(notifications));
258      // TODO(vadimt): Figure out what to do when notifications are disabled for
259      // our extension.
260      notifications = notifications || {};
261
262      // Build a set of non-expired recent dismissals. It will be used for
263      // client-side filtering of cards.
264      var updatedRecentDismissals = {};
265      var currentTimeMs = Date.now();
266      for (var notificationId in items.recentDismissals) {
267        if (currentTimeMs - items.recentDismissals[notificationId] <
268            DISMISS_RETENTION_TIME_MS) {
269          updatedRecentDismissals[notificationId] =
270              items.recentDismissals[notificationId];
271        }
272      }
273
274      // Mark existing notifications that received an update in this server
275      // response.
276      var updatedNotifications = {};
277
278      for (var i = 0; i < cards.length; ++i) {
279        var notificationId = cards[i].notificationId;
280        if (!(notificationId in updatedRecentDismissals) &&
281            notificationId in notifications) {
282          updatedNotifications[notificationId] = true;
283        }
284      }
285
286      // Delete notifications that didn't receive an update.
287      for (var notificationId in notifications) {
288        console.log('parseAndShowNotificationCards-delete-check ' +
289                    notificationId);
290        if (!(notificationId in updatedNotifications)) {
291          console.log('parseAndShowNotificationCards-delete ' + notificationId);
292          chrome.notifications.clear(
293              notificationId,
294              function() {});
295        }
296      }
297
298      recordEvent(DiagnosticEvent.CARDS_PARSE_SUCCESS);
299
300      // Create/update notifications and store their new properties.
301      var newNotificationsData = {};
302      for (var i = 0; i < cards.length; ++i) {
303        var card = cards[i];
304        if (!(card.notificationId in updatedRecentDismissals)) {
305          var notificationData = items.notificationsData[card.notificationId];
306          var previousVersion = notifications[card.notificationId] &&
307                                notificationData &&
308                                notificationData.previousVersion;
309          showNotification(card, newNotificationsData, previousVersion);
310        }
311      }
312
313      updateCardsAttempts.start(parsedResponse.expiration_timestamp_seconds);
314
315      storage.set({
316        notificationsData: newNotificationsData,
317        recentDismissals: updatedRecentDismissals
318      });
319      callback();
320    });
321  });
322}
323
324/**
325 * Requests notification cards from the server.
326 * @param {Location} position Location of this computer.
327 * @param {function()} callback Completion callback.
328 */
329function requestNotificationCards(position, callback) {
330  console.log('requestNotificationCards ' + JSON.stringify(position) +
331      ' from ' + NOTIFICATION_CARDS_URL);
332
333  if (!NOTIFICATION_CARDS_URL) {
334    callback();
335    return;
336  }
337
338  recordEvent(DiagnosticEvent.REQUEST_FOR_CARDS_TOTAL);
339
340  // TODO(vadimt): Should we use 'q' as the parameter name?
341  var requestParameters =
342      'q=' + position.coords.latitude +
343      ',' + position.coords.longitude +
344      ',' + position.coords.accuracy;
345
346  // TODO(vadimt): Figure out how to send user's identity to the server.
347  var request = buildServerRequest('notifications');
348
349  request.onloadend = tasks.wrapCallback(function(event) {
350    console.log('requestNotificationCards-onloadend ' + request.status);
351    if (request.status == HTTP_OK) {
352      recordEvent(DiagnosticEvent.REQUEST_FOR_CARDS_SUCCESS);
353      parseAndShowNotificationCards(request.response, callback);
354    } else {
355      callback();
356    }
357  });
358
359  tasks.debugSetStepName('requestNotificationCards-send-request');
360  request.send(requestParameters);
361}
362
363/**
364 * Starts getting location for a cards update.
365 */
366function requestLocation() {
367  console.log('requestLocation');
368  recordEvent(DiagnosticEvent.LOCATION_REQUEST);
369  // TODO(vadimt): Figure out location request options.
370  chrome.location.watchLocation(LOCATION_WATCH_NAME, {});
371}
372
373
374/**
375 * Obtains new location; requests and shows notification cards based on this
376 * location.
377 * @param {Location} position Location of this computer.
378 */
379function updateNotificationsCards(position) {
380  console.log('updateNotificationsCards ' + JSON.stringify(position) +
381      ' @' + new Date());
382  tasks.add(UPDATE_CARDS_TASK_NAME, function(callback) {
383    console.log('updateNotificationsCards-task-begin');
384    updateCardsAttempts.planForNext(function() {
385      processPendingDismissals(function(success) {
386        if (success) {
387          // The cards are requested only if there are no unsent dismissals.
388          requestNotificationCards(position, callback);
389        } else {
390          callback();
391        }
392      });
393    });
394  });
395}
396
397/**
398 * Sends a server request to dismiss a card.
399 * @param {string} notificationId Unique identifier of the card.
400 * @param {number} dismissalTimeMs Time of the user's dismissal of the card in
401 *     milliseconds since epoch.
402 * @param {function(boolean)} callbackBoolean Completion callback with 'success'
403 *     parameter.
404 */
405function requestCardDismissal(
406    notificationId, dismissalTimeMs, callbackBoolean) {
407  console.log('requestDismissingCard ' + notificationId + ' from ' +
408      NOTIFICATION_CARDS_URL);
409  recordEvent(DiagnosticEvent.DISMISS_REQUEST_TOTAL);
410  // Send a dismiss request to the server.
411  var requestParameters = 'id=' + notificationId +
412                          '&dismissalAge=' + (Date.now() - dismissalTimeMs);
413  var request = buildServerRequest('dismiss');
414  request.onloadend = tasks.wrapCallback(function(event) {
415    console.log('requestDismissingCard-onloadend ' + request.status);
416    if (request.status == HTTP_OK)
417      recordEvent(DiagnosticEvent.DISMISS_REQUEST_SUCCESS);
418
419    callbackBoolean(request.status == HTTP_OK);
420  });
421
422  tasks.debugSetStepName('requestCardDismissal-send-request');
423  request.send(requestParameters);
424}
425
426/**
427 * Tries to send dismiss requests for all pending dismissals.
428 * @param {function(boolean)} callbackBoolean Completion callback with 'success'
429 *     parameter. Success means that no pending dismissals are left.
430 */
431function processPendingDismissals(callbackBoolean) {
432  tasks.debugSetStepName('processPendingDismissals-storage-get');
433  storage.get(['pendingDismissals', 'recentDismissals'], function(items) {
434    console.log('processPendingDismissals-storage-get ' +
435                JSON.stringify(items));
436    items.pendingDismissals = items.pendingDismissals || [];
437    items.recentDismissals = items.recentDismissals || {};
438
439    var dismissalsChanged = false;
440
441    function onFinish(success) {
442      if (dismissalsChanged) {
443        storage.set({
444          pendingDismissals: items.pendingDismissals,
445          recentDismissals: items.recentDismissals
446        });
447      }
448      callbackBoolean(success);
449    }
450
451    function doProcessDismissals() {
452      if (items.pendingDismissals.length == 0) {
453        dismissalAttempts.stop();
454        onFinish(true);
455        return;
456      }
457
458      // Send dismissal for the first card, and if successful, repeat
459      // recursively with the rest.
460      var dismissal = items.pendingDismissals[0];
461      requestCardDismissal(
462          dismissal.notificationId, dismissal.time, function(success) {
463        if (success) {
464          dismissalsChanged = true;
465          items.pendingDismissals.splice(0, 1);
466          items.recentDismissals[dismissal.notificationId] = Date.now();
467          doProcessDismissals();
468        } else {
469          onFinish(false);
470        }
471      });
472    }
473
474    doProcessDismissals();
475  });
476}
477
478/**
479 * Submits a task to send pending dismissals.
480 */
481function retryPendingDismissals() {
482  tasks.add(RETRY_DISMISS_TASK_NAME, function(callback) {
483    dismissalAttempts.planForNext(function() {
484      processPendingDismissals(function(success) { callback(); });
485     });
486  });
487}
488
489/**
490 * Opens URL corresponding to the clicked part of the notification.
491 * @param {string} notificationId Unique identifier of the notification.
492 * @param {function(Object): string} selector Function that extracts the url for
493 *     the clicked area from the button action URLs info.
494 */
495function onNotificationClicked(notificationId, selector) {
496  storage.get('notificationsData', function(items) {
497    items.notificationsData = items.notificationsData || {};
498
499    var notificationData = items.notificationsData[notificationId];
500
501    if (!notificationData) {
502      // 'notificationsData' in storage may not match the actual list of
503      // notifications.
504      return;
505    }
506
507    var actionUrls = notificationData.actionUrls;
508    if (typeof actionUrls != 'object') {
509      return;
510    }
511
512    var url = selector(actionUrls);
513
514    if (typeof url != 'string')
515      return;
516
517    chrome.tabs.create({url: url}, function(tab) {
518      if (!tab)
519        chrome.windows.create({url: url});
520    });
521  });
522}
523
524/**
525 * Callback for chrome.notifications.onClosed event.
526 * @param {string} notificationId Unique identifier of the notification.
527 * @param {boolean} byUser Whether the notification was closed by the user.
528 */
529function onNotificationClosed(notificationId, byUser) {
530  if (!byUser)
531    return;
532
533  chrome.metricsPrivate.recordUserAction('GoogleNow.Dismissed');
534
535  tasks.add(DISMISS_CARD_TASK_NAME, function(callback) {
536    dismissalAttempts.start();
537
538    // Deleting the notification in case it was re-added while this task was
539    // scheduled, waiting for execution.
540    chrome.notifications.clear(
541        notificationId,
542        function() {});
543
544    tasks.debugSetStepName('onNotificationClosed-get-pendingDismissals');
545    storage.get('pendingDismissals', function(items) {
546      items.pendingDismissals = items.pendingDismissals || [];
547
548      var dismissal = {
549        notificationId: notificationId,
550        time: Date.now()
551      };
552      items.pendingDismissals.push(dismissal);
553      storage.set({pendingDismissals: items.pendingDismissals});
554      processPendingDismissals(function(success) { callback(); });
555    });
556  });
557}
558
559/**
560 * Initializes the event page on install or on browser startup.
561 */
562function initialize() {
563  // Create an update timer for a case when for some reason location request
564  // gets stuck.
565  updateCardsAttempts.start(MAXIMUM_POLLING_PERIOD_SECONDS);
566
567  requestLocation();
568}
569
570chrome.runtime.onInstalled.addListener(function(details) {
571  console.log('onInstalled ' + JSON.stringify(details));
572  if (details.reason != 'chrome_update') {
573    initialize();
574  }
575});
576
577chrome.runtime.onStartup.addListener(function() {
578  console.log('onStartup');
579  initialize();
580});
581
582chrome.notifications.onClicked.addListener(
583    function(notificationId) {
584      chrome.metricsPrivate.recordUserAction('GoogleNow.MessageClicked');
585      onNotificationClicked(notificationId, function(actionUrls) {
586        return actionUrls.messageUrl;
587      });
588    });
589
590chrome.notifications.onButtonClicked.addListener(
591    function(notificationId, buttonIndex) {
592      chrome.metricsPrivate.recordUserAction(
593          'GoogleNow.ButtonClicked' + buttonIndex);
594      onNotificationClicked(notificationId, function(actionUrls) {
595        if (!Array.isArray(actionUrls.buttonUrls))
596          return undefined;
597
598        return actionUrls.buttonUrls[buttonIndex];
599      });
600    });
601
602chrome.notifications.onClosed.addListener(onNotificationClosed);
603
604chrome.location.onLocationUpdate.addListener(function(position) {
605  recordEvent(DiagnosticEvent.LOCATION_UPDATE);
606  updateNotificationsCards(position);
607});
608
609chrome.omnibox.onInputEntered.addListener(function(text) {
610  localStorage['server_url'] = NOTIFICATION_CARDS_URL = text;
611  initialize();
612});
613