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.
5 cr.define('hotword', function() {
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
13 * @param {!hotword.StateManager} stateManager
16 function PageAudioManager(stateManager) {
18 * Manager of global hotwording state.
19 * @private {!hotword.StateManager}
21 this.stateManager_ = stateManager;
24 * Mapping between tab ID and port that is connected from the injected
26 * @private {!Object<number, Port>}
31 * Chrome event listeners. Saved so that they can be de-registered when
32 * hotwording is disabled.
34 this.connectListener_ = this.handleConnect_.bind(this);
35 this.tabCreatedListener_ = this.handleCreatedTab_.bind(this);
36 this.tabUpdatedListener_ = this.handleUpdatedTab_.bind(this);
37 this.tabActivatedListener_ = this.handleActivatedTab_.bind(this);
38 this.microphoneStateChangedListener_ =
39 this.handleMicrophoneStateChanged_.bind(this);
40 this.windowFocusChangedListener_ = this.handleChangedWindow_.bind(this);
41 this.messageListener_ = this.handleMessageFromPage_.bind(this);
43 // Need to setup listeners on startup, otherwise events that caused the
44 // event page to start up, will be lost.
45 this.setupListeners_();
47 this.stateManager_.onStatusChanged.addListener(function() {
48 this.updateListeners_();
49 this.updateTabState_();
53 var CommandToPage = hotword.constants.CommandToPage;
54 var CommandFromPage = hotword.constants.CommandFromPage;
56 PageAudioManager.prototype = {
58 * Helper function to test if a URL path is eligible for hotwording.
59 * @param {!string} url URL to check.
60 * @param {!string} base Base URL to compare against..
61 * @return {boolean} True if url is an eligible hotword URL.
64 checkUrlPathIsEligible_: function(url, base) {
67 url.indexOf(base + '/_/chrome/newtab?') == 0 || // Appcache NTP.
68 url.indexOf(base + '/?') == 0 ||
69 url.indexOf(base + '/#') == 0 ||
70 url.indexOf(base + '/webhp') == 0 ||
71 url.indexOf(base + '/search') == 0) {
78 * Determines if a URL is eligible for hotwording. For now, the valid pages
79 * are the Google HP and SERP (this will include the NTP).
80 * @param {!string} url URL to check.
81 * @return {boolean} True if url is an eligible hotword URL.
84 isEligibleUrl_: function(url) {
88 var baseGoogleUrls = [
89 'https://www.google.',
90 'https://encrypted.google.'
92 // TODO(amistry): Get this list from a file in the shared module instead.
112 // Check for the new tab page first.
113 if (this.checkUrlPathIsEligible_(url, 'chrome://newtab'))
116 // Check URLs with each type of local-based TLD.
117 for (var i = 0; i < baseGoogleUrls.length; i++) {
118 for (var j = 0; j < tlds.length; j++) {
119 var base = baseGoogleUrls[i] + tlds[j];
120 if (this.checkUrlPathIsEligible_(url, base))
128 * Locates the current active tab in the current focused window and
129 * performs a callback with the tab as the parameter.
130 * @param {function(?Tab)} callback Function to call with the
131 * active tab or null if not found. The function's |this| will be set to
135 findCurrentTab_: function(callback) {
136 chrome.windows.getAll(
139 for (var i = 0; i < windows.length; ++i) {
140 if (!windows[i].focused)
143 for (var j = 0; j < windows[i].tabs.length; ++j) {
144 var tab = windows[i].tabs[j];
146 callback.call(this, tab);
151 callback.call(this, null);
156 * This function is called when a tab is activated (comes into focus).
157 * @param {Tab} tab Current active tab.
160 activateTab_: function(tab) {
162 this.stopHotwording_();
165 if (tab.id in this.portMap_) {
166 this.startHotwordingIfEligible_();
169 this.stopHotwording_();
170 this.prepareTab_(tab);
174 * Prepare a new or updated tab by injecting the content script.
175 * @param {!Tab} tab Newly updated or created tab.
178 prepareTab_: function(tab) {
179 if (!this.isEligibleUrl_(tab.url))
182 chrome.tabs.executeScript(
184 {'file': 'audio_client.js'},
186 if (chrome.runtime.lastError) {
187 // Ignore this error. For new tab pages, even though the URL is
188 // reported to be chrome://newtab/, the actual URL is a
189 // country-specific google domain. Since we don't have permission
190 // to inject on every page, an error will happen when the user is
191 // in an unsupported country.
193 // The property still needs to be accessed so that the error
194 // condition is cleared. If it isn't, exectureScript will log an
195 // error the next time it is called.
201 * Updates hotwording state based on the state of current tabs/windows.
204 updateTabState_: function() {
205 this.findCurrentTab_(this.activateTab_);
209 * Handles a newly created tab.
210 * @param {!Tab} tab Newly created tab.
213 handleCreatedTab_: function(tab) {
214 this.prepareTab_(tab);
218 * Handles an updated tab.
219 * @param {number} tabId Id of the updated tab.
220 * @param {{status: string}} info Change info of the tab.
221 * @param {!Tab} tab Updated tab.
224 handleUpdatedTab_: function(tabId, info, tab) {
225 // Chrome fires multiple update events: undefined, loading and completed.
226 // We perform content injection on loading state.
227 if (info['status'] != 'loading')
230 this.prepareTab_(tab);
234 * Handles a tab that has just become active.
235 * @param {{tabId: number}} info Information about the activated tab.
238 handleActivatedTab_: function(info) {
239 this.updateTabState_();
243 * Handles the microphone state changing.
244 * @param {boolean} enabled Whether the microphone is now enabled.
247 handleMicrophoneStateChanged_: function(enabled) {
249 this.updateTabState_();
253 this.stopHotwording_();
257 * Handles a change in Chrome windows.
258 * Note: this does not always trigger in Linux.
259 * @param {number} windowId Id of newly focused window.
262 handleChangedWindow_: function(windowId) {
263 this.updateTabState_();
267 * Handles a content script attempting to connect.
268 * @param {!Port} port Communications port from the client.
271 handleConnect_: function(port) {
272 if (port.name != hotword.constants.CLIENT_PORT_NAME)
275 var tab = /** @type {!Tab} */(port.sender.tab);
276 // An existing port from the same tab might already exist. But that port
277 // may be from the previous page, so just overwrite the port.
278 this.portMap_[tab.id] = port;
279 port.onDisconnect.addListener(function() {
280 this.handleClientDisconnect_(port);
282 port.onMessage.addListener(function(msg) {
283 this.handleMessage_(msg, port.sender, port.postMessage);
288 * Handles a client content script disconnect.
289 * @param {Port} port Disconnected port.
292 handleClientDisconnect_: function(port) {
293 var tabId = port.sender.tab.id;
294 if (tabId in this.portMap_ && this.portMap_[tabId] == port) {
295 // Due to a race between port disconnection and tabs.onUpdated messages,
296 // the port could have changed.
297 delete this.portMap_[port.sender.tab.id];
299 this.stopHotwordingIfIneligibleTab_();
303 * Disconnect all connected clients.
306 disconnectAllClients_: function() {
307 for (var id in this.portMap_) {
308 var port = this.portMap_[id];
310 delete this.portMap_[id];
315 * Sends a command to the client content script on an eligible tab.
316 * @param {hotword.constants.CommandToPage} command Command to send.
317 * @param {number} tabId Id of the target tab.
320 sendClient_: function(command, tabId) {
321 if (tabId in this.portMap_) {
323 message[hotword.constants.COMMAND_FIELD_NAME] = command;
324 this.portMap_[tabId].postMessage(message);
329 * Sends a command to all connected clients.
330 * @param {hotword.constants.CommandToPage} command Command to send.
333 sendAllClients_: function(command) {
334 for (var idStr in this.portMap_) {
335 var id = parseInt(idStr, 10);
336 assert(!isNaN(id), 'Tab ID is not a number: ' + idStr);
337 this.sendClient_(command, id);
342 * Handles a hotword trigger. Sends a trigger message to the currently
346 hotwordTriggered_: function() {
347 this.findCurrentTab_(function(tab) {
349 this.sendClient_(CommandToPage.HOTWORD_VOICE_TRIGGER, tab.id);
357 startHotwording_: function() {
358 this.stateManager_.startSession(
359 hotword.constants.SessionSource.NTP,
361 this.sendAllClients_(CommandToPage.HOTWORD_STARTED);
363 this.hotwordTriggered_.bind(this));
367 * Starts hotwording if the currently active tab is eligible for hotwording
371 startHotwordingIfEligible_: function() {
372 this.findCurrentTab_(function(tab) {
374 this.stopHotwording_();
377 if (this.isEligibleUrl_(tab.url))
378 this.startHotwording_();
386 stopHotwording_: function() {
387 this.stateManager_.stopSession(hotword.constants.SessionSource.NTP);
388 this.sendAllClients_(CommandToPage.HOTWORD_ENDED);
392 * Stops hotwording if the currently active tab is not eligible for
393 * hotwording (i.e. google.com).
396 stopHotwordingIfIneligibleTab_: function() {
397 this.findCurrentTab_(function(tab) {
399 this.stopHotwording_();
402 if (!this.isEligibleUrl_(tab.url))
403 this.stopHotwording_();
408 * Handles a message from the content script injected into the page.
409 * @param {!Object} request Request from the content script.
410 * @param {!MessageSender} sender Message sender.
411 * @param {!function(Object)} sendResponse Function for sending a response.
414 handleMessage_: function(request, sender, sendResponse) {
415 switch (request[hotword.constants.COMMAND_FIELD_NAME]) {
416 // TODO(amistry): Handle other messages such as CLICKED_RESUME and
417 // CLICKED_RESTART, if necessary.
418 case CommandFromPage.SPEECH_START:
419 this.stopHotwording_();
421 case CommandFromPage.SPEECH_END:
422 case CommandFromPage.SPEECH_RESET:
423 this.startHotwording_();
430 * Handles a message directly from the NTP/HP/SERP.
431 * @param {!Object} request Message from the sender.
432 * @param {!MessageSender} sender Information about the sender.
433 * @param {!function(HotwordStatus)} sendResponse Callback to respond
435 * @return {boolean} Whether to maintain the port open to call sendResponse.
438 handleMessageFromPage_: function(request, sender, sendResponse) {
439 switch (request.type) {
440 case CommandFromPage.PAGE_WAKEUP:
441 if (sender.tab && this.isEligibleUrl_(sender.tab.url)) {
442 chrome.hotwordPrivate.getStatus(
443 true /* getOptionalFields */,
444 this.statusDone_.bind(
446 request.tab || sender.tab || {incognito: true},
451 case CommandFromPage.CLICKED_OPTIN:
452 chrome.hotwordPrivate.setEnabled(true);
454 // User has explicitly clicked 'no thanks'.
455 case CommandFromPage.CLICKED_NO_OPTIN:
456 chrome.hotwordPrivate.setEnabled(false);
463 * Sends the response to the tab.
464 * @param {Tab} tab The tab that the request was sent from.
465 * @param {function(HotwordStatus)} sendResponse Callback function to
467 * @param {HotwordStatus} hotwordStatus Status of the hotword extension.
470 statusDone_: function(tab, sendResponse, hotwordStatus) {
471 var response = {'doNotShowOptinMessage': true};
473 // If always-on is available, then we do not show the promo, as the promo
474 // only works with the sometimes-on pref.
475 if (!tab.incognito && hotwordStatus.available &&
476 !hotwordStatus.enabledSet && !hotwordStatus.alwaysOnAvailable) {
477 response = hotwordStatus;
481 sendResponse(response);
483 // Suppress the exception thrown by sendResponse() when the page doesn't
484 // specify a response callback in the call to
485 // chrome.runtime.sendMessage().
486 // Unfortunately, there doesn't appear to be a way to detect one-way
487 // messages without explicitly saying in the message itself. This
488 // message is defined as a constant in
489 // extensions/renderer/messaging_bindings.cc
490 if (err.message == 'Attempting to use a disconnected port object')
497 * Set up event listeners.
500 setupListeners_: function() {
501 if (chrome.runtime.onConnect.hasListener(this.connectListener_))
504 chrome.runtime.onConnect.addListener(this.connectListener_);
505 chrome.tabs.onCreated.addListener(this.tabCreatedListener_);
506 chrome.tabs.onUpdated.addListener(this.tabUpdatedListener_);
507 chrome.tabs.onActivated.addListener(this.tabActivatedListener_);
508 chrome.windows.onFocusChanged.addListener(
509 this.windowFocusChangedListener_);
510 chrome.hotwordPrivate.onMicrophoneStateChanged.addListener(
511 this.microphoneStateChangedListener_);
512 if (chrome.runtime.onMessage.hasListener(this.messageListener_))
514 chrome.runtime.onMessageExternal.addListener(
515 this.messageListener_);
519 * Remove event listeners.
522 removeListeners_: function() {
523 chrome.runtime.onConnect.removeListener(this.connectListener_);
524 chrome.tabs.onCreated.removeListener(this.tabCreatedListener_);
525 chrome.tabs.onUpdated.removeListener(this.tabUpdatedListener_);
526 chrome.tabs.onActivated.removeListener(this.tabActivatedListener_);
527 chrome.windows.onFocusChanged.removeListener(
528 this.windowFocusChangedListener_);
529 chrome.hotwordPrivate.onMicrophoneStateChanged.removeListener(
530 this.microphoneStateChangedListener_);
531 // Don't remove the Message listener, as we want them listening all
536 * Update event listeners based on the current hotwording state.
539 updateListeners_: function() {
540 if (this.stateManager_.isSometimesOnEnabled()) {
541 this.setupListeners_();
543 this.removeListeners_();
544 this.stopHotwording_();
545 this.disconnectAllClients_();
551 PageAudioManager: PageAudioManager