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.windowFocusChangedListener_ = this.handleChangedWindow_.bind(this);
39 this.messageListener_ = this.handleMessageFromPage_.bind(this);
41 // Need to setup listeners on startup, otherwise events that caused the
42 // event page to start up, will be lost.
43 this.setupListeners_();
45 this.stateManager_.onStatusChanged.addListener(function() {
46 this.updateListeners_();
47 this.updateTabState_();
51 var CommandToPage = hotword.constants.CommandToPage;
52 var CommandFromPage = hotword.constants.CommandFromPage;
54 PageAudioManager.prototype = {
56 * Helper function to test if a URL path is eligible for hotwording.
57 * @param {!string} url URL to check.
58 * @param {!string} base Base URL to compare against..
59 * @return {boolean} True if url is an eligible hotword URL.
62 checkUrlPathIsEligible_: function(url, base) {
65 url.indexOf(base + '/_/chrome/newtab?') == 0 || // Appcache NTP.
66 url.indexOf(base + '/?') == 0 ||
67 url.indexOf(base + '/#') == 0 ||
68 url.indexOf(base + '/webhp') == 0 ||
69 url.indexOf(base + '/search') == 0) {
76 * Determines if a URL is eligible for hotwording. For now, the valid pages
77 * are the Google HP and SERP (this will include the NTP).
78 * @param {!string} url URL to check.
79 * @return {boolean} True if url is an eligible hotword URL.
82 isEligibleUrl_: function(url) {
86 var baseGoogleUrls = [
87 'https://www.google.',
88 'https://encrypted.google.'
90 // TODO(amistry): Get this list from a file in the shared module instead.
110 // Check for the new tab page first.
111 if (this.checkUrlPathIsEligible_(url, 'chrome://newtab'))
114 // Check URLs with each type of local-based TLD.
115 for (var i = 0; i < baseGoogleUrls.length; i++) {
116 for (var j = 0; j < tlds.length; j++) {
117 var base = baseGoogleUrls[i] + tlds[j];
118 if (this.checkUrlPathIsEligible_(url, base))
126 * Locates the current active tab in the current focused window and
127 * performs a callback with the tab as the parameter.
128 * @param {function(?Tab)} callback Function to call with the
129 * active tab or null if not found. The function's |this| will be set to
133 findCurrentTab_: function(callback) {
134 chrome.windows.getAll(
137 for (var i = 0; i < windows.length; ++i) {
138 if (!windows[i].focused)
141 for (var j = 0; j < windows[i].tabs.length; ++j) {
142 var tab = windows[i].tabs[j];
144 callback.call(this, tab);
149 callback.call(this, null);
154 * This function is called when a tab is activated (comes into focus).
155 * @param {Tab} tab Current active tab.
158 activateTab_: function(tab) {
160 this.stopHotwording_();
163 if (tab.id in this.portMap_) {
164 this.startHotwordingIfEligible_();
167 this.stopHotwording_();
168 this.prepareTab_(tab);
172 * Prepare a new or updated tab by injecting the content script.
173 * @param {!Tab} tab Newly updated or created tab.
176 prepareTab_: function(tab) {
177 if (!this.isEligibleUrl_(tab.url))
180 chrome.tabs.executeScript(
182 {'file': 'audio_client.js'},
184 if (chrome.runtime.lastError) {
185 // Ignore this error. For new tab pages, even though the URL is
186 // reported to be chrome://newtab/, the actual URL is a
187 // country-specific google domain. Since we don't have permission
188 // to inject on every page, an error will happen when the user is
189 // in an unsupported country.
191 // The property still needs to be accessed so that the error
192 // condition is cleared. If it isn't, exectureScript will log an
193 // error the next time it is called.
199 * Updates hotwording state based on the state of current tabs/windows.
202 updateTabState_: function() {
203 this.findCurrentTab_(this.activateTab_);
207 * Handles a newly created tab.
208 * @param {!Tab} tab Newly created tab.
211 handleCreatedTab_: function(tab) {
212 this.prepareTab_(tab);
216 * Handles an updated tab.
217 * @param {number} tabId Id of the updated tab.
218 * @param {{status: string}} info Change info of the tab.
219 * @param {!Tab} tab Updated tab.
222 handleUpdatedTab_: function(tabId, info, tab) {
223 // Chrome fires multiple update events: undefined, loading and completed.
224 // We perform content injection on loading state.
225 if (info['status'] != 'loading')
228 this.prepareTab_(tab);
232 * Handles a tab that has just become active.
233 * @param {{tabId: number}} info Information about the activated tab.
236 handleActivatedTab_: function(info) {
237 this.updateTabState_();
242 * Handles a change in Chrome windows.
243 * Note: this does not always trigger in Linux.
244 * @param {number} windowId Id of newly focused window.
247 handleChangedWindow_: function(windowId) {
248 this.updateTabState_();
252 * Handles a content script attempting to connect.
253 * @param {!Port} port Communications port from the client.
256 handleConnect_: function(port) {
257 if (port.name != hotword.constants.CLIENT_PORT_NAME)
260 var tab = /** @type {!Tab} */(port.sender.tab);
261 // An existing port from the same tab might already exist. But that port
262 // may be from the previous page, so just overwrite the port.
263 this.portMap_[tab.id] = port;
264 port.onDisconnect.addListener(function() {
265 this.handleClientDisconnect_(port);
267 port.onMessage.addListener(function(msg) {
268 this.handleMessage_(msg, port.sender, port.postMessage);
273 * Handles a client content script disconnect.
274 * @param {Port} port Disconnected port.
277 handleClientDisconnect_: function(port) {
278 var tabId = port.sender.tab.id;
279 if (tabId in this.portMap_ && this.portMap_[tabId] == port) {
280 // Due to a race between port disconnection and tabs.onUpdated messages,
281 // the port could have changed.
282 delete this.portMap_[port.sender.tab.id];
284 this.stopHotwordingIfIneligibleTab_();
288 * Disconnect all connected clients.
291 disconnectAllClients_: function() {
292 for (var id in this.portMap_) {
293 var port = this.portMap_[id];
295 delete this.portMap_[id];
300 * Sends a command to the client content script on an eligible tab.
301 * @param {hotword.constants.CommandToPage} command Command to send.
302 * @param {number} tabId Id of the target tab.
305 sendClient_: function(command, tabId) {
306 if (tabId in this.portMap_) {
308 message[hotword.constants.COMMAND_FIELD_NAME] = command;
309 this.portMap_[tabId].postMessage(message);
314 * Sends a command to all connected clients.
315 * @param {hotword.constants.CommandToPage} command Command to send.
318 sendAllClients_: function(command) {
319 for (var idStr in this.portMap_) {
320 var id = parseInt(idStr, 10);
321 assert(!isNaN(id), 'Tab ID is not a number: ' + idStr);
322 this.sendClient_(command, id);
327 * Handles a hotword trigger. Sends a trigger message to the currently
331 hotwordTriggered_: function() {
332 this.findCurrentTab_(function(tab) {
334 this.sendClient_(CommandToPage.HOTWORD_VOICE_TRIGGER, tab.id);
342 startHotwording_: function() {
343 this.stateManager_.startSession(
344 hotword.constants.SessionSource.NTP,
346 this.sendAllClients_(CommandToPage.HOTWORD_STARTED);
348 this.hotwordTriggered_.bind(this));
352 * Starts hotwording if the currently active tab is eligible for hotwording
356 startHotwordingIfEligible_: function() {
357 this.findCurrentTab_(function(tab) {
359 this.stopHotwording_();
362 if (this.isEligibleUrl_(tab.url))
363 this.startHotwording_();
371 stopHotwording_: function() {
372 this.stateManager_.stopSession(hotword.constants.SessionSource.NTP);
373 this.sendAllClients_(CommandToPage.HOTWORD_ENDED);
377 * Stops hotwording if the currently active tab is not eligible for
378 * hotwording (i.e. google.com).
381 stopHotwordingIfIneligibleTab_: function() {
382 this.findCurrentTab_(function(tab) {
384 this.stopHotwording_();
387 if (!this.isEligibleUrl_(tab.url))
388 this.stopHotwording_();
393 * Handles a message from the content script injected into the page.
394 * @param {!Object} request Request from the content script.
395 * @param {!MessageSender} sender Message sender.
396 * @param {!function(Object)} sendResponse Function for sending a response.
399 handleMessage_: function(request, sender, sendResponse) {
400 switch (request[hotword.constants.COMMAND_FIELD_NAME]) {
401 // TODO(amistry): Handle other messages such as CLICKED_RESUME and
402 // CLICKED_RESTART, if necessary.
403 case CommandFromPage.SPEECH_START:
404 this.stopHotwording_();
406 case CommandFromPage.SPEECH_END:
407 case CommandFromPage.SPEECH_RESET:
408 this.startHotwording_();
415 * Handles a message directly from the NTP/HP/SERP.
416 * @param {!Object} request Message from the sender.
417 * @param {!MessageSender} sender Information about the sender.
418 * @param {!function(HotwordStatus)} sendResponse Callback to respond
420 * @return {boolean} Whether to maintain the port open to call sendResponse.
423 handleMessageFromPage_: function(request, sender, sendResponse) {
424 switch (request.type) {
425 case CommandFromPage.PAGE_WAKEUP:
426 if (sender.tab && this.isEligibleUrl_(sender.tab.url)) {
427 chrome.hotwordPrivate.getStatus(
428 this.statusDone_.bind(
430 request.tab || sender.tab || {incognito: true},
435 case CommandFromPage.CLICKED_OPTIN:
436 chrome.hotwordPrivate.setEnabled(true);
438 // User has explicitly clicked 'no thanks'.
439 case CommandFromPage.CLICKED_NO_OPTIN:
440 chrome.hotwordPrivate.setEnabled(false);
447 * Sends the response to the tab.
448 * @param {Tab} tab The tab that the request was sent from.
449 * @param {function(HotwordStatus)} sendResponse Callback function to
451 * @param {HotwordStatus} hotwordStatus Status of the hotword extension.
454 statusDone_: function(tab, sendResponse, hotwordStatus) {
455 var response = {'doNotShowOptinMessage': true};
457 // If always-on is available, then we do not show the promo, as the promo
458 // only works with the sometimes-on pref.
459 if (!tab.incognito && hotwordStatus.available &&
460 !hotwordStatus.enabledSet && !hotwordStatus.alwaysOnAvailable) {
461 response = hotwordStatus;
465 sendResponse(response);
467 // Suppress the exception thrown by sendResponse() when the page doesn't
468 // specify a response callback in the call to
469 // chrome.runtime.sendMessage().
470 // Unfortunately, there doesn't appear to be a way to detect one-way
471 // messages without explicitly saying in the message itself. This
472 // message is defined as a constant in
473 // extensions/renderer/messaging_bindings.cc
474 if (err.message == 'Attempting to use a disconnected port object')
481 * Set up event listeners.
484 setupListeners_: function() {
485 if (chrome.runtime.onConnect.hasListener(this.connectListener_))
488 chrome.runtime.onConnect.addListener(this.connectListener_);
489 chrome.tabs.onCreated.addListener(this.tabCreatedListener_);
490 chrome.tabs.onUpdated.addListener(this.tabUpdatedListener_);
491 chrome.tabs.onActivated.addListener(this.tabActivatedListener_);
492 chrome.windows.onFocusChanged.addListener(
493 this.windowFocusChangedListener_);
494 if (chrome.runtime.onMessage.hasListener(this.messageListener_))
496 chrome.runtime.onMessageExternal.addListener(
497 this.messageListener_);
501 * Remove event listeners.
504 removeListeners_: function() {
505 chrome.runtime.onConnect.removeListener(this.connectListener_);
506 chrome.tabs.onCreated.removeListener(this.tabCreatedListener_);
507 chrome.tabs.onUpdated.removeListener(this.tabUpdatedListener_);
508 chrome.tabs.onActivated.removeListener(this.tabActivatedListener_);
509 chrome.windows.onFocusChanged.removeListener(
510 this.windowFocusChangedListener_);
511 // Don't remove the Message listener, as we want them listening all
516 * Update event listeners based on the current hotwording state.
519 updateListeners_: function() {
520 if (this.stateManager_.isSometimesOnEnabled()) {
521 this.setupListeners_();
523 this.removeListeners_();
524 this.stopHotwording_();
525 this.disconnectAllClients_();
531 PageAudioManager: PageAudioManager