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