Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / chrome / browser / resources / hotword / page_audio_manager.js
blob067ff4ec2358a418af2b86df0269523c094d134c
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() {
6   'use strict';
8   /**
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
12    * pages.
13    * @param {!hotword.StateManager} stateManager
14    * @constructor
15    */
16   function PageAudioManager(stateManager) {
17     /**
18      * Manager of global hotwording state.
19      * @private {!hotword.StateManager}
20      */
21     this.stateManager_ = stateManager;
23     /**
24      * Mapping between tab ID and port that is connected from the injected
25      * content script.
26      * @private {!Object<number, Port>}
27      */
28     this.portMap_ = {};
30     /**
31      * Chrome event listeners. Saved so that they can be de-registered when
32      * hotwording is disabled.
33      */
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_();
50     }.bind(this));
51   };
53   var CommandToPage = hotword.constants.CommandToPage;
54   var CommandFromPage = hotword.constants.CommandFromPage;
56   PageAudioManager.prototype = {
57     /**
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.
62      * @private
63      */
64     checkUrlPathIsEligible_: function(url, base) {
65       if (url == base ||
66           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) {
72         return true;
73       }
74       return false;
75     },
77     /**
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.
82      * @private
83      */
84     isEligibleUrl_: function(url) {
85       if (!url)
86         return false;
88       var baseGoogleUrls = [
89         'https://www.google.',
90         'https://encrypted.google.'
91       ];
92       // TODO(amistry): Get this list from a file in the shared module instead.
93       var tlds = [
94         'at',
95         'ca',
96         'com',
97         'com.au',
98         'com.mx',
99         'com.br',
100         'co.jp',
101         'co.kr',
102         'co.nz',
103         'co.uk',
104         'co.za',
105         'de',
106         'es',
107         'fr',
108         'it',
109         'ru'
110       ];
112       // Check for the new tab page first.
113       if (this.checkUrlPathIsEligible_(url, 'chrome://newtab'))
114         return true;
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))
121             return true;
122         }
123       }
124       return false;
125     },
127     /**
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
132      *     this object.
133      * @private
134      */
135     findCurrentTab_: function(callback) {
136       chrome.windows.getAll(
137           {'populate': true},
138           function(windows) {
139             for (var i = 0; i < windows.length; ++i) {
140               if (!windows[i].focused)
141                 continue;
143               for (var j = 0; j < windows[i].tabs.length; ++j) {
144                 var tab = windows[i].tabs[j];
145                 if (tab.active) {
146                   callback.call(this, tab);
147                   return;
148                 }
149               }
150             }
151             callback.call(this, null);
152           }.bind(this));
153     },
155     /**
156      * This function is called when a tab is activated (comes into focus).
157      * @param {Tab} tab Current active tab.
158      * @private
159      */
160     activateTab_: function(tab) {
161       if (!tab) {
162         this.stopHotwording_();
163         return;
164       }
165       if (tab.id in this.portMap_) {
166         this.startHotwordingIfEligible_();
167         return;
168       }
169       this.stopHotwording_();
170       this.prepareTab_(tab);
171     },
173     /**
174      * Prepare a new or updated tab by injecting the content script.
175      * @param {!Tab} tab Newly updated or created tab.
176      * @private
177      */
178     prepareTab_: function(tab) {
179       if (!this.isEligibleUrl_(tab.url))
180         return;
182       chrome.tabs.executeScript(
183           tab.id,
184           {'file': 'audio_client.js'},
185           function(results) {
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.
192               //
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.
196             }
197           });
198     },
200     /**
201      * Updates hotwording state based on the state of current tabs/windows.
202      * @private
203      */
204     updateTabState_: function() {
205       this.findCurrentTab_(this.activateTab_);
206     },
208     /**
209      * Handles a newly created tab.
210      * @param {!Tab} tab Newly created tab.
211      * @private
212      */
213     handleCreatedTab_: function(tab) {
214       this.prepareTab_(tab);
215     },
217     /**
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.
222      * @private
223      */
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')
228         return;
230       this.prepareTab_(tab);
231     },
233     /**
234      * Handles a tab that has just become active.
235      * @param {{tabId: number}} info Information about the activated tab.
236      * @private
237      */
238     handleActivatedTab_: function(info) {
239       this.updateTabState_();
240     },
242     /**
243      * Handles the microphone state changing.
244      * @param {boolean} enabled Whether the microphone is now enabled.
245      * @private
246      */
247     handleMicrophoneStateChanged_: function(enabled) {
248       if (enabled) {
249         this.updateTabState_();
250         return;
251       }
253       this.stopHotwording_();
254     },
256     /**
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.
260      * @private
261      */
262     handleChangedWindow_: function(windowId) {
263       this.updateTabState_();
264     },
266     /**
267      * Handles a content script attempting to connect.
268      * @param {!Port} port Communications port from the client.
269      * @private
270      */
271     handleConnect_: function(port) {
272       if (port.name != hotword.constants.CLIENT_PORT_NAME)
273         return;
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);
281       }.bind(this));
282       port.onMessage.addListener(function(msg) {
283         this.handleMessage_(msg, port.sender, port.postMessage);
284       }.bind(this));
285     },
287     /**
288      * Handles a client content script disconnect.
289      * @param {Port} port Disconnected port.
290      * @private
291      */
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];
298       }
299       this.stopHotwordingIfIneligibleTab_();
300     },
302     /**
303      * Disconnect all connected clients.
304      * @private
305      */
306     disconnectAllClients_: function() {
307       for (var id in this.portMap_) {
308         var port = this.portMap_[id];
309         port.disconnect();
310         delete this.portMap_[id];
311       }
312     },
314     /**
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.
318      * @private
319      */
320     sendClient_: function(command, tabId) {
321       if (tabId in this.portMap_) {
322         var message = {};
323         message[hotword.constants.COMMAND_FIELD_NAME] = command;
324         this.portMap_[tabId].postMessage(message);
325       }
326     },
328     /**
329      * Sends a command to all connected clients.
330      * @param {hotword.constants.CommandToPage} command Command to send.
331      * @private
332      */
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);
338       }
339     },
341     /**
342      * Handles a hotword trigger. Sends a trigger message to the currently
343      * active tab.
344      * @private
345      */
346     hotwordTriggered_: function() {
347       this.findCurrentTab_(function(tab) {
348         if (tab)
349           this.sendClient_(CommandToPage.HOTWORD_VOICE_TRIGGER, tab.id);
350       });
351     },
353     /**
354      * Starts hotwording.
355      * @private
356      */
357     startHotwording_: function() {
358       this.stateManager_.startSession(
359           hotword.constants.SessionSource.NTP,
360           function() {
361             this.sendAllClients_(CommandToPage.HOTWORD_STARTED);
362           }.bind(this),
363           this.hotwordTriggered_.bind(this));
364     },
366     /**
367      * Starts hotwording if the currently active tab is eligible for hotwording
368      * (e.g. google.com).
369      * @private
370      */
371     startHotwordingIfEligible_: function() {
372       this.findCurrentTab_(function(tab) {
373         if (!tab) {
374           this.stopHotwording_();
375           return;
376         }
377         if (this.isEligibleUrl_(tab.url))
378           this.startHotwording_();
379       });
380     },
382     /**
383      * Stops hotwording.
384      * @private
385      */
386     stopHotwording_: function() {
387       this.stateManager_.stopSession(hotword.constants.SessionSource.NTP);
388       this.sendAllClients_(CommandToPage.HOTWORD_ENDED);
389     },
391     /**
392      * Stops hotwording if the currently active tab is not eligible for
393      * hotwording (i.e. google.com).
394      * @private
395      */
396     stopHotwordingIfIneligibleTab_: function() {
397       this.findCurrentTab_(function(tab) {
398         if (!tab) {
399           this.stopHotwording_();
400           return;
401         }
402         if (!this.isEligibleUrl_(tab.url))
403           this.stopHotwording_();
404       });
405     },
407     /**
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.
412      * @private
413      */
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_();
420           break;
421         case CommandFromPage.SPEECH_END:
422         case CommandFromPage.SPEECH_RESET:
423           this.startHotwording_();
424           break;
425       }
426     },
429     /**
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
434      *     to sender.
435      * @return {boolean} Whether to maintain the port open to call sendResponse.
436      * @private
437      */
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(
445                     this,
446                     request.tab || sender.tab || {incognito: true},
447                     sendResponse));
448             return true;
449           }
450           break;
451         case CommandFromPage.CLICKED_OPTIN:
452           chrome.hotwordPrivate.setEnabled(true);
453           break;
454         // User has explicitly clicked 'no thanks'.
455         case CommandFromPage.CLICKED_NO_OPTIN:
456           chrome.hotwordPrivate.setEnabled(false);
457           break;
458       }
459       return false;
460     },
462     /**
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
466      *     respond to sender.
467      * @param {HotwordStatus} hotwordStatus Status of the hotword extension.
468      * @private
469      */
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;
478       }
480       try {
481         sendResponse(response);
482       } catch (err) {
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')
491           return;
492         throw err;
493       }
494     },
496     /**
497      * Set up event listeners.
498      * @private
499      */
500     setupListeners_: function() {
501       if (chrome.runtime.onConnect.hasListener(this.connectListener_))
502         return;
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_))
513         return;
514       chrome.runtime.onMessageExternal.addListener(
515           this.messageListener_);
516     },
518     /**
519      * Remove event listeners.
520      * @private
521      */
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
532       // the time,
533     },
535     /**
536      * Update event listeners based on the current hotwording state.
537      * @private
538      */
539     updateListeners_: function() {
540       if (this.stateManager_.isSometimesOnEnabled()) {
541         this.setupListeners_();
542       } else {
543         this.removeListeners_();
544         this.stopHotwording_();
545         this.disconnectAllClients_();
546       }
547     }
548   };
550   return {
551     PageAudioManager: PageAudioManager
552   };