OLD | NEW |
(Empty) | |
| 1 // Copyright 2014 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 cr.define('hotword', function() { |
| 6 'use strict'; |
| 7 |
| 8 /** |
| 9 * Class used to manage the interaction between hotwording and the |
| 10 * NTP/google.com. Injects a content script to interact with NTP/google.com |
| 11 * and updates the global hotwording state based on interaction with those |
| 12 * pages. |
| 13 * @param {!hotword.StateManager} stateManager |
| 14 * @constructor |
| 15 * @struct |
| 16 */ |
| 17 function PageAudioManager(stateManager) { |
| 18 /** |
| 19 * Manager of global hotwording state. |
| 20 * @private {!hotword.StateManager} |
| 21 */ |
| 22 this.stateManager_ = stateManager; |
| 23 |
| 24 /** |
| 25 * Mapping between tab ID and port that is connected from the injected |
| 26 * content script. |
| 27 * @private {!Object.<number, chrome.runtime.Port>} |
| 28 */ |
| 29 this.portMap_ = {}; |
| 30 |
| 31 /** |
| 32 * Chrome event listeners. Saved so that they can be de-registered when |
| 33 * hotwording is disabled. |
| 34 */ |
| 35 this.connectListener_ = this.handleConnect_.bind(this); |
| 36 this.tabCreatedListener_ = this.handleCreatedTab_.bind(this); |
| 37 this.tabUpdatedListener_ = this.handleUpdatedTab_.bind(this); |
| 38 this.tabActivatedListener_ = this.handleActivatedTab_.bind(this); |
| 39 this.windowFocusChangedListener_ = this.handleChangedWindow_.bind(this); |
| 40 |
| 41 // Need to setup listeners on startup, otherwise events that caused the |
| 42 // event page to start up, will be lost. |
| 43 this.setupListeners_(); |
| 44 |
| 45 this.stateManager_.onStatusChanged.addListener(function() { |
| 46 this.updateListeners_(); |
| 47 }.bind(this)); |
| 48 }; |
| 49 |
| 50 var CommandToPage = hotword.constants.CommandToPage; |
| 51 var CommandFromPage = hotword.constants.CommandFromPage; |
| 52 |
| 53 PageAudioManager.prototype = { |
| 54 /** |
| 55 * Helper function to test if a URL path is eligible for hotwording. |
| 56 * @param {!string} url URL to check. |
| 57 * @param {!string} base Base URL to compare against.. |
| 58 * @return {boolean} True if url is an eligible hotword URL. |
| 59 * @private |
| 60 */ |
| 61 checkUrlPathIsEligible_: function(url, base) { |
| 62 if (url == base || |
| 63 url == base + '/' || |
| 64 url.indexOf(base + '/_/chrome/newtab?') == 0 || // Appcache NTP. |
| 65 url.indexOf(base + '/?') == 0 || |
| 66 url.indexOf(base + '/#') == 0 || |
| 67 url.indexOf(base + '/webhp') == 0 || |
| 68 url.indexOf(base + '/search') == 0) { |
| 69 return true; |
| 70 } |
| 71 return false; |
| 72 }, |
| 73 |
| 74 /** |
| 75 * Determines if a URL is eligible for hotwording. For now, the valid pages |
| 76 * are the Google HP and SERP (this will include the NTP). |
| 77 * @param {!string} url URL to check. |
| 78 * @return {boolean} True if url is an eligible hotword URL. |
| 79 * @private |
| 80 */ |
| 81 isEligibleUrl_: function(url) { |
| 82 if (!url) |
| 83 return false; |
| 84 |
| 85 var baseGoogleUrls = [ |
| 86 'https://www.google.', |
| 87 'https://encrypted.google.' |
| 88 ]; |
| 89 // TODO(amistry): Get this list from a file in the shared module instead. |
| 90 var tlds = [ |
| 91 'com', |
| 92 'co.uk', |
| 93 'de', |
| 94 'fr', |
| 95 'ru' |
| 96 ]; |
| 97 |
| 98 // Check for the new tab page first. |
| 99 if (this.checkUrlPathIsEligible_(url, 'chrome://newtab')) |
| 100 return true; |
| 101 |
| 102 // Check URLs with each type of local-based TLD. |
| 103 for (var i = 0; i < baseGoogleUrls.length; i++) { |
| 104 for (var j = 0; j < tlds.length; j++) { |
| 105 var base = baseGoogleUrls[i] + tlds[j]; |
| 106 if (this.checkUrlPathIsEligible_(url, base)) |
| 107 return true; |
| 108 } |
| 109 } |
| 110 return false; |
| 111 }, |
| 112 |
| 113 /** |
| 114 * Locates the current active tab in the current focused window and |
| 115 * performs a callback with the tab as the parameter. |
| 116 * @param {function(?Tab)} callback Function to call with the |
| 117 * active tab or null if not found. The function's |this| will be set to |
| 118 * this object. |
| 119 * @private |
| 120 */ |
| 121 findCurrentTab_: function(callback) { |
| 122 chrome.windows.getAll( |
| 123 {'populate': true}, |
| 124 function(windows) { |
| 125 for (var i = 0; i < windows.length; ++i) { |
| 126 if (!windows[i].focused) |
| 127 continue; |
| 128 |
| 129 for (var j = 0; j < windows[i].tabs.length; ++j) { |
| 130 var tab = windows[i].tabs[j]; |
| 131 if (tab.active) { |
| 132 callback.call(this, tab); |
| 133 return; |
| 134 } |
| 135 } |
| 136 } |
| 137 callback.call(this, null); |
| 138 }.bind(this)); |
| 139 }, |
| 140 |
| 141 /** |
| 142 * This function is called when a tab is activated (comes into focus). |
| 143 * @param {Tab} tab Current active tab. |
| 144 * @private |
| 145 */ |
| 146 activateTab_: function(tab) { |
| 147 if (!tab) { |
| 148 this.stopHotwording_(); |
| 149 return; |
| 150 } |
| 151 if (tab.id in this.portMap_) { |
| 152 this.startHotwordingIfEligible_(); |
| 153 return; |
| 154 } |
| 155 this.stopHotwording_(); |
| 156 this.prepareTab_(tab); |
| 157 }, |
| 158 |
| 159 /** |
| 160 * Prepare a new or updated tab by injecting the content script. |
| 161 * @param {!Tab} tab Newly updated or created tab. |
| 162 * @private |
| 163 */ |
| 164 prepareTab_: function(tab) { |
| 165 if (!this.isEligibleUrl_(tab.url)) |
| 166 return; |
| 167 |
| 168 chrome.tabs.executeScript(tab.id, {'file': 'audio_client.js'}); |
| 169 }, |
| 170 |
| 171 /** |
| 172 * Updates hotwording state based on the state of current tabs/windows. |
| 173 * @private |
| 174 */ |
| 175 updateTabState_: function() { |
| 176 this.findCurrentTab_(this.activateTab_); |
| 177 }, |
| 178 |
| 179 /** |
| 180 * Handles a newly created tab. |
| 181 * @param {!Tab} tab Newly created tab. |
| 182 * @private |
| 183 */ |
| 184 handleCreatedTab_: function(tab) { |
| 185 this.prepareTab_(tab); |
| 186 }, |
| 187 |
| 188 /** |
| 189 * Handles an updated tab. |
| 190 * @param {number} tabId Id of the updated tab. |
| 191 * @param {{status: string}} info Change info of the tab. |
| 192 * @param {!Tab} tab Updated tab. |
| 193 * @private |
| 194 */ |
| 195 handleUpdatedTab_: function(tabId, info, tab) { |
| 196 // Chrome fires multiple update events: undefined, loading and completed. |
| 197 // We perform content injection on loading state. |
| 198 if (info['status'] != 'loading') |
| 199 return; |
| 200 |
| 201 this.prepareTab_(tab); |
| 202 }, |
| 203 |
| 204 /** |
| 205 * Handles a tab that was just became active. |
| 206 * @param {{tabId: number}} info Information about the activated tab. |
| 207 * @private |
| 208 */ |
| 209 handleActivatedTab_: function(info) { |
| 210 this.updateTabState_(); |
| 211 }, |
| 212 |
| 213 |
| 214 /** |
| 215 * Handles a change in Chrome windows. |
| 216 * Note: this does not always trigger in Linux. |
| 217 * @param {number} windowId Id of newly focused window. |
| 218 * @private |
| 219 */ |
| 220 handleChangedWindow_: function(windowId) { |
| 221 this.updateTabState_(); |
| 222 }, |
| 223 |
| 224 /** |
| 225 * Handles a content script attempting to connect. |
| 226 * @param {!Port} port Communications port from the client. |
| 227 * @private |
| 228 */ |
| 229 handleConnect_: function(port) { |
| 230 if (port.name != hotword.constants.CLIENT_PORT_NAME) |
| 231 return; |
| 232 |
| 233 var tab = /** @type {!Tab} */(port.sender.tab); |
| 234 // An existing port from the same tab might already exist. But that port |
| 235 // may be from the previous page, so just overwrite the port. |
| 236 this.portMap_[tab.id] = port; |
| 237 port.onDisconnect.addListener(function() { |
| 238 this.handleClientDisconnect_(port); |
| 239 }.bind(this)); |
| 240 port.onMessage.addListener(function(msg) { |
| 241 this.handleMessage_(msg, port.sender, port.postMessage); |
| 242 }.bind(this)); |
| 243 }, |
| 244 |
| 245 /** |
| 246 * Handles a client content script disconnect. |
| 247 * @param {Port} port Disconnected port. |
| 248 * @private |
| 249 */ |
| 250 handleClientDisconnect_: function(port) { |
| 251 var tabId = port.sender.tab.id; |
| 252 if (tabId in this.portMap_ && this.portMap_[tabId] == port) { |
| 253 // Due to a race between port disconnection and tabs.onUpdated messages, |
| 254 // the port could have changed. |
| 255 delete this.portMap_[port.sender.tab.id]; |
| 256 } |
| 257 this.stopHotwordingIfIneligibleTab_(); |
| 258 }, |
| 259 |
| 260 /** |
| 261 * Disconnect all connected clients. |
| 262 * @private |
| 263 */ |
| 264 disconnectAllClients_: function() { |
| 265 for (var id in this.portMap_) { |
| 266 var port = this.portMap_[id]; |
| 267 port.disconnect(); |
| 268 delete this.portMap_[id]; |
| 269 } |
| 270 }, |
| 271 |
| 272 /** |
| 273 * Sends a command to the client content script on an eligible tab. |
| 274 * @param {hotword.constants.CommandToPage} command Command to send. |
| 275 * @param {number} tabId Id of the target tab. |
| 276 * @private |
| 277 */ |
| 278 sendClient_: function(command, tabId) { |
| 279 if (tabId in this.portMap_) { |
| 280 var message = {}; |
| 281 message[hotword.constants.COMMAND_FIELD_NAME] = command; |
| 282 this.portMap_[tabId].postMessage(message); |
| 283 } |
| 284 }, |
| 285 |
| 286 /** |
| 287 * Sends a command to all connected clients. |
| 288 * @param {hotword.constants.CommandToPage} command Command to send. |
| 289 * @private |
| 290 */ |
| 291 sendAllClients_: function(command) { |
| 292 for (var idStr in this.portMap_) { |
| 293 var id = parseInt(idStr, 10); |
| 294 assert(!isNaN(id), 'Tab ID is not a number: ' + idStr); |
| 295 this.sendClient_(command, id); |
| 296 } |
| 297 }, |
| 298 |
| 299 /** |
| 300 * Handles a hotword trigger. Sends a trigger message to the currently |
| 301 * active tab. |
| 302 * @private |
| 303 */ |
| 304 hotwordTriggered_: function() { |
| 305 this.findCurrentTab_(function(tab) { |
| 306 if (tab) |
| 307 this.sendClient_(CommandToPage.HOTWORD_VOICE_TRIGGER, tab.id); |
| 308 }); |
| 309 }, |
| 310 |
| 311 /* |
| 312 * Starts hotwording. |
| 313 * @private |
| 314 */ |
| 315 startHotwording_: function() { |
| 316 this.stateManager_.startSession( |
| 317 hotword.constants.SessionSource.NTP, |
| 318 function() { |
| 319 this.sendAllClients_(CommandToPage.HOTWORD_STARTED); |
| 320 }.bind(this), |
| 321 this.hotwordTriggered_.bind(this)); |
| 322 }, |
| 323 |
| 324 /* |
| 325 * Starts hotwording if the currently active tab is eligible for hotwording |
| 326 * (i.e. google.com). |
| 327 * @private |
| 328 */ |
| 329 startHotwordingIfEligible_: function() { |
| 330 this.findCurrentTab_(function(tab) { |
| 331 if (!tab) { |
| 332 this.stopHotwording_(); |
| 333 return; |
| 334 } |
| 335 if (this.isEligibleUrl_(tab.url)) |
| 336 this.startHotwording_(); |
| 337 }); |
| 338 }, |
| 339 |
| 340 /* |
| 341 * Stops hotwording. |
| 342 * @private |
| 343 */ |
| 344 stopHotwording_: function() { |
| 345 this.stateManager_.stopSession(hotword.constants.SessionSource.NTP); |
| 346 this.sendAllClients_(CommandToPage.HOTWORD_ENDED); |
| 347 }, |
| 348 |
| 349 /* |
| 350 * Stops hotwording if the currently active tab is not eligible for |
| 351 * hotwording (i.e. google.com). |
| 352 * @private |
| 353 */ |
| 354 stopHotwordingIfIneligibleTab_: function() { |
| 355 this.findCurrentTab_(function(tab) { |
| 356 if (!tab) { |
| 357 this.stopHotwording_(); |
| 358 return; |
| 359 } |
| 360 if (!this.isEligibleUrl_(tab.url)) |
| 361 this.stopHotwording_(); |
| 362 }); |
| 363 }, |
| 364 |
| 365 /** |
| 366 * Handles a message from the content script injected into the page. |
| 367 * @param {!Object} request Request from the content script. |
| 368 * @param {!MessageSender} sender Message sender. |
| 369 * @param {!function(Object)} sendResponse Function for sending a response. |
| 370 * @private |
| 371 */ |
| 372 handleMessage_: function(request, sender, sendResponse) { |
| 373 switch (request[hotword.constants.COMMAND_FIELD_NAME]) { |
| 374 // TODO(amistry): Handle other messages such as CLICKED_RESUME and |
| 375 // CLICKED_RESTART, if necessary. |
| 376 case CommandFromPage.SPEECH_START: |
| 377 this.stopHotwording_(); |
| 378 break; |
| 379 case CommandFromPage.SPEECH_END: |
| 380 case CommandFromPage.SPEECH_RESET: |
| 381 this.startHotwording_(); |
| 382 break; |
| 383 } |
| 384 }, |
| 385 |
| 386 /** |
| 387 * Set up event listeners. |
| 388 * @private |
| 389 */ |
| 390 setupListeners_: function() { |
| 391 if (chrome.runtime.onConnect.hasListener(this.connectListener_)) |
| 392 return; |
| 393 |
| 394 chrome.runtime.onConnect.addListener(this.connectListener_); |
| 395 chrome.tabs.onCreated.addListener(this.tabCreatedListener_); |
| 396 chrome.tabs.onUpdated.addListener(this.tabUpdatedListener_); |
| 397 chrome.tabs.onActivated.addListener(this.tabActivatedListener_); |
| 398 chrome.windows.onFocusChanged.addListener( |
| 399 this.windowFocusChangedListener_); |
| 400 }, |
| 401 |
| 402 /** |
| 403 * Remove event listeners. |
| 404 * @private |
| 405 */ |
| 406 removeListeners_: function() { |
| 407 chrome.runtime.onConnect.removeListener(this.connectListener_); |
| 408 chrome.tabs.onCreated.removeListener(this.tabCreatedListener_); |
| 409 chrome.tabs.onUpdated.removeListener(this.tabUpdatedListener_); |
| 410 chrome.tabs.onActivated.removeListener(this.tabActivatedListener_); |
| 411 chrome.windows.onFocusChanged.removeListener( |
| 412 this.windowFocusChangedListener_); |
| 413 }, |
| 414 |
| 415 /** |
| 416 * Update event listeners based on the current hotwording state. |
| 417 * @private |
| 418 */ |
| 419 updateListeners_: function() { |
| 420 var enabled = this.stateManager_.isEnabled() && |
| 421 !this.stateManager_.isAlwaysOnEnabled(); |
| 422 if (enabled) { |
| 423 this.setupListeners_(); |
| 424 } else { |
| 425 this.removeListeners_(); |
| 426 this.stopHotwording_(); |
| 427 this.disconnectAllClients_(); |
| 428 } |
| 429 } |
| 430 }; |
| 431 |
| 432 return { |
| 433 PageAudioManager: PageAudioManager |
| 434 }; |
| 435 }); |
OLD | NEW |