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