| // Copyright 2014 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| 'use strict'; |
| |
| /** |
| * @fileoverview This is the audio client content script injected into eligible |
| * Google.com and New tab pages for interaction between the Webpage and the |
| * Hotword extension. |
| */ |
| |
| (function() { |
| /** |
| * @constructor |
| */ |
| var AudioClient = function() { |
| /** @private {Element} */ |
| this.speechOverlay_ = null; |
| |
| /** @private {number} */ |
| this.checkSpeechUiRetries_ = 0; |
| |
| /** |
| * Port used to communicate with the audio manager. |
| * @private {?Port} |
| */ |
| this.port_ = null; |
| |
| /** |
| * Keeps track of the effects of different commands. Used to verify that |
| * proper UIs are shown to the user. |
| * @private {Object<AudioClient.CommandToPage, Object>} |
| */ |
| this.uiStatus_ = null; |
| |
| /** |
| * Bound function used to handle commands sent from the page to this script. |
| * @private {Function} |
| */ |
| this.handleCommandFromPageFunc_ = null; |
| }; |
| |
| /** |
| * Messages sent to the page to control the voice search UI. |
| * @enum {string} |
| */ |
| AudioClient.CommandToPage = { |
| HOTWORD_VOICE_TRIGGER: 'vt', |
| HOTWORD_STARTED: 'hs', |
| HOTWORD_ENDED: 'hd', |
| HOTWORD_TIMEOUT: 'ht', |
| HOTWORD_ERROR: 'he' |
| }; |
| |
| /** |
| * Messages received from the page used to indicate voice search state. |
| * @enum {string} |
| */ |
| AudioClient.CommandFromPage = { |
| SPEECH_START: 'ss', |
| SPEECH_END: 'se', |
| SPEECH_RESET: 'sr', |
| SHOWING_HOTWORD_START: 'shs', |
| SHOWING_ERROR_MESSAGE: 'sem', |
| SHOWING_TIMEOUT_MESSAGE: 'stm', |
| CLICKED_RESUME: 'hcc', |
| CLICKED_RESTART: 'hcr', |
| CLICKED_DEBUG: 'hcd' |
| }; |
| |
| /** |
| * Errors that are sent to the hotword extension. |
| * @enum {string} |
| */ |
| AudioClient.Error = { |
| NO_SPEECH_UI: 'ac1', |
| NO_HOTWORD_STARTED_UI: 'ac2', |
| NO_HOTWORD_TIMEOUT_UI: 'ac3', |
| NO_HOTWORD_ERROR_UI: 'ac4' |
| }; |
| |
| /** |
| * @const {string} |
| * @private |
| */ |
| AudioClient.HOTWORD_EXTENSION_ID_ = 'nbpagnldghgfoolbancepceaanlmhfmd'; |
| |
| /** |
| * Number of times to retry checking a transient error. |
| * @const {number} |
| * @private |
| */ |
| AudioClient.MAX_RETRIES = 3; |
| |
| /** |
| * Delay to wait in milliseconds before rechecking for any transient errors. |
| * @const {number} |
| * @private |
| */ |
| AudioClient.RETRY_TIME_MS_ = 2000; |
| |
| /** |
| * DOM ID for the speech UI overlay. |
| * @const {string} |
| * @private |
| */ |
| AudioClient.SPEECH_UI_OVERLAY_ID_ = 'spch'; |
| |
| /** |
| * @const {string} |
| * @private |
| */ |
| AudioClient.HELP_CENTER_URL_ = |
| 'https://support.google.com/chrome/?p=ui_hotword_search'; |
| |
| /** |
| * @const {string} |
| * @private |
| */ |
| AudioClient.CLIENT_PORT_NAME_ = 'chwcpn'; |
| |
| /** |
| * Existence of the Audio Client. |
| * @const {string} |
| * @private |
| */ |
| AudioClient.EXISTS_ = 'chwace'; |
| |
| /** |
| * Checks for the presence of speech overlay UI DOM elements. |
| * @private |
| */ |
| AudioClient.prototype.checkSpeechOverlayUi_ = function() { |
| if (!this.speechOverlay_) { |
| window.setTimeout(this.delayedCheckSpeechOverlayUi_.bind(this), |
| AudioClient.RETRY_TIME_MS_); |
| } else { |
| this.checkSpeechUiRetries_ = 0; |
| } |
| }; |
| |
| /** |
| * Function called to check for the speech UI overlay after some time has |
| * passed since an initial check. Will either retry triggering the speech |
| * or sends an error message depending on the number of retries. |
| * @private |
| */ |
| AudioClient.prototype.delayedCheckSpeechOverlayUi_ = function() { |
| this.speechOverlay_ = document.getElementById( |
| AudioClient.SPEECH_UI_OVERLAY_ID_); |
| if (!this.speechOverlay_) { |
| if (this.checkSpeechUiRetries_++ < AudioClient.MAX_RETRIES) { |
| this.sendCommandToPage_(AudioClient.CommandToPage.VOICE_TRIGGER); |
| this.checkSpeechOverlayUi_(); |
| } else { |
| this.sendCommandToExtension_(AudioClient.Error.NO_SPEECH_UI); |
| } |
| } else { |
| this.checkSpeechUiRetries_ = 0; |
| } |
| }; |
| |
| /** |
| * Checks that the triggered UI is actually displayed. |
| * @param {AudioClient.CommandToPage} command Command that was send. |
| * @private |
| */ |
| AudioClient.prototype.checkUi_ = function(command) { |
| this.uiStatus_[command].timeoutId = |
| window.setTimeout(this.failedCheckUi_.bind(this, command), |
| AudioClient.RETRY_TIME_MS_); |
| }; |
| |
| /** |
| * Function called when the UI verification is not called in time. Will either |
| * retry the command or sends an error message, depending on the number of |
| * retries for the command. |
| * @param {AudioClient.CommandToPage} command Command that was sent. |
| * @private |
| */ |
| AudioClient.prototype.failedCheckUi_ = function(command) { |
| if (this.uiStatus_[command].tries++ < AudioClient.MAX_RETRIES) { |
| this.sendCommandToPage_(command); |
| this.checkUi_(command); |
| } else { |
| this.sendCommandToExtension_(this.uiStatus_[command].error); |
| } |
| }; |
| |
| /** |
| * Confirm that an UI element has been shown. |
| * @param {AudioClient.CommandToPage} command UI to confirm. |
| * @private |
| */ |
| AudioClient.prototype.verifyUi_ = function(command) { |
| if (this.uiStatus_[command].timeoutId) { |
| window.clearTimeout(this.uiStatus_[command].timeoutId); |
| this.uiStatus_[command].timeoutId = null; |
| this.uiStatus_[command].tries = 0; |
| } |
| }; |
| |
| /** |
| * Sends a command to the audio manager. |
| * @param {string} commandStr command to send to plugin. |
| * @private |
| */ |
| AudioClient.prototype.sendCommandToExtension_ = function(commandStr) { |
| if (this.port_) |
| this.port_.postMessage({'cmd': commandStr}); |
| }; |
| |
| /** |
| * Handles a message from the audio manager. |
| * @param {{cmd: string}} commandObj Command from the audio manager. |
| * @private |
| */ |
| AudioClient.prototype.handleCommandFromExtension_ = function(commandObj) { |
| var command = commandObj['cmd']; |
| if (command) { |
| switch (command) { |
| case AudioClient.CommandToPage.HOTWORD_VOICE_TRIGGER: |
| this.sendCommandToPage_(command); |
| this.checkSpeechOverlayUi_(); |
| break; |
| case AudioClient.CommandToPage.HOTWORD_STARTED: |
| this.sendCommandToPage_(command); |
| this.checkUi_(command); |
| break; |
| case AudioClient.CommandToPage.HOTWORD_ENDED: |
| this.sendCommandToPage_(command); |
| break; |
| case AudioClient.CommandToPage.HOTWORD_TIMEOUT: |
| this.sendCommandToPage_(command); |
| this.checkUi_(command); |
| break; |
| case AudioClient.CommandToPage.HOTWORD_ERROR: |
| this.sendCommandToPage_(command); |
| this.checkUi_(command); |
| break; |
| } |
| } |
| }; |
| |
| /** |
| * @param {AudioClient.CommandToPage} commandStr Command to send. |
| * @private |
| */ |
| AudioClient.prototype.sendCommandToPage_ = function(commandStr) { |
| window.postMessage({'type': commandStr}, '*'); |
| }; |
| |
| /** |
| * Handles a message from the html window. |
| * @param {!MessageEvent} messageEvent Message event from the window. |
| * @private |
| */ |
| AudioClient.prototype.handleCommandFromPage_ = function(messageEvent) { |
| if (messageEvent.source == window && messageEvent.data.type) { |
| var command = messageEvent.data.type; |
| switch (command) { |
| case AudioClient.CommandFromPage.SPEECH_START: |
| this.speechActive_ = true; |
| this.sendCommandToExtension_(command); |
| break; |
| case AudioClient.CommandFromPage.SPEECH_END: |
| this.speechActive_ = false; |
| this.sendCommandToExtension_(command); |
| break; |
| case AudioClient.CommandFromPage.SPEECH_RESET: |
| this.speechActive_ = false; |
| this.sendCommandToExtension_(command); |
| break; |
| case 'SPEECH_RESET': // Legacy, for embedded NTP. |
| this.speechActive_ = false; |
| this.sendCommandToExtension_(AudioClient.CommandFromPage.SPEECH_END); |
| break; |
| case AudioClient.CommandFromPage.CLICKED_RESUME: |
| this.sendCommandToExtension_(command); |
| break; |
| case AudioClient.CommandFromPage.CLICKED_RESTART: |
| this.sendCommandToExtension_(command); |
| break; |
| case AudioClient.CommandFromPage.CLICKED_DEBUG: |
| window.open(AudioClient.HELP_CENTER_URL_, '_blank'); |
| break; |
| case AudioClient.CommandFromPage.SHOWING_HOTWORD_START: |
| this.verifyUi_(AudioClient.CommandToPage.HOTWORD_STARTED); |
| break; |
| case AudioClient.CommandFromPage.SHOWING_ERROR_MESSAGE: |
| this.verifyUi_(AudioClient.CommandToPage.HOTWORD_ERROR); |
| break; |
| case AudioClient.CommandFromPage.SHOWING_TIMEOUT_MESSAGE: |
| this.verifyUi_(AudioClient.CommandToPage.HOTWORD_TIMEOUT); |
| break; |
| } |
| } |
| }; |
| |
| /** |
| * Initialize the content script. |
| */ |
| AudioClient.prototype.initialize = function() { |
| if (AudioClient.EXISTS_ in window) |
| return; |
| window[AudioClient.EXISTS_] = true; |
| |
| // UI verification object. |
| this.uiStatus_ = {}; |
| this.uiStatus_[AudioClient.CommandToPage.HOTWORD_STARTED] = { |
| timeoutId: null, |
| tries: 0, |
| error: AudioClient.Error.NO_HOTWORD_STARTED_UI |
| }; |
| this.uiStatus_[AudioClient.CommandToPage.HOTWORD_TIMEOUT] = { |
| timeoutId: null, |
| tries: 0, |
| error: AudioClient.Error.NO_HOTWORD_TIMEOUT_UI |
| }; |
| this.uiStatus_[AudioClient.CommandToPage.HOTWORD_ERROR] = { |
| timeoutId: null, |
| tries: 0, |
| error: AudioClient.Error.NO_HOTWORD_ERROR_UI |
| }; |
| |
| this.handleCommandFromPageFunc_ = this.handleCommandFromPage_.bind(this); |
| window.addEventListener('message', this.handleCommandFromPageFunc_, false); |
| this.initPort_(); |
| }; |
| |
| /** |
| * Initialize the communications port with the audio manager. This |
| * function will be also be called again if the audio-manager |
| * disconnects for some reason (such as the extension |
| * background.html page being reloaded). |
| * @private |
| */ |
| AudioClient.prototype.initPort_ = function() { |
| this.port_ = chrome.runtime.connect( |
| AudioClient.HOTWORD_EXTENSION_ID_, |
| {'name': AudioClient.CLIENT_PORT_NAME_}); |
| // Note that this listen may have to be destroyed manually if AudioClient |
| // is ever destroyed on this tab. |
| this.port_.onDisconnect.addListener( |
| (function(e) { |
| if (this.handleCommandFromPageFunc_) { |
| window.removeEventListener( |
| 'message', this.handleCommandFromPageFunc_, false); |
| } |
| delete window[AudioClient.EXISTS_]; |
| }).bind(this)); |
| |
| // See note above. |
| this.port_.onMessage.addListener( |
| this.handleCommandFromExtension_.bind(this)); |
| |
| if (this.speechActive_) { |
| this.sendCommandToExtension_(AudioClient.CommandFromPage.SPEECH_START); |
| } else { |
| // It's possible for this script to be injected into the page after it has |
| // completed loaded (i.e. when prerendering). In this case, this script |
| // won't receive a SPEECH_RESET from the page to forward onto the |
| // extension. To make up for this, always send a SPEECH_RESET. This means |
| // in most cases, the extension will receive SPEECH_RESET twice, one from |
| // this sendCommandToExtension_ and the one forwarded from the page. But |
| // that's OK and the extension can handle it. |
| this.sendCommandToExtension_(AudioClient.CommandFromPage.SPEECH_RESET); |
| } |
| }; |
| |
| // Initializes as soon as the code is ready, do not wait for the page. |
| new AudioClient().initialize(); |
| })(); |