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