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 */ 28var HTTP_OK = 200; 29var HTTP_NOCONTENT = 204; 30 31var HTTP_BAD_REQUEST = 400; 32var HTTP_UNAUTHORIZED = 401; 33var HTTP_FORBIDDEN = 403; 34var HTTP_METHOD_NOT_ALLOWED = 405; 35 36var MS_IN_SECOND = 1000; 37var 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 */ 43var INITIAL_POLLING_PERIOD_SECONDS = 5 * 60; // 5 minutes 44 45/** 46 * Mininal period for polling for Google Now Notifications cards. 47 */ 48var 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 */ 54var MAXIMUM_POLLING_PERIOD_SECONDS = 60 * 60; // 1 hour 55 56/** 57 * Initial period for retrying the server request for dismissing cards. 58 */ 59var INITIAL_RETRY_DISMISS_PERIOD_SECONDS = 60; // 1 minute 60 61/** 62 * Maximum period for retrying the server request for dismissing cards. 63 */ 64var MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS = 60 * 60; // 1 hour 65 66/** 67 * Time we keep retrying dismissals. 68 */ 69var MAXIMUM_DISMISSAL_AGE_MS = 24 * 60 * 60 * 1000; // 1 day 70 71/** 72 * Time we keep dismissals after successful server dismiss requests. 73 */ 74var 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 */ 79var 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 */ 85var 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 */ 90var LOCATION_CARDS_LINK_THRESHOLD = 10; 91 92/** 93 * Names for tasks that can be created by the extension. 94 */ 95var UPDATE_CARDS_TASK_NAME = 'update-cards'; 96var DISMISS_CARD_TASK_NAME = 'dismiss-card'; 97var RETRY_DISMISS_TASK_NAME = 'retry-dismiss'; 98var STATE_CHANGED_TASK_NAME = 'state-changed'; 99var SHOW_ON_START_TASK_NAME = 'show-cards-on-start'; 100var ON_PUSH_MESSAGE_START_TASK_NAME = 'on-push-message'; 101 102var 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 */ 113var 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 */ 124var 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 */ 140var 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 */ 152var 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 */ 161function 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 181var tasks = buildTaskManager(areTasksConflicting); 182 183// Add error processing to API calls. 184wrapper.instrumentChromeApiFunction('location.onLocationUpdate.addListener', 0); 185wrapper.instrumentChromeApiFunction('metricsPrivate.getVariationParams', 1); 186wrapper.instrumentChromeApiFunction('notifications.clear', 1); 187wrapper.instrumentChromeApiFunction('notifications.create', 2); 188wrapper.instrumentChromeApiFunction('notifications.getPermissionLevel', 0); 189wrapper.instrumentChromeApiFunction('notifications.update', 2); 190wrapper.instrumentChromeApiFunction('notifications.getAll', 0); 191wrapper.instrumentChromeApiFunction( 192 'notifications.onButtonClicked.addListener', 0); 193wrapper.instrumentChromeApiFunction('notifications.onClicked.addListener', 0); 194wrapper.instrumentChromeApiFunction('notifications.onClosed.addListener', 0); 195wrapper.instrumentChromeApiFunction( 196 'notifications.onPermissionLevelChanged.addListener', 0); 197wrapper.instrumentChromeApiFunction( 198 'notifications.onShowSettings.addListener', 0); 199wrapper.instrumentChromeApiFunction( 200 'preferencesPrivate.googleGeolocationAccessEnabled.get', 201 1); 202wrapper.instrumentChromeApiFunction( 203 'preferencesPrivate.googleGeolocationAccessEnabled.onChange.addListener', 204 0); 205wrapper.instrumentChromeApiFunction('permissions.contains', 1); 206wrapper.instrumentChromeApiFunction('pushMessaging.onMessage.addListener', 0); 207wrapper.instrumentChromeApiFunction('runtime.onInstalled.addListener', 0); 208wrapper.instrumentChromeApiFunction('runtime.onStartup.addListener', 0); 209wrapper.instrumentChromeApiFunction('tabs.create', 1); 210wrapper.instrumentChromeApiFunction('storage.local.get', 1); 211 212var updateCardsAttempts = buildAttemptManager( 213 'cards-update', 214 requestLocation, 215 INITIAL_POLLING_PERIOD_SECONDS, 216 MAXIMUM_POLLING_PERIOD_SECONDS); 217var dismissalAttempts = buildAttemptManager( 218 'dismiss', 219 retryPendingDismissals, 220 INITIAL_RETRY_DISMISS_PERIOD_SECONDS, 221 MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS); 222var cardSet = buildCardSet(); 223 224var authenticationManager = buildAuthenticationManager(); 225 226/** 227 * Google Now UMA event identifier. 228 * @enum {number} 229 */ 230var 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 */ 250function 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 */ 268function 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 */ 304function 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 */ 340function 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 */ 363function 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 */ 391function 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 */ 430function 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 * Parses JSON response from the notification server, shows notifications and 446 * schedules next update. 447 * @param {string} response Server response. 448 * @param {function(ReceivedNotification)=} onCardShown Optional parameter 449 * called when each card is shown. 450 */ 451function parseAndShowNotificationCards(response, onCardShown) { 452 console.log('parseAndShowNotificationCards ' + response); 453 /** @type {ServerResponse} */ 454 var parsedResponse = JSON.parse(response); 455 456 if (parsedResponse.googleNowDisabled) { 457 chrome.storage.local.set({googleNowEnabled: false}); 458 // TODO(vadimt): Remove the line below once the server stops sending groups 459 // with 'googleNowDisabled' responses. 460 parsedResponse.groups = {}; 461 // Google Now was enabled; now it's disabled. This is a state change. 462 onStateChange(); 463 } 464 465 var receivedGroups = parsedResponse.groups; 466 467 instrumented.storage.local.get( 468 ['notificationGroups', 'recentDismissals'], 469 function(items) { 470 console.log( 471 'parseAndShowNotificationCards-get ' + JSON.stringify(items)); 472 items = items || {}; 473 /** @type {Object.<string, StoredNotificationGroup>} */ 474 items.notificationGroups = items.notificationGroups || {}; 475 /** @type {Object.<NotificationId, number>} */ 476 items.recentDismissals = items.recentDismissals || {}; 477 478 // Build a set of non-expired recent dismissals. It will be used for 479 // client-side filtering of cards. 480 /** @type {Object.<NotificationId, number>} */ 481 var updatedRecentDismissals = {}; 482 var now = Date.now(); 483 for (var notificationId in items.recentDismissals) { 484 var dismissalAge = now - items.recentDismissals[notificationId]; 485 if (dismissalAge < DISMISS_RETENTION_TIME_MS) { 486 updatedRecentDismissals[notificationId] = 487 items.recentDismissals[notificationId]; 488 } 489 } 490 491 // Populate groups with corresponding cards. 492 if (parsedResponse.notifications) { 493 for (var i = 0; i < parsedResponse.notifications.length; ++i) { 494 /** @type {ReceivedNotification} */ 495 var card = parsedResponse.notifications[i]; 496 if (!(card.notificationId in updatedRecentDismissals)) { 497 var group = receivedGroups[card.groupName]; 498 group.cards = group.cards || []; 499 group.cards.push(card); 500 } 501 } 502 } 503 504 // Build updated set of groups. 505 var updatedGroups = {}; 506 507 for (var groupName in receivedGroups) { 508 var receivedGroup = receivedGroups[groupName]; 509 var storedGroup = items.notificationGroups[groupName] || { 510 cards: [], 511 cardsTimestamp: undefined, 512 nextPollTime: undefined, 513 rank: undefined 514 }; 515 516 if (receivedGroup.requested) 517 receivedGroup.cards = receivedGroup.cards || []; 518 519 if (receivedGroup.cards) { 520 // If the group contains a cards update, all its fields will get new 521 // values. 522 storedGroup.cards = receivedGroup.cards; 523 storedGroup.cardsTimestamp = now; 524 storedGroup.rank = receivedGroup.rank; 525 storedGroup.nextPollTime = undefined; 526 // The code below assigns nextPollTime a defined value if 527 // nextPollSeconds is specified in the received group. 528 // If the group's cards are not updated, and nextPollSeconds is 529 // unspecified, this method doesn't change group's nextPollTime. 530 } 531 532 // 'nextPollSeconds' may be sent even for groups that don't contain 533 // cards updates. 534 if (receivedGroup.nextPollSeconds !== undefined) { 535 storedGroup.nextPollTime = 536 now + receivedGroup.nextPollSeconds * MS_IN_SECOND; 537 } 538 539 updatedGroups[groupName] = storedGroup; 540 } 541 542 scheduleNextPoll(updatedGroups, !parsedResponse.googleNowDisabled); 543 combineAndShowNotificationCards( 544 updatedGroups, 545 function() { 546 chrome.storage.local.set({ 547 notificationGroups: updatedGroups, 548 recentDismissals: updatedRecentDismissals 549 }); 550 recordEvent(GoogleNowEvent.CARDS_PARSE_SUCCESS); 551 }, 552 onCardShown); 553 }); 554} 555 556/** 557 * Update Location Cards Shown Count. 558 * @param {ReceivedNotification} receivedNotification Notification as it was 559 * received from the server. 560 */ 561function countLocationCard(receivedNotification) { 562 if (receivedNotification.locationBased) { 563 localStorage['locationCardsShown']++; 564 } 565} 566 567/** 568 * Requests notification cards from the server for specified groups. 569 * @param {Array.<string>} groupNames Names of groups that need to be refreshed. 570 */ 571function requestNotificationGroups(groupNames) { 572 console.log('requestNotificationGroups from ' + NOTIFICATION_CARDS_URL + 573 ', groupNames=' + JSON.stringify(groupNames)); 574 575 recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_TOTAL); 576 577 var requestParameters = '?timeZoneOffsetMs=' + 578 (-new Date().getTimezoneOffset() * MS_IN_MINUTE); 579 580 var cardShownCallback = undefined; 581 if (localStorage['locationCardsShown'] < LOCATION_CARDS_LINK_THRESHOLD) { 582 requestParameters += '&locationExplanation=true'; 583 cardShownCallback = countLocationCard; 584 } 585 586 groupNames.forEach(function(groupName) { 587 requestParameters += ('&requestTypes=' + groupName); 588 }); 589 590 console.log('requestNotificationGroups: request=' + requestParameters); 591 592 var request = buildServerRequest('GET', 'notifications' + requestParameters); 593 594 request.onloadend = function(event) { 595 console.log('requestNotificationGroups-onloadend ' + request.status); 596 if (request.status == HTTP_OK) { 597 recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_SUCCESS); 598 parseAndShowNotificationCards(request.responseText, cardShownCallback); 599 } 600 }; 601 602 setAuthorization(request, function(success) { 603 if (success) 604 request.send(); 605 }); 606} 607 608/** 609 * Requests the account opted-in state from the server. 610 * @param {function()} optedInCallback Function that will be called if 611 * opted-in state is 'true'. 612 */ 613function requestOptedIn(optedInCallback) { 614 console.log('requestOptedIn from ' + NOTIFICATION_CARDS_URL); 615 616 var request = buildServerRequest('GET', 'settings/optin'); 617 618 request.onloadend = function(event) { 619 console.log( 620 'requestOptedIn-onloadend ' + request.status + ' ' + request.response); 621 if (request.status == HTTP_OK) { 622 var parsedResponse = JSON.parse(request.responseText); 623 if (parsedResponse.value) { 624 chrome.storage.local.set({googleNowEnabled: true}); 625 optedInCallback(); 626 // Google Now was disabled, now it's enabled. This is a state change. 627 onStateChange(); 628 } else { 629 scheduleNextPoll({}, false); 630 } 631 } 632 }; 633 634 setAuthorization(request, function(success) { 635 if (success) 636 request.send(); 637 }); 638} 639 640/** 641 * Requests notification cards from the server. 642 * @param {Location=} position Location of this computer. 643 */ 644function requestNotificationCards(position) { 645 console.log('requestNotificationCards ' + JSON.stringify(position)); 646 647 instrumented.storage.local.get( 648 ['notificationGroups', 'googleNowEnabled'], function(items) { 649 console.log('requestNotificationCards-storage-get ' + 650 JSON.stringify(items)); 651 items = items || {}; 652 /** @type {Object.<string, StoredNotificationGroup>} */ 653 items.notificationGroups = items.notificationGroups || {}; 654 655 var groupsToRequest = []; 656 657 var now = Date.now(); 658 659 for (var groupName in items.notificationGroups) { 660 var group = items.notificationGroups[groupName]; 661 if (group.nextPollTime !== undefined && group.nextPollTime <= now) 662 groupsToRequest.push(groupName); 663 } 664 665 if (items.googleNowEnabled) { 666 requestNotificationGroups(groupsToRequest); 667 } else { 668 requestOptedIn(function() { 669 requestNotificationGroups(groupsToRequest); 670 }); 671 } 672 }); 673} 674 675/** 676 * Starts getting location for a cards update. 677 */ 678function requestLocation() { 679 console.log('requestLocation'); 680 recordEvent(GoogleNowEvent.LOCATION_REQUEST); 681 // TODO(vadimt): Figure out location request options. 682 instrumented.metricsPrivate.getVariationParams('GoogleNow', function(params) { 683 var minDistanceInMeters = 684 parseInt(params && params.minDistanceInMeters, 10) || 685 100; 686 var minTimeInMilliseconds = 687 parseInt(params && params.minTimeInMilliseconds, 10) || 688 180000; // 3 minutes. 689 690 // TODO(vadimt): Uncomment/remove watchLocation and remove invoking 691 // updateNotificationsCards once state machine design is finalized. 692// chrome.location.watchLocation(LOCATION_WATCH_NAME, { 693// minDistanceInMeters: minDistanceInMeters, 694// minTimeInMilliseconds: minTimeInMilliseconds 695// }); 696 // We need setTimeout to avoid recursive task creation. This is a temporary 697 // code, and it will be removed once we finally decide to send or not send 698 // client location to the server. 699 setTimeout(wrapper.wrapCallback(updateNotificationsCards, true), 0); 700 }); 701} 702 703/** 704 * Stops getting the location. 705 */ 706function stopRequestLocation() { 707 console.log('stopRequestLocation'); 708 chrome.location.clearWatch(LOCATION_WATCH_NAME); 709} 710 711/** 712 * Obtains new location; requests and shows notification cards based on this 713 * location. 714 * @param {Location=} position Location of this computer. 715 */ 716function updateNotificationsCards(position) { 717 console.log('updateNotificationsCards ' + JSON.stringify(position) + 718 ' @' + new Date()); 719 tasks.add(UPDATE_CARDS_TASK_NAME, function() { 720 console.log('updateNotificationsCards-task-begin'); 721 updateCardsAttempts.isRunning(function(running) { 722 if (running) { 723 updateCardsAttempts.planForNext(function() { 724 processPendingDismissals(function(success) { 725 if (success) { 726 // The cards are requested only if there are no unsent dismissals. 727 requestNotificationCards(position); 728 } 729 }); 730 }); 731 } 732 }); 733 }); 734} 735 736/** 737 * Sends a server request to dismiss a card. 738 * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of 739 * the card. 740 * @param {number} dismissalTimeMs Time of the user's dismissal of the card in 741 * milliseconds since epoch. 742 * @param {DismissalData} dismissalData Data to build a dismissal request. 743 * @param {function(boolean)} callbackBoolean Completion callback with 'done' 744 * parameter. 745 */ 746function requestCardDismissal( 747 chromeNotificationId, dismissalTimeMs, dismissalData, callbackBoolean) { 748 console.log('requestDismissingCard ' + chromeNotificationId + 749 ' from ' + NOTIFICATION_CARDS_URL + 750 ', dismissalData=' + JSON.stringify(dismissalData)); 751 752 var dismissalAge = Date.now() - dismissalTimeMs; 753 754 if (dismissalAge > MAXIMUM_DISMISSAL_AGE_MS) { 755 callbackBoolean(true); 756 return; 757 } 758 759 recordEvent(GoogleNowEvent.DISMISS_REQUEST_TOTAL); 760 761 var requestParameters = 'notifications/' + dismissalData.notificationId + 762 '?age=' + dismissalAge + 763 '&chromeNotificationId=' + chromeNotificationId; 764 765 for (var paramField in dismissalData.parameters) 766 requestParameters += ('&' + paramField + 767 '=' + dismissalData.parameters[paramField]); 768 769 console.log('requestCardDismissal: requestParameters=' + requestParameters); 770 771 var request = buildServerRequest('DELETE', requestParameters); 772 request.onloadend = function(event) { 773 console.log('requestDismissingCard-onloadend ' + request.status); 774 if (request.status == HTTP_NOCONTENT) 775 recordEvent(GoogleNowEvent.DISMISS_REQUEST_SUCCESS); 776 777 // A dismissal doesn't require further retries if it was successful or 778 // doesn't have a chance for successful completion. 779 var done = request.status == HTTP_NOCONTENT || 780 request.status == HTTP_BAD_REQUEST || 781 request.status == HTTP_METHOD_NOT_ALLOWED; 782 callbackBoolean(done); 783 }; 784 785 setAuthorization(request, function(success) { 786 if (success) 787 request.send(); 788 else 789 callbackBoolean(false); 790 }); 791} 792 793/** 794 * Tries to send dismiss requests for all pending dismissals. 795 * @param {function(boolean)} callbackBoolean Completion callback with 'success' 796 * parameter. Success means that no pending dismissals are left. 797 */ 798function processPendingDismissals(callbackBoolean) { 799 instrumented.storage.local.get(['pendingDismissals', 'recentDismissals'], 800 function(items) { 801 console.log('processPendingDismissals-storage-get ' + 802 JSON.stringify(items)); 803 items = items || {}; 804 /** @type {Array.<PendingDismissal>} */ 805 items.pendingDismissals = items.pendingDismissals || []; 806 /** @type {Object.<NotificationId, number>} */ 807 items.recentDismissals = items.recentDismissals || {}; 808 809 var dismissalsChanged = false; 810 811 function onFinish(success) { 812 if (dismissalsChanged) { 813 chrome.storage.local.set({ 814 pendingDismissals: items.pendingDismissals, 815 recentDismissals: items.recentDismissals 816 }); 817 } 818 callbackBoolean(success); 819 } 820 821 function doProcessDismissals() { 822 if (items.pendingDismissals.length == 0) { 823 dismissalAttempts.stop(); 824 onFinish(true); 825 return; 826 } 827 828 // Send dismissal for the first card, and if successful, repeat 829 // recursively with the rest. 830 /** @type {PendingDismissal} */ 831 var dismissal = items.pendingDismissals[0]; 832 requestCardDismissal( 833 dismissal.chromeNotificationId, 834 dismissal.time, 835 dismissal.dismissalData, 836 function(done) { 837 if (done) { 838 dismissalsChanged = true; 839 items.pendingDismissals.splice(0, 1); 840 items.recentDismissals[ 841 dismissal.dismissalData.notificationId] = 842 Date.now(); 843 doProcessDismissals(); 844 } else { 845 onFinish(false); 846 } 847 }); 848 } 849 850 doProcessDismissals(); 851 }); 852} 853 854/** 855 * Submits a task to send pending dismissals. 856 */ 857function retryPendingDismissals() { 858 tasks.add(RETRY_DISMISS_TASK_NAME, function() { 859 dismissalAttempts.planForNext(function() { 860 processPendingDismissals(function(success) {}); 861 }); 862 }); 863} 864 865/** 866 * Opens a URL in a new tab. 867 * @param {string} url URL to open. 868 */ 869function openUrl(url) { 870 instrumented.tabs.create({url: url}, function(tab) { 871 if (tab) 872 chrome.windows.update(tab.windowId, {focused: true}); 873 else 874 chrome.windows.create({url: url, focused: true}); 875 }); 876} 877 878/** 879 * Opens URL corresponding to the clicked part of the notification. 880 * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of 881 * the card. 882 * @param {function((ActionUrls|undefined)): (string|undefined)} selector 883 * Function that extracts the url for the clicked area from the button 884 * action URLs info. 885 */ 886function onNotificationClicked(chromeNotificationId, selector) { 887 instrumented.storage.local.get('notificationsData', function(items) { 888 /** @type {(NotificationDataEntry|undefined)} */ 889 var notificationData = items && 890 items.notificationsData && 891 items.notificationsData[chromeNotificationId]; 892 893 if (!notificationData) 894 return; 895 896 var url = selector(notificationData.actionUrls); 897 if (!url) 898 return; 899 900 openUrl(url); 901 }); 902} 903 904/** 905 * Callback for chrome.notifications.onClosed event. 906 * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of 907 * the card. 908 * @param {boolean} byUser Whether the notification was closed by the user. 909 */ 910function onNotificationClosed(chromeNotificationId, byUser) { 911 if (!byUser) 912 return; 913 914 // At this point we are guaranteed that the notification is a now card. 915 chrome.metricsPrivate.recordUserAction('GoogleNow.Dismissed'); 916 917 tasks.add(DISMISS_CARD_TASK_NAME, function() { 918 dismissalAttempts.start(); 919 920 instrumented.storage.local.get( 921 ['pendingDismissals', 'notificationsData', 'notificationGroups'], 922 function(items) { 923 items = items || {}; 924 /** @type {Array.<PendingDismissal>} */ 925 items.pendingDismissals = items.pendingDismissals || []; 926 /** @type {Object.<string, NotificationDataEntry>} */ 927 items.notificationsData = items.notificationsData || {}; 928 /** @type {Object.<string, StoredNotificationGroup>} */ 929 items.notificationGroups = items.notificationGroups || {}; 930 931 /** @type {NotificationDataEntry} */ 932 var notificationData = 933 items.notificationsData[chromeNotificationId] || 934 { 935 timestamp: Date.now(), 936 combinedCard: [] 937 }; 938 939 var dismissalResult = 940 cardSet.onDismissal( 941 chromeNotificationId, 942 notificationData, 943 items.notificationGroups); 944 945 for (var i = 0; i < dismissalResult.dismissals.length; i++) { 946 /** @type {PendingDismissal} */ 947 var dismissal = { 948 chromeNotificationId: chromeNotificationId, 949 time: Date.now(), 950 dismissalData: dismissalResult.dismissals[i] 951 }; 952 items.pendingDismissals.push(dismissal); 953 } 954 955 items.notificationsData[chromeNotificationId] = 956 dismissalResult.notificationData; 957 958 chrome.storage.local.set(items); 959 960 processPendingDismissals(function(success) {}); 961 }); 962 }); 963} 964 965/** 966 * Initializes the polling system to start monitoring location and fetching 967 * cards. 968 */ 969function startPollingCards() { 970 // Create an update timer for a case when for some reason location request 971 // gets stuck. 972 updateCardsAttempts.start(MAXIMUM_POLLING_PERIOD_SECONDS); 973 974 requestLocation(); 975} 976 977/** 978 * Stops all machinery in the polling system. 979 */ 980function stopPollingCards() { 981 stopRequestLocation(); 982 updateCardsAttempts.stop(); 983 removeAllCards(); 984 // Mark the Google Now as disabled to start with checking the opt-in state 985 // next time startPollingCards() is called. 986 chrome.storage.local.set({googleNowEnabled: false}); 987} 988 989/** 990 * Initializes the event page on install or on browser startup. 991 */ 992function initialize() { 993 recordEvent(GoogleNowEvent.EXTENSION_START); 994 onStateChange(); 995} 996 997/** 998 * Starts or stops the polling of cards. 999 * @param {boolean} shouldPollCardsRequest true to start and 1000 * false to stop polling cards. 1001 */ 1002function setShouldPollCards(shouldPollCardsRequest) { 1003 updateCardsAttempts.isRunning(function(currentValue) { 1004 if (shouldPollCardsRequest != currentValue) { 1005 console.log('Action Taken setShouldPollCards=' + shouldPollCardsRequest); 1006 if (shouldPollCardsRequest) 1007 startPollingCards(); 1008 else 1009 stopPollingCards(); 1010 } else { 1011 console.log( 1012 'Action Ignored setShouldPollCards=' + shouldPollCardsRequest); 1013 } 1014 }); 1015} 1016 1017/** 1018 * Enables or disables the Google Now background permission. 1019 * @param {boolean} backgroundEnable true to run in the background. 1020 * false to not run in the background. 1021 */ 1022function setBackgroundEnable(backgroundEnable) { 1023 instrumented.permissions.contains({permissions: ['background']}, 1024 function(hasPermission) { 1025 if (backgroundEnable != hasPermission) { 1026 console.log('Action Taken setBackgroundEnable=' + backgroundEnable); 1027 if (backgroundEnable) 1028 chrome.permissions.request({permissions: ['background']}); 1029 else 1030 chrome.permissions.remove({permissions: ['background']}); 1031 } else { 1032 console.log('Action Ignored setBackgroundEnable=' + backgroundEnable); 1033 } 1034 }); 1035} 1036 1037/** 1038 * Does the actual work of deciding what Google Now should do 1039 * based off of the current state of Chrome. 1040 * @param {boolean} signedIn true if the user is signed in. 1041 * @param {boolean} geolocationEnabled true if 1042 * the geolocation option is enabled. 1043 * @param {boolean} canEnableBackground true if 1044 * the background permission can be requested. 1045 * @param {boolean} notificationEnabled true if 1046 * Google Now for Chrome is allowed to show notifications. 1047 * @param {boolean} googleNowEnabled true if 1048 * the Google Now is enabled for the user. 1049 */ 1050function updateRunningState( 1051 signedIn, 1052 geolocationEnabled, 1053 canEnableBackground, 1054 notificationEnabled, 1055 googleNowEnabled) { 1056 console.log( 1057 'State Update signedIn=' + signedIn + ' ' + 1058 'geolocationEnabled=' + geolocationEnabled + ' ' + 1059 'canEnableBackground=' + canEnableBackground + ' ' + 1060 'notificationEnabled=' + notificationEnabled + ' ' + 1061 'googleNowEnabled=' + googleNowEnabled); 1062 1063 // TODO(vadimt): Remove this line once state machine design is finalized. 1064 geolocationEnabled = true; 1065 1066 var shouldPollCards = false; 1067 var shouldSetBackground = false; 1068 1069 if (signedIn && notificationEnabled) { 1070 if (geolocationEnabled) { 1071 if (canEnableBackground && googleNowEnabled) 1072 shouldSetBackground = true; 1073 1074 shouldPollCards = true; 1075 } 1076 } else { 1077 recordEvent(GoogleNowEvent.STOPPED); 1078 } 1079 1080 console.log( 1081 'Requested Actions shouldSetBackground=' + shouldSetBackground + ' ' + 1082 'setShouldPollCards=' + shouldPollCards); 1083 1084 setBackgroundEnable(shouldSetBackground); 1085 setShouldPollCards(shouldPollCards); 1086} 1087 1088/** 1089 * Coordinates the behavior of Google Now for Chrome depending on 1090 * Chrome and extension state. 1091 */ 1092function onStateChange() { 1093 tasks.add(STATE_CHANGED_TASK_NAME, function() { 1094 authenticationManager.isSignedIn(function(signedIn) { 1095 instrumented.metricsPrivate.getVariationParams( 1096 'GoogleNow', 1097 function(response) { 1098 var canEnableBackground = 1099 (!response || (response.canEnableBackground != 'false')); 1100 instrumented.notifications.getPermissionLevel(function(level) { 1101 var notificationEnabled = (level == 'granted'); 1102 instrumented. 1103 preferencesPrivate. 1104 googleGeolocationAccessEnabled. 1105 get({}, function(prefValue) { 1106 var geolocationEnabled = !!prefValue.value; 1107 instrumented.storage.local.get( 1108 'googleNowEnabled', 1109 function(items) { 1110 var googleNowEnabled = 1111 items && !!items.googleNowEnabled; 1112 updateRunningState( 1113 signedIn, 1114 geolocationEnabled, 1115 canEnableBackground, 1116 notificationEnabled, 1117 googleNowEnabled); 1118 }); 1119 }); 1120 }); 1121 }); 1122 }); 1123 }); 1124} 1125 1126instrumented.runtime.onInstalled.addListener(function(details) { 1127 console.log('onInstalled ' + JSON.stringify(details)); 1128 if (details.reason != 'chrome_update') { 1129 initialize(); 1130 } 1131}); 1132 1133instrumented.runtime.onStartup.addListener(function() { 1134 console.log('onStartup'); 1135 1136 // Show notifications received by earlier polls. Doing this as early as 1137 // possible to reduce latency of showing first notifications. This mimics how 1138 // persistent notifications will work. 1139 tasks.add(SHOW_ON_START_TASK_NAME, function() { 1140 instrumented.storage.local.get('notificationGroups', function(items) { 1141 console.log('onStartup-get ' + JSON.stringify(items)); 1142 items = items || {}; 1143 /** @type {Object.<string, StoredNotificationGroup>} */ 1144 items.notificationGroups = items.notificationGroups || {}; 1145 1146 combineAndShowNotificationCards(items.notificationGroups, function() { 1147 chrome.storage.local.set(items); 1148 }); 1149 }); 1150 }); 1151 1152 initialize(); 1153}); 1154 1155instrumented. 1156 preferencesPrivate. 1157 googleGeolocationAccessEnabled. 1158 onChange. 1159 addListener(function(prefValue) { 1160 console.log('googleGeolocationAccessEnabled Pref onChange ' + 1161 prefValue.value); 1162 onStateChange(); 1163}); 1164 1165authenticationManager.addListener(function() { 1166 console.log('signIn State Change'); 1167 onStateChange(); 1168}); 1169 1170instrumented.notifications.onClicked.addListener( 1171 function(chromeNotificationId) { 1172 chrome.metricsPrivate.recordUserAction('GoogleNow.MessageClicked'); 1173 onNotificationClicked(chromeNotificationId, function(actionUrls) { 1174 return actionUrls && actionUrls.messageUrl; 1175 }); 1176 }); 1177 1178instrumented.notifications.onButtonClicked.addListener( 1179 function(chromeNotificationId, buttonIndex) { 1180 chrome.metricsPrivate.recordUserAction( 1181 'GoogleNow.ButtonClicked' + buttonIndex); 1182 onNotificationClicked(chromeNotificationId, function(actionUrls) { 1183 var url = actionUrls.buttonUrls[buttonIndex]; 1184 verify(url !== undefined, 'onButtonClicked: no url for a button'); 1185 return url; 1186 }); 1187 }); 1188 1189instrumented.notifications.onClosed.addListener(onNotificationClosed); 1190 1191instrumented.notifications.onPermissionLevelChanged.addListener( 1192 function(permissionLevel) { 1193 console.log('Notifications permissionLevel Change'); 1194 onStateChange(); 1195 }); 1196 1197instrumented.notifications.onShowSettings.addListener(function() { 1198 openUrl(SETTINGS_URL); 1199}); 1200 1201instrumented.location.onLocationUpdate.addListener(function(position) { 1202 recordEvent(GoogleNowEvent.LOCATION_UPDATE); 1203 updateNotificationsCards(position); 1204}); 1205 1206instrumented.pushMessaging.onMessage.addListener(function(message) { 1207 // message.payload will be '' when the extension first starts. 1208 // Each time after signing in, we'll get latest payload for all channels. 1209 // So, we need to poll the server only when the payload is non-empty and has 1210 // changed. 1211 console.log('pushMessaging.onMessage ' + JSON.stringify(message)); 1212 if (message.payload.indexOf('REQUEST_CARDS') == 0) { 1213 tasks.add(ON_PUSH_MESSAGE_START_TASK_NAME, function() { 1214 instrumented.storage.local.get( 1215 ['lastPollNowPayloads', 'notificationGroups'], function(items) { 1216 // If storage.get fails, it's safer to do nothing, preventing polling 1217 // the server when the payload really didn't change. 1218 if (!items) 1219 return; 1220 1221 // If this is the first time we get lastPollNowPayloads, initialize it. 1222 items.lastPollNowPayloads = items.lastPollNowPayloads || {}; 1223 1224 if (items.lastPollNowPayloads[message.subchannelId] != 1225 message.payload) { 1226 items.lastPollNowPayloads[message.subchannelId] = message.payload; 1227 1228 /** @type {Object.<string, StoredNotificationGroup>} */ 1229 items.notificationGroups = items.notificationGroups || {}; 1230 items.notificationGroups['PUSH' + message.subchannelId] = { 1231 cards: [], 1232 nextPollTime: Date.now() 1233 }; 1234 1235 chrome.storage.local.set({ 1236 lastPollNowPayloads: items.lastPollNowPayloads, 1237 notificationGroups: items.notificationGroups 1238 }); 1239 1240 updateNotificationsCards(); 1241 } 1242 }); 1243 }); 1244 } 1245}); 1246