| Index: chrome/browser/resources/google_now/cards.js | 
| diff --git a/chrome/browser/resources/google_now/cards.js b/chrome/browser/resources/google_now/cards.js | 
| index 865db8a36b649f97608886f4f016e306370326e7..945c0d54caf8a13f1b1c647a5db7488d753c2bf5 100644 | 
| --- a/chrome/browser/resources/google_now/cards.js | 
| +++ b/chrome/browser/resources/google_now/cards.js | 
| @@ -8,237 +8,334 @@ | 
| * Show/hide trigger in a card. | 
| * | 
| * @typedef {{ | 
| - *   showTime: number=, | 
| - *   hideTime: number= | 
| + *   showTimeSec: (string|undefined), | 
| + *   hideTimeSec: string | 
| * }} | 
| */ | 
| var Trigger; | 
|  | 
| /** | 
| + * ID of an individual (uncombined) notification. | 
| + * | 
| + * @typedef {string} | 
| + */ | 
| +var NotificationId; | 
| + | 
| +/** | 
| * Data to build a dismissal request for a card from a specific group. | 
| * | 
| * @typedef {{ | 
| - *   notificationId: string, | 
| + *   notificationId: NotificationId, | 
| *   parameters: Object | 
| * }} | 
| */ | 
| var DismissalData; | 
|  | 
| /** | 
| - * Card merged from potentially multiple groups. | 
| + * Urls that need to be opened when clicking a notification or its buttons. | 
| * | 
| * @typedef {{ | 
| + *   messageUrl: (string|undefined), | 
| + *   buttonUrls: (Array.<string>|undefined) | 
| + * }} | 
| + */ | 
| +var ActionUrls; | 
| + | 
| +/** | 
| + * ID of a combined notification. This is the ID used with chrome.notifications | 
| + * API. | 
| + * | 
| + * @typedef {string} | 
| + */ | 
| +var ChromeNotificationId; | 
| + | 
| +/** | 
| + * Notification as sent by the server. | 
| + * | 
| + * @typedef {{ | 
| + *   notificationId: NotificationId, | 
| + *   chromeNotificationId: ChromeNotificationId, | 
| *   trigger: Trigger, | 
| - *   version: number, | 
| - *   timestamp: number, | 
| - *   notification: Object, | 
| - *   actionUrls: Object=, | 
| - *   groupRank: number, | 
| - *   dismissals: Array.<DismissalData>, | 
| - *   locationBased: boolean= | 
| + *   chromeNotificationOptions: Object, | 
| + *   actionUrls: (ActionUrls|undefined), | 
| + *   dismissal: Object, | 
| + *   locationBased: (boolean|undefined), | 
| + *   groupName: string | 
| * }} | 
| */ | 
| -var MergedCard; | 
| +var ReceivedNotification; | 
|  | 
| /** | 
| - * Set of parameters for creating card notification. | 
| + * Received notification in a self-sufficient form that doesn't require group's | 
| + * timestamp to calculate show and hide times. | 
| * | 
| * @typedef {{ | 
| - *   notification: Object, | 
| - *   hideTime: number=, | 
| - *   version: number, | 
| - *   previousVersion: number=, | 
| - *   locationBased: boolean= | 
| + *   receivedNotification: ReceivedNotification, | 
| + *   showTime: (number|undefined), | 
| + *   hideTime: number | 
| * }} | 
| */ | 
| -var CardCreateInfo; | 
| +var UncombinedNotification; | 
| + | 
| +/** | 
| + * Card combined from potentially multiple groups. | 
| + * | 
| + * @typedef {Array.<UncombinedNotification>} | 
| + */ | 
| +var CombinedCard; | 
| + | 
| +/** | 
| + * Data entry that we store for every Chrome notification. | 
| + * |timestamp| is the time when corresponding Chrome notification was created or | 
| + * updated last time by cardSet.update(). | 
| + * | 
| + * @typedef {{ | 
| + *   actionUrls: (ActionUrls|undefined), | 
| + *   timestamp: number, | 
| + *   combinedCard: CombinedCard | 
| + * }} | 
| + * | 
| + */ | 
| + var NotificationDataEntry; | 
|  | 
| /** | 
| * Names for tasks that can be created by the this file. | 
| */ | 
| -var SHOW_CARD_TASK_NAME = 'show-card'; | 
| -var CLEAR_CARD_TASK_NAME = 'clear-card'; | 
| +var UPDATE_CARD_TASK_NAME = 'update-card'; | 
|  | 
| /** | 
| * Builds an object to manage notification card set. | 
| * @return {Object} Card set interface. | 
| */ | 
| function buildCardSet() { | 
| -  var cardShowPrefix = 'card-show-'; | 
| -  var cardHidePrefix = 'card-hide-'; | 
| +  var alarmPrefix = 'card-'; | 
|  | 
| /** | 
| -   * Schedules hiding a notification. | 
| -   * @param {string} cardId Card ID. | 
| -   * @param {number=} opt_timeHide If specified, epoch time to hide the card. If | 
| -   *      undefined, the card will be kept shown at least until next update. | 
| +   * Creates/updates/deletes a Chrome notification. | 
| +   * @param {ChromeNotificationId} cardId Card ID. | 
| +   * @param {?ReceivedNotification} receivedNotification Google Now card | 
| +   *     represented as a set of parameters for showing a Chrome notification, | 
| +   *     or null if the notification needs to be deleted. | 
| +   * @param {function(ReceivedNotification)=} onCardShown Optional parameter | 
| +   *     called when each card is shown. | 
| */ | 
| -  function scheduleHiding(cardId, opt_timeHide) { | 
| -    if (opt_timeHide === undefined) | 
| +  function updateNotification(cardId, receivedNotification, onCardShown) { | 
| +    console.log('cardManager.updateNotification ' + cardId + ' ' + | 
| +                JSON.stringify(receivedNotification)); | 
| + | 
| +    if (!receivedNotification) { | 
| +      instrumented.notifications.clear(cardId, function() {}); | 
| return; | 
| +    } | 
|  | 
| -    var alarmName = cardHidePrefix + cardId; | 
| -    var alarmInfo = {when: opt_timeHide}; | 
| -    chrome.alarms.create(alarmName, alarmInfo); | 
| +    // Try updating the notification. | 
| +    instrumented.notifications.update( | 
| +        cardId, | 
| +        receivedNotification.chromeNotificationOptions, | 
| +        function(wasUpdated) { | 
| +          if (!wasUpdated) { | 
| +            // If the notification wasn't updated, it probably didn't exist. | 
| +            // Create it. | 
| +            console.log('cardManager.updateNotification ' + cardId + | 
| +                        ' failed to update, creating'); | 
| +            instrumented.notifications.create( | 
| +                cardId, | 
| +                receivedNotification.chromeNotificationOptions, | 
| +                function(newNotificationId) { | 
| +                  if (!newNotificationId || chrome.runtime.lastError) { | 
| +                    var errorMessage = chrome.runtime.lastError && | 
| +                                       chrome.runtime.lastError.message; | 
| +                    console.error('notifications.create: ID=' + | 
| +                        newNotificationId + ', ERROR=' + errorMessage); | 
| +                    return; | 
| +                  } | 
| + | 
| +                  if (onCardShown !== undefined) | 
| +                    onCardShown(receivedNotification); | 
| +                }); | 
| +          } | 
| +        }); | 
| } | 
|  | 
| /** | 
| -   * Shows a notification. | 
| -   * @param {string} cardId Card ID. | 
| -   * @param {CardCreateInfo} cardCreateInfo Google Now card represented as a set | 
| -   *     of parameters for showing a Chrome notification. | 
| -   * @param {function(CardCreateInfo)=} onCardShown Optional parameter called | 
| -   *     when each card is shown. | 
| +   * Iterates uncombined notifications in a combined card, determining for | 
| +   * each whether it's visible at the specified moment. | 
| +   * @param {CombinedCard} combinedCard The combined card in question. | 
| +   * @param {number} timestamp Time for which to calculate visibility. | 
| +   * @param {function(UncombinedNotification, boolean)} callback Function | 
| +   *     invoked for every uncombined notification in |combinedCard|. | 
| +   *     The boolean parameter indicates whether the uncombined notification is | 
| +   *     visible at |timestamp|. | 
| */ | 
| -  function showNotification(cardId, cardCreateInfo, onCardShown) { | 
| -    console.log('cardManager.showNotification ' + cardId + ' ' + | 
| -                JSON.stringify(cardCreateInfo)); | 
| - | 
| -    if (cardCreateInfo.hideTime <= Date.now()) { | 
| -      console.log('cardManager.showNotification ' + cardId + ': expired'); | 
| -      // Card has expired. Schedule hiding to delete asociated information. | 
| -      scheduleHiding(cardId, cardCreateInfo.hideTime); | 
| -      return; | 
| +  function iterateUncombinedNotifications(combinedCard, timestamp, callback) { | 
| +    for (var i = 0; i != combinedCard.length; ++i) { | 
| +      var uncombinedNotification = combinedCard[i]; | 
| +      var shouldShow = !uncombinedNotification.showTime || | 
| +          uncombinedNotification.showTime <= timestamp; | 
| +      var shouldHide = uncombinedNotification.hideTime <= timestamp; | 
| + | 
| +      callback(uncombinedNotification, shouldShow && !shouldHide); | 
| } | 
| +  } | 
|  | 
| -    if (cardCreateInfo.previousVersion !== cardCreateInfo.version) { | 
| -      // Delete a notification with the specified id if it already exists, and | 
| -      // then create a notification. | 
| -      instrumented.notifications.create( | 
| -          cardId, | 
| -          cardCreateInfo.notification, | 
| -          function(newNotificationId) { | 
| -            if (!newNotificationId || chrome.runtime.lastError) { | 
| -              var errorMessage = chrome.runtime.lastError && | 
| -                                 chrome.runtime.lastError.message; | 
| -              console.error('notifications.create: ID=' + newNotificationId + | 
| -                            ', ERROR=' + errorMessage); | 
| -              return; | 
| -            } | 
| +  /** | 
| +   * Refreshes (shows/hides) the notification corresponding to the combined card | 
| +   * based on the current time and show-hide intervals in the combined card. | 
| +   * @param {ChromeNotificationId} cardId Card ID. | 
| +   * @param {CombinedCard} combinedCard Combined cards with |cardId|. | 
| +   * @param {function(ReceivedNotification)=} onCardShown Optional parameter | 
| +   *     called when each card is shown. | 
| +   * @return {(NotificationDataEntry|undefined)} Notification data entry for | 
| +   *     this card. It's 'undefined' if the card's life is over. | 
| +   */ | 
| +  function update(cardId, combinedCard, onCardShown) { | 
| +    console.log('cardManager.update ' + JSON.stringify(combinedCard)); | 
|  | 
| -            if (onCardShown !== undefined) | 
| -              onCardShown(cardCreateInfo); | 
| +    chrome.alarms.clear(alarmPrefix + cardId); | 
| +    var now = Date.now(); | 
| +    /** @type {?UncombinedNotification} */ | 
| +    var winningCard = null; | 
| +    // Next moment of time when winning notification selection algotithm can | 
| +    // potentially return a different notification. | 
| +    /** @type {?number} */ | 
| +    var nextEventTime = null; | 
|  | 
| -            scheduleHiding(cardId, cardCreateInfo.hideTime); | 
| -          }); | 
| -    } else { | 
| -      // Update existing notification. | 
| -      instrumented.notifications.update( | 
| -          cardId, | 
| -          cardCreateInfo.notification, | 
| -          function(wasUpdated) { | 
| -            if (!wasUpdated || chrome.runtime.lastError) { | 
| -              var errorMessage = chrome.runtime.lastError && | 
| -                                 chrome.runtime.lastError.message; | 
| -              console.error('notifications.update: UPDATED=' + wasUpdated + | 
| -                            ', ERROR=' + errorMessage); | 
| -              return; | 
| +    // Find a winning uncombined notification: a highest-priority notification | 
| +    // that needs to be shown now. | 
| +    iterateUncombinedNotifications( | 
| +        combinedCard, | 
| +        now, | 
| +        function(uncombinedCard, visible) { | 
| +          // If the uncombined notification is visible now and set the winning | 
| +          // card to it if its priority is higher. | 
| +          if (visible) { | 
| +            if (!winningCard || | 
| +                uncombinedCard.receivedNotification.chromeNotificationOptions. | 
| +                    priority > | 
| +                winningCard.receivedNotification.chromeNotificationOptions. | 
| +                    priority) { | 
| +              winningCard = uncombinedCard; | 
| } | 
| +          } | 
|  | 
| -            scheduleHiding(cardId, cardCreateInfo.hideTime); | 
| -          }); | 
| +          // Next event time is the closest hide or show event. | 
| +          if (uncombinedCard.showTime && uncombinedCard.showTime > now) { | 
| +            if (!nextEventTime || nextEventTime > uncombinedCard.showTime) | 
| +              nextEventTime = uncombinedCard.showTime; | 
| +          } | 
| +          if (uncombinedCard.hideTime > now) { | 
| +            if (!nextEventTime || nextEventTime > uncombinedCard.hideTime) | 
| +              nextEventTime = uncombinedCard.hideTime; | 
| +          } | 
| +        }); | 
| + | 
| +    // Show/hide the winning card. | 
| +    updateNotification( | 
| +        cardId, winningCard && winningCard.receivedNotification, onCardShown); | 
| + | 
| +    if (nextEventTime) { | 
| +      // If we expect more events, create an alarm for the next one. | 
| +      chrome.alarms.create(alarmPrefix + cardId, {when: nextEventTime}); | 
| + | 
| +      // The trick with stringify/parse is to create a copy of action URLs, | 
| +      // otherwise notifications data with 2 pointers to the same object won't | 
| +      // be stored correctly to chrome.storage. | 
| +      var winningActionUrls = winningCard && | 
| +          JSON.parse(JSON.stringify( | 
| +              winningCard.receivedNotification.actionUrls)); | 
| + | 
| +      return { | 
| +        actionUrls: winningActionUrls, | 
| +        timestamp: now, | 
| +        combinedCard: combinedCard | 
| +      }; | 
| +    } else { | 
| +      // If there are no more events, we are done with this card. | 
| +      verify(!winningCard, 'No events left, but card is shown.'); | 
| +      clearCardFromGroups(cardId); | 
| +      return undefined; | 
| } | 
| } | 
|  | 
| /** | 
| -   * Updates/creates a card notification with new data. | 
| -   * @param {string} cardId Card ID. | 
| -   * @param {MergedCard} card Google Now card from the server. | 
| -   * @param {number=} previousVersion The version of the shown card with | 
| -   *     this id, if it exists, undefined otherwise. | 
| -   * @param {function(CardCreateInfo)=} onCardShown Optional parameter called | 
| -   *     when each card is shown. | 
| -   * @return {Object} Notification data entry for this card. | 
| +   * Removes dismissed part of a card and refreshes the card. Returns remaining | 
| +   * dismissals for the combined card and updated notification data. | 
| +   * @param {ChromeNotificationId} cardId Card ID. | 
| +   * @param {NotificationDataEntry} notificationData Stored notification entry | 
| +   *     for this card. | 
| +   * @return {{ | 
| +   *   dismissals: Array.<DismissalData>, | 
| +   *   notificationData: (NotificationDataEntry|undefined) | 
| +   * }} | 
| */ | 
| -  function update(cardId, card, previousVersion, onCardShown) { | 
| -    console.log('cardManager.update ' + JSON.stringify(card) + ' ' + | 
| -        previousVersion); | 
| - | 
| -    chrome.alarms.clear(cardHidePrefix + cardId); | 
| - | 
| -    var cardCreateInfo = { | 
| -      notification: card.notification, | 
| -      hideTime: card.trigger.hideTime, | 
| -      version: card.version, | 
| -      previousVersion: previousVersion, | 
| -      locationBased: card.locationBased | 
| -    }; | 
| +  function onDismissal(cardId, notificationData) { | 
| +    var dismissals = []; | 
| +    var newCombinedCard = []; | 
|  | 
| -    var shownImmediately = false; | 
| -    var cardShowAlarmName = cardShowPrefix + cardId; | 
| -    if (card.trigger.showTime && card.trigger.showTime > Date.now()) { | 
| -      // Card needs to be shown later. | 
| -      console.log('cardManager.update: postponed'); | 
| -      var alarmInfo = { | 
| -        when: card.trigger.showTime | 
| -      }; | 
| -      chrome.alarms.create(cardShowAlarmName, alarmInfo); | 
| -    } else { | 
| -      // Card needs to be shown immediately. | 
| -      console.log('cardManager.update: immediate'); | 
| -      chrome.alarms.clear(cardShowAlarmName); | 
| -      showNotification(cardId, cardCreateInfo, onCardShown); | 
| -    } | 
| +    // Determine which parts of the combined card need to be dismissed or to be | 
| +    // preserved. We dismiss parts that were visible at the moment when the card | 
| +    // was last updated. | 
| +    iterateUncombinedNotifications( | 
| +      notificationData.combinedCard, | 
| +      notificationData.timestamp, | 
| +      function(uncombinedCard, visible) { | 
| +        if (visible) { | 
| +          dismissals.push({ | 
| +            notificationId: uncombinedCard.receivedNotification.notificationId, | 
| +            parameters: uncombinedCard.receivedNotification.dismissal | 
| +          }); | 
| +        } else { | 
| +          newCombinedCard.push(uncombinedCard); | 
| +        } | 
| +      }); | 
|  | 
| return { | 
| -      actionUrls: card.actionUrls, | 
| -      cardCreateInfo: cardCreateInfo, | 
| -      dismissals: card.dismissals | 
| +      dismissals: dismissals, | 
| +      notificationData: update(cardId, newCombinedCard) | 
| }; | 
| } | 
|  | 
| /** | 
| -   * Removes a card notification. | 
| -   * @param {string} cardId Card ID. | 
| -   * @param {boolean} clearStorage True if the information associated with the | 
| -   *     card should be erased from chrome.storage. | 
| +   * Removes card information from 'notificationGroups'. | 
| +   * @param {ChromeNotificationId} cardId Card ID. | 
| */ | 
| -  function clear(cardId, clearStorage) { | 
| -    console.log('cardManager.clear ' + cardId); | 
| - | 
| -    chrome.notifications.clear(cardId, function() {}); | 
| -    chrome.alarms.clear(cardShowPrefix + cardId); | 
| -    chrome.alarms.clear(cardHidePrefix + cardId); | 
| - | 
| -    if (clearStorage) { | 
| -      instrumented.storage.local.get( | 
| -          ['notificationsData', 'notificationGroups'], | 
| -          function(items) { | 
| -            items = items || {}; | 
| -            items.notificationsData = items.notificationsData || {}; | 
| -            items.notificationGroups = items.notificationGroups || {}; | 
| - | 
| -            delete items.notificationsData[cardId]; | 
| - | 
| -            for (var groupName in items.notificationGroups) { | 
| -              var group = items.notificationGroups[groupName]; | 
| -              for (var i = 0; i != group.cards.length; ++i) { | 
| -                if (group.cards[i].chromeNotificationId == cardId) { | 
| -                  group.cards.splice(i, 1); | 
| -                  break; | 
| -                } | 
| -              } | 
| -            } | 
| +  function clearCardFromGroups(cardId) { | 
| +    console.log('cardManager.clearCardFromGroups ' + cardId); | 
|  | 
| -            chrome.storage.local.set(items); | 
| -          }); | 
| -    } | 
| +    instrumented.storage.local.get('notificationGroups', function(items) { | 
| +      items = items || {}; | 
| +      /** @type {Object.<string, StoredNotificationGroup>} */ | 
| +      items.notificationGroups = items.notificationGroups || {}; | 
| + | 
| +      for (var groupName in items.notificationGroups) { | 
| +        var group = items.notificationGroups[groupName]; | 
| +        for (var i = 0; i != group.cards.length; ++i) { | 
| +          if (group.cards[i].chromeNotificationId == cardId) { | 
| +            group.cards.splice(i, 1); | 
| +            break; | 
| +          } | 
| +        } | 
| +      } | 
| + | 
| +      chrome.storage.local.set(items); | 
| +    }); | 
| } | 
|  | 
| instrumented.alarms.onAlarm.addListener(function(alarm) { | 
| console.log('cardManager.onAlarm ' + JSON.stringify(alarm)); | 
|  | 
| -    if (alarm.name.indexOf(cardShowPrefix) == 0) { | 
| +    if (alarm.name.indexOf(alarmPrefix) == 0) { | 
| // Alarm to show the card. | 
| -      tasks.add(SHOW_CARD_TASK_NAME, function() { | 
| -        var cardId = alarm.name.substring(cardShowPrefix.length); | 
| +      tasks.add(UPDATE_CARD_TASK_NAME, function() { | 
| +        var cardId = alarm.name.substring(alarmPrefix.length); | 
| instrumented.storage.local.get('notificationsData', function(items) { | 
| console.log('cardManager.onAlarm.get ' + JSON.stringify(items)); | 
| -          if (!items || !items.notificationsData) | 
| -            return; | 
| -          var notificationData = items.notificationsData[cardId]; | 
| -          if (!notificationData) | 
| -            return; | 
| +          items = items || {}; | 
| +          /** @type {Object.<string, NotificationDataEntry>} */ | 
| +          items.notificationsData = items.notificationsData || {}; | 
| +          var combinedCard = | 
| +            (items.notificationsData[cardId] && | 
| +             items.notificationsData[cardId].combinedCard) || []; | 
|  | 
| var cardShownCallback = undefined; | 
| if (localStorage['locationCardsShown'] < | 
| @@ -246,21 +343,17 @@ function buildCardSet() { | 
| cardShownCallback = countLocationCard; | 
| } | 
|  | 
| -          showNotification( | 
| -              cardId, notificationData.cardCreateInfo, cardShownCallback); | 
| +          items.notificationsData[cardId] = | 
| +              update(cardId, combinedCard, cardShownCallback); | 
| + | 
| +          chrome.storage.local.set(items); | 
| }); | 
| }); | 
| -    } else if (alarm.name.indexOf(cardHidePrefix) == 0) { | 
| -      // Alarm to hide the card. | 
| -      tasks.add(CLEAR_CARD_TASK_NAME, function() { | 
| -        var cardId = alarm.name.substring(cardHidePrefix.length); | 
| -        clear(cardId, true); | 
| -      }); | 
| } | 
| }); | 
|  | 
| return { | 
| update: update, | 
| -    clear: clear | 
| +    onDismissal: onDismissal | 
| }; | 
| } | 
|  |