1 // Copyright (c) 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 'use strict'; 6 7 /** 8 * @fileoverview This is the audio client content script injected into eligible 9 * Google.com and New tab pages for interaction between the Webpage and the 10 * Hotword extension. 11 */ 12 13 14 15 (function() { 16 /** 17 * @constructor 18 */ 19 var AudioClient = function() { 20 /** @private {Element} */ 21 this.speechOverlay_ = null; 22 23 /** @private {number} */ 24 this.checkSpeechUiRetries_ = 0; 25 26 /** 27 * Port used to communicate with the audio manager. 28 * @private {?Port} 29 */ 30 this.port_ = null; 31 32 /** 33 * Keeps track of the effects of different commands. Used to verify that 34 * proper UIs are shown to the user. 35 * @private {Object.<AudioClient.CommandToPage, Object>} 36 */ 37 this.uiStatus_ = null; 38 39 /** 40 * Bound function used to handle commands sent from the page to this script. 41 * @private {Function} 42 */ 43 this.handleCommandFromPageFunc_ = null; 44 }; 45 46 47 /** 48 * Messages sent to the page to control the voice search UI. 49 * @enum {string} 50 */ 51 AudioClient.CommandToPage = { 52 HOTWORD_VOICE_TRIGGER: 'vt', 53 HOTWORD_STARTED: 'hs', 54 HOTWORD_ENDED: 'hd', 55 HOTWORD_TIMEOUT: 'ht', 56 HOTWORD_ERROR: 'he' 57 }; 58 59 60 /** 61 * Messages received from the page used to indicate voice search state. 62 * @enum {string} 63 */ 64 AudioClient.CommandFromPage = { 65 SPEECH_START: 'ss', 66 SPEECH_END: 'se', 67 SPEECH_RESET: 'sr', 68 SHOWING_HOTWORD_START: 'shs', 69 SHOWING_ERROR_MESSAGE: 'sem', 70 SHOWING_TIMEOUT_MESSAGE: 'stm', 71 CLICKED_RESUME: 'hcc', 72 CLICKED_RESTART: 'hcr', 73 CLICKED_DEBUG: 'hcd' 74 }; 75 76 77 /** 78 * Errors that are sent to the hotword extension. 79 * @enum {string} 80 */ 81 AudioClient.Error = { 82 NO_SPEECH_UI: 'ac1', 83 NO_HOTWORD_STARTED_UI: 'ac2', 84 NO_HOTWORD_TIMEOUT_UI: 'ac3', 85 NO_HOTWORD_ERROR_UI: 'ac4' 86 }; 87 88 89 /** 90 * @const {string} 91 * @private 92 */ 93 AudioClient.HOTWORD_EXTENSION_ID_ = 'bepbmhgboaologfdajaanbcjmnhjmhfn'; 94 95 96 /** 97 * Number of times to retry checking a transient error. 98 * @const {number} 99 * @private 100 */ 101 AudioClient.MAX_RETRIES = 3; 102 103 104 /** 105 * Delay to wait in milliseconds before rechecking for any transient errors. 106 * @const {number} 107 * @private 108 */ 109 AudioClient.RETRY_TIME_MS_ = 2000; 110 111 112 /** 113 * DOM ID for the speech UI overlay. 114 * @const {string} 115 * @private 116 */ 117 AudioClient.SPEECH_UI_OVERLAY_ID_ = 'spch'; 118 119 120 /** 121 * @const {string} 122 * @private 123 */ 124 AudioClient.HELP_CENTER_URL_ = 125 'https://support.google.com/chrome/?p=ui_hotword_search'; 126 127 128 /** 129 * @const {string} 130 * @private 131 */ 132 AudioClient.CLIENT_PORT_NAME_ = 'chwcpn'; 133 134 /** 135 * Existence of the Audio Client. 136 * @const {string} 137 * @private 138 */ 139 AudioClient.EXISTS_ = 'chwace'; 140 141 142 /** 143 * Checks for the presence of speech overlay UI DOM elements. 144 * @private 145 */ 146 AudioClient.prototype.checkSpeechOverlayUi_ = function() { 147 if (!this.speechOverlay_) { 148 window.setTimeout(this.delayedCheckSpeechOverlayUi_.bind(this), 149 AudioClient.RETRY_TIME_MS_); 150 } else { 151 this.checkSpeechUiRetries_ = 0; 152 } 153 }; 154 155 156 /** 157 * Function called to check for the speech UI overlay after some time has 158 * passed since an initial check. Will either retry triggering the speech 159 * or sends an error message depending on the number of retries. 160 * @private 161 */ 162 AudioClient.prototype.delayedCheckSpeechOverlayUi_ = function() { 163 this.speechOverlay_ = document.getElementById( 164 AudioClient.SPEECH_UI_OVERLAY_ID_); 165 if (!this.speechOverlay_) { 166 if (this.checkSpeechUiRetries_++ < AudioClient.MAX_RETRIES) { 167 this.sendCommandToPage_(AudioClient.CommandToPage.VOICE_TRIGGER); 168 this.checkSpeechOverlayUi_(); 169 } else { 170 this.sendCommandToExtension_(AudioClient.Error.NO_SPEECH_UI); 171 } 172 } else { 173 this.checkSpeechUiRetries_ = 0; 174 } 175 }; 176 177 178 /** 179 * Checks that the triggered UI is actually displayed. 180 * @param {AudioClient.CommandToPage} command Command that was send. 181 * @private 182 */ 183 AudioClient.prototype.checkUi_ = function(command) { 184 this.uiStatus_[command].timeoutId = 185 window.setTimeout(this.failedCheckUi_.bind(this, command), 186 AudioClient.RETRY_TIME_MS_); 187 }; 188 189 190 /** 191 * Function called when the UI verification is not called in time. Will either 192 * retry the command or sends an error message, depending on the number of 193 * retries for the command. 194 * @param {AudioClient.CommandToPage} command Command that was sent. 195 * @private 196 */ 197 AudioClient.prototype.failedCheckUi_ = function(command) { 198 if (this.uiStatus_[command].tries++ < AudioClient.MAX_RETRIES) { 199 this.sendCommandToPage_(command); 200 this.checkUi_(command); 201 } else { 202 this.sendCommandToExtension_(this.uiStatus_[command].error); 203 } 204 }; 205 206 207 /** 208 * Confirm that an UI element has been shown. 209 * @param {AudioClient.CommandToPage} command UI to confirm. 210 * @private 211 */ 212 AudioClient.prototype.verifyUi_ = function(command) { 213 if (this.uiStatus_[command].timeoutId) { 214 window.clearTimeout(this.uiStatus_[command].timeoutId); 215 this.uiStatus_[command].timeoutId = null; 216 this.uiStatus_[command].tries = 0; 217 } 218 }; 219 220 221 /** 222 * Sends a command to the audio manager. 223 * @param {string} commandStr command to send to plugin. 224 * @private 225 */ 226 AudioClient.prototype.sendCommandToExtension_ = function(commandStr) { 227 if (this.port_) 228 this.port_.postMessage({'cmd': commandStr}); 229 }; 230 231 232 /** 233 * Handles a message from the audio manager. 234 * @param {{cmd: string}} commandObj Command from the audio manager. 235 * @private 236 */ 237 AudioClient.prototype.handleCommandFromExtension_ = function(commandObj) { 238 var command = commandObj['cmd']; 239 if (command) { 240 switch (command) { 241 case AudioClient.CommandToPage.HOTWORD_VOICE_TRIGGER: 242 this.sendCommandToPage_(command); 243 this.checkSpeechOverlayUi_(); 244 break; 245 case AudioClient.CommandToPage.HOTWORD_STARTED: 246 this.sendCommandToPage_(command); 247 this.checkUi_(command); 248 break; 249 case AudioClient.CommandToPage.HOTWORD_ENDED: 250 this.sendCommandToPage_(command); 251 break; 252 case AudioClient.CommandToPage.HOTWORD_TIMEOUT: 253 this.sendCommandToPage_(command); 254 this.checkUi_(command); 255 break; 256 case AudioClient.CommandToPage.HOTWORD_ERROR: 257 this.sendCommandToPage_(command); 258 this.checkUi_(command); 259 break; 260 } 261 } 262 }; 263 264 265 /** 266 * @param {AudioClient.CommandToPage} commandStr Command to send. 267 * @private 268 */ 269 AudioClient.prototype.sendCommandToPage_ = function(commandStr) { 270 window.postMessage({'type': commandStr}, '*'); 271 }; 272 273 274 /** 275 * Handles a message from the html window. 276 * @param {!MessageEvent} messageEvent Message event from the window. 277 * @private 278 */ 279 AudioClient.prototype.handleCommandFromPage_ = function(messageEvent) { 280 if (messageEvent.source == window && messageEvent.data.type) { 281 var command = messageEvent.data.type; 282 switch (command) { 283 case AudioClient.CommandFromPage.SPEECH_START: 284 this.speechActive_ = true; 285 this.sendCommandToExtension_(command); 286 break; 287 case AudioClient.CommandFromPage.SPEECH_END: 288 this.speechActive_ = false; 289 this.sendCommandToExtension_(command); 290 break; 291 case AudioClient.CommandFromPage.SPEECH_RESET: 292 this.speechActive_ = false; 293 this.sendCommandToExtension_(command); 294 break; 295 case 'SPEECH_RESET': // Legacy, for embedded NTP. 296 this.speechActive_ = false; 297 this.sendCommandToExtension_(AudioClient.CommandFromPage.SPEECH_END); 298 break; 299 case AudioClient.CommandFromPage.CLICKED_RESUME: 300 this.sendCommandToExtension_(command); 301 break; 302 case AudioClient.CommandFromPage.CLICKED_RESTART: 303 this.sendCommandToExtension_(command); 304 break; 305 case AudioClient.CommandFromPage.CLICKED_DEBUG: 306 window.open(AudioClient.HELP_CENTER_URL_, '_blank'); 307 break; 308 case AudioClient.CommandFromPage.SHOWING_HOTWORD_START: 309 this.verifyUi_(AudioClient.CommandToPage.HOTWORD_STARTED); 310 break; 311 case AudioClient.CommandFromPage.SHOWING_ERROR_MESSAGE: 312 this.verifyUi_(AudioClient.CommandToPage.HOTWORD_ERROR); 313 break; 314 case AudioClient.CommandFromPage.SHOWING_TIMEOUT_MESSAGE: 315 this.verifyUi_(AudioClient.CommandToPage.HOTWORD_TIMEOUT); 316 break; 317 } 318 } 319 }; 320 321 322 /** 323 * Initialize the content script. 324 */ 325 AudioClient.prototype.initialize = function() { 326 if (AudioClient.EXISTS_ in window) 327 return; 328 window[AudioClient.EXISTS_] = true; 329 330 // UI verification object. 331 this.uiStatus_ = {}; 332 this.uiStatus_[AudioClient.CommandToPage.HOTWORD_STARTED] = { 333 timeoutId: null, 334 tries: 0, 335 error: AudioClient.Error.NO_HOTWORD_STARTED_UI 336 }; 337 this.uiStatus_[AudioClient.CommandToPage.HOTWORD_TIMEOUT] = { 338 timeoutId: null, 339 tries: 0, 340 error: AudioClient.Error.NO_HOTWORD_TIMEOUT_UI 341 }; 342 this.uiStatus_[AudioClient.CommandToPage.HOTWORD_ERROR] = { 343 timeoutId: null, 344 tries: 0, 345 error: AudioClient.Error.NO_HOTWORD_ERROR_UI 346 }; 347 348 this.handleCommandFromPageFunc_ = this.handleCommandFromPage_.bind(this); 349 window.addEventListener('message', this.handleCommandFromPageFunc_, false); 350 this.initPort_(); 351 }; 352 353 354 /** 355 * Initialize the communications port with the audio manager. This 356 * function will be also be called again if the audio-manager 357 * disconnects for some reason (such as the extension 358 * background.html page being reloaded). 359 * @private 360 */ 361 AudioClient.prototype.initPort_ = function() { 362 this.port_ = chrome.runtime.connect( 363 AudioClient.HOTWORD_EXTENSION_ID_, 364 {'name': AudioClient.CLIENT_PORT_NAME_}); 365 // Note that this listen may have to be destroyed manually if AudioClient 366 // is ever destroyed on this tab. 367 this.port_.onDisconnect.addListener( 368 (function(e) { 369 if (this.handleCommandFromPageFunc_) { 370 window.removeEventListener( 371 'message', this.handleCommandFromPageFunc_, false); 372 } 373 delete window[AudioClient.EXISTS_]; 374 }).bind(this)); 375 376 // See note above. 377 this.port_.onMessage.addListener( 378 this.handleCommandFromExtension_.bind(this)); 379 380 if (this.speechActive_) 381 this.sendCommandToExtension_(AudioClient.CommandFromPage.SPEECH_START); 382 }; 383 384 385 // Initializes as soon as the code is ready, do not wait for the page. 386 new AudioClient().initialize(); 387 })(); 388