blob: 11e343d0dbe544993f7d4c9c70afdee240c47b2a [file] [log] [blame]
// 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.
cr.define('hotword', function() {
'use strict';
/**
* Class used to manage the interaction between hotwording and the
* NTP/google.com. Injects a content script to interact with NTP/google.com
* and updates the global hotwording state based on interaction with those
* pages.
* @param {!hotword.StateManager} stateManager
* @constructor
*/
function PageAudioManager(stateManager) {
/**
* Manager of global hotwording state.
* @private {!hotword.StateManager}
*/
this.stateManager_ = stateManager;
/**
* Mapping between tab ID and port that is connected from the injected
* content script.
* @private {!Object.<number, Port>}
*/
this.portMap_ = {};
/**
* Chrome event listeners. Saved so that they can be de-registered when
* hotwording is disabled.
*/
this.connectListener_ = this.handleConnect_.bind(this);
this.tabCreatedListener_ = this.handleCreatedTab_.bind(this);
this.tabUpdatedListener_ = this.handleUpdatedTab_.bind(this);
this.tabActivatedListener_ = this.handleActivatedTab_.bind(this);
this.windowFocusChangedListener_ = this.handleChangedWindow_.bind(this);
// Need to setup listeners on startup, otherwise events that caused the
// event page to start up, will be lost.
this.setupListeners_();
this.stateManager_.onStatusChanged.addListener(function() {
this.updateListeners_();
}.bind(this));
};
var CommandToPage = hotword.constants.CommandToPage;
var CommandFromPage = hotword.constants.CommandFromPage;
PageAudioManager.prototype = {
/**
* Helper function to test if a URL path is eligible for hotwording.
* @param {!string} url URL to check.
* @param {!string} base Base URL to compare against..
* @return {boolean} True if url is an eligible hotword URL.
* @private
*/
checkUrlPathIsEligible_: function(url, base) {
if (url == base ||
url == base + '/' ||
url.indexOf(base + '/_/chrome/newtab?') == 0 || // Appcache NTP.
url.indexOf(base + '/?') == 0 ||
url.indexOf(base + '/#') == 0 ||
url.indexOf(base + '/webhp') == 0 ||
url.indexOf(base + '/search') == 0) {
return true;
}
return false;
},
/**
* Determines if a URL is eligible for hotwording. For now, the valid pages
* are the Google HP and SERP (this will include the NTP).
* @param {!string} url URL to check.
* @return {boolean} True if url is an eligible hotword URL.
* @private
*/
isEligibleUrl_: function(url) {
if (!url)
return false;
var baseGoogleUrls = [
'https://www.google.',
'https://encrypted.google.'
];
// TODO(amistry): Get this list from a file in the shared module instead.
var tlds = [
'com',
'co.uk',
'de',
'fr',
'ru'
];
// Check for the new tab page first.
if (this.checkUrlPathIsEligible_(url, 'chrome://newtab'))
return true;
// Check URLs with each type of local-based TLD.
for (var i = 0; i < baseGoogleUrls.length; i++) {
for (var j = 0; j < tlds.length; j++) {
var base = baseGoogleUrls[i] + tlds[j];
if (this.checkUrlPathIsEligible_(url, base))
return true;
}
}
return false;
},
/**
* Locates the current active tab in the current focused window and
* performs a callback with the tab as the parameter.
* @param {function(?Tab)} callback Function to call with the
* active tab or null if not found. The function's |this| will be set to
* this object.
* @private
*/
findCurrentTab_: function(callback) {
chrome.windows.getAll(
{'populate': true},
function(windows) {
for (var i = 0; i < windows.length; ++i) {
if (!windows[i].focused)
continue;
for (var j = 0; j < windows[i].tabs.length; ++j) {
var tab = windows[i].tabs[j];
if (tab.active) {
callback.call(this, tab);
return;
}
}
}
callback.call(this, null);
}.bind(this));
},
/**
* This function is called when a tab is activated (comes into focus).
* @param {Tab} tab Current active tab.
* @private
*/
activateTab_: function(tab) {
if (!tab) {
this.stopHotwording_();
return;
}
if (tab.id in this.portMap_) {
this.startHotwordingIfEligible_();
return;
}
this.stopHotwording_();
this.prepareTab_(tab);
},
/**
* Prepare a new or updated tab by injecting the content script.
* @param {!Tab} tab Newly updated or created tab.
* @private
*/
prepareTab_: function(tab) {
if (!this.isEligibleUrl_(tab.url))
return;
chrome.tabs.executeScript(tab.id, {'file': 'audio_client.js'});
},
/**
* Updates hotwording state based on the state of current tabs/windows.
* @private
*/
updateTabState_: function() {
this.findCurrentTab_(this.activateTab_);
},
/**
* Handles a newly created tab.
* @param {!Tab} tab Newly created tab.
* @private
*/
handleCreatedTab_: function(tab) {
this.prepareTab_(tab);
},
/**
* Handles an updated tab.
* @param {number} tabId Id of the updated tab.
* @param {{status: string}} info Change info of the tab.
* @param {!Tab} tab Updated tab.
* @private
*/
handleUpdatedTab_: function(tabId, info, tab) {
// Chrome fires multiple update events: undefined, loading and completed.
// We perform content injection on loading state.
if (info['status'] != 'loading')
return;
this.prepareTab_(tab);
},
/**
* Handles a tab that was just became active.
* @param {{tabId: number}} info Information about the activated tab.
* @private
*/
handleActivatedTab_: function(info) {
this.updateTabState_();
},
/**
* Handles a change in Chrome windows.
* Note: this does not always trigger in Linux.
* @param {number} windowId Id of newly focused window.
* @private
*/
handleChangedWindow_: function(windowId) {
this.updateTabState_();
},
/**
* Handles a content script attempting to connect.
* @param {!Port} port Communications port from the client.
* @private
*/
handleConnect_: function(port) {
if (port.name != hotword.constants.CLIENT_PORT_NAME)
return;
var tab = /** @type {!Tab} */(port.sender.tab);
// An existing port from the same tab might already exist. But that port
// may be from the previous page, so just overwrite the port.
this.portMap_[tab.id] = port;
port.onDisconnect.addListener(function() {
this.handleClientDisconnect_(port);
}.bind(this));
port.onMessage.addListener(function(msg) {
this.handleMessage_(msg, port.sender, port.postMessage);
}.bind(this));
},
/**
* Handles a client content script disconnect.
* @param {Port} port Disconnected port.
* @private
*/
handleClientDisconnect_: function(port) {
var tabId = port.sender.tab.id;
if (tabId in this.portMap_ && this.portMap_[tabId] == port) {
// Due to a race between port disconnection and tabs.onUpdated messages,
// the port could have changed.
delete this.portMap_[port.sender.tab.id];
}
this.stopHotwordingIfIneligibleTab_();
},
/**
* Disconnect all connected clients.
* @private
*/
disconnectAllClients_: function() {
for (var id in this.portMap_) {
var port = this.portMap_[id];
port.disconnect();
delete this.portMap_[id];
}
},
/**
* Sends a command to the client content script on an eligible tab.
* @param {hotword.constants.CommandToPage} command Command to send.
* @param {number} tabId Id of the target tab.
* @private
*/
sendClient_: function(command, tabId) {
if (tabId in this.portMap_) {
var message = {};
message[hotword.constants.COMMAND_FIELD_NAME] = command;
this.portMap_[tabId].postMessage(message);
}
},
/**
* Sends a command to all connected clients.
* @param {hotword.constants.CommandToPage} command Command to send.
* @private
*/
sendAllClients_: function(command) {
for (var idStr in this.portMap_) {
var id = parseInt(idStr, 10);
assert(!isNaN(id), 'Tab ID is not a number: ' + idStr);
this.sendClient_(command, id);
}
},
/**
* Handles a hotword trigger. Sends a trigger message to the currently
* active tab.
* @private
*/
hotwordTriggered_: function() {
this.findCurrentTab_(function(tab) {
if (tab)
this.sendClient_(CommandToPage.HOTWORD_VOICE_TRIGGER, tab.id);
});
},
/**
* Starts hotwording.
* @private
*/
startHotwording_: function() {
this.stateManager_.startSession(
hotword.constants.SessionSource.NTP,
function() {
this.sendAllClients_(CommandToPage.HOTWORD_STARTED);
}.bind(this),
this.hotwordTriggered_.bind(this));
},
/**
* Starts hotwording if the currently active tab is eligible for hotwording
* (i.e. google.com).
* @private
*/
startHotwordingIfEligible_: function() {
this.findCurrentTab_(function(tab) {
if (!tab) {
this.stopHotwording_();
return;
}
if (this.isEligibleUrl_(tab.url))
this.startHotwording_();
});
},
/**
* Stops hotwording.
* @private
*/
stopHotwording_: function() {
this.stateManager_.stopSession(hotword.constants.SessionSource.NTP);
this.sendAllClients_(CommandToPage.HOTWORD_ENDED);
},
/**
* Stops hotwording if the currently active tab is not eligible for
* hotwording (i.e. google.com).
* @private
*/
stopHotwordingIfIneligibleTab_: function() {
this.findCurrentTab_(function(tab) {
if (!tab) {
this.stopHotwording_();
return;
}
if (!this.isEligibleUrl_(tab.url))
this.stopHotwording_();
});
},
/**
* Handles a message from the content script injected into the page.
* @param {!Object} request Request from the content script.
* @param {!MessageSender} sender Message sender.
* @param {!function(Object)} sendResponse Function for sending a response.
* @private
*/
handleMessage_: function(request, sender, sendResponse) {
switch (request[hotword.constants.COMMAND_FIELD_NAME]) {
// TODO(amistry): Handle other messages such as CLICKED_RESUME and
// CLICKED_RESTART, if necessary.
case CommandFromPage.SPEECH_START:
this.stopHotwording_();
break;
case CommandFromPage.SPEECH_END:
case CommandFromPage.SPEECH_RESET:
this.startHotwording_();
break;
}
},
/**
* Set up event listeners.
* @private
*/
setupListeners_: function() {
if (chrome.runtime.onConnect.hasListener(this.connectListener_))
return;
chrome.runtime.onConnect.addListener(this.connectListener_);
chrome.tabs.onCreated.addListener(this.tabCreatedListener_);
chrome.tabs.onUpdated.addListener(this.tabUpdatedListener_);
chrome.tabs.onActivated.addListener(this.tabActivatedListener_);
chrome.windows.onFocusChanged.addListener(
this.windowFocusChangedListener_);
},
/**
* Remove event listeners.
* @private
*/
removeListeners_: function() {
chrome.runtime.onConnect.removeListener(this.connectListener_);
chrome.tabs.onCreated.removeListener(this.tabCreatedListener_);
chrome.tabs.onUpdated.removeListener(this.tabUpdatedListener_);
chrome.tabs.onActivated.removeListener(this.tabActivatedListener_);
chrome.windows.onFocusChanged.removeListener(
this.windowFocusChangedListener_);
},
/**
* Update event listeners based on the current hotwording state.
* @private
*/
updateListeners_: function() {
var enabled = this.stateManager_.isEnabled() &&
!this.stateManager_.isAlwaysOnEnabled();
if (enabled) {
this.setupListeners_();
} else {
this.removeListeners_();
this.stopHotwording_();
this.disconnectAllClients_();
}
}
};
return {
PageAudioManager: PageAudioManager
};
});