Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / chrome / browser / resources / hotword_audio_verification / flow.js
blob9c0c4b016000bd674cc6e4203b8a2cd741e25817
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 (function() {
7   // Correspond to steps in the hotword opt-in flow.
8   /** @const */ var START = 'start-container';
9   /** @const */ var AUDIO_HISTORY = 'audio-history-container';
10   /** @const */ var SPEECH_TRAINING = 'speech-training-container';
11   /** @const */ var FINISH = 'finish-container';
13   /**
14    * These flows correspond to the three LaunchModes as defined in
15    * chrome/browser/search/hotword_service.h and should be kept in sync
16    * with them.
17    * @const
18    */
19   var FLOWS = [
20     [START, SPEECH_TRAINING, FINISH],
21     [START, AUDIO_HISTORY, SPEECH_TRAINING, FINISH],
22     [SPEECH_TRAINING, FINISH]
23   ];
25   /**
26    * The launch mode. This enum needs to be kept in sync with that of
27    * the same name in hotword_service.h.
28    * @enum {number}
29    */
30   var LaunchMode = {
31     HOTWORD_ONLY: 0,
32     HOTWORD_AND_AUDIO_HISTORY: 1,
33     RETRAIN: 2
34   };
36   /**
37    * The training state.
38    * @enum {string}
39    */
40   var TrainingState = {
41     RESET: 'reset',
42     TIMEOUT: 'timeout',
43     ERROR: 'error',
44   };
46   /**
47    * Class to control the page flow of the always-on hotword and
48    * Audio History opt-in process.
49    * @constructor
50    */
51   function Flow() {
52     this.currentStepIndex_ = -1;
53     this.currentFlow_ = [];
55     /**
56      * The mode that this app was launched in.
57      * @private {LaunchMode}
58      */
59     this.launchMode_ = LaunchMode.HOTWORD_AND_AUDIO_HISTORY;
61     /**
62      * Whether this flow is currently in the process of training a voice model.
63      * @private {boolean}
64      */
65     this.training_ = false;
67     /**
68      * The current training state.
69      * @private {?TrainingState}
70      */
71     this.trainingState_ = null;
73     /**
74      * Whether an expected hotword trigger has been received, indexed by
75      * training step.
76      * @private {boolean[]}
77      */
78     this.hotwordTriggerReceived_ = [];
80     /**
81      * Prefix of the element ids for the page that is currently training.
82      * @private {string}
83      */
84     this.trainingPagePrefix_ = 'speech-training';
86     /**
87      * Whether the speaker model for this flow has been finalized.
88      * @private {boolean}
89      */
90     this.speakerModelFinalized_ = false;
92     /**
93      * ID of the currently active timeout.
94      * @private {?number}
95      */
96     this.timeoutId_ = null;
98     /**
99      * Listener for the speakerModelSaved event.
100      * @private {Function}
101      */
102     this.speakerModelFinalizedListener_ =
103         this.onSpeakerModelFinalized_.bind(this);
105     /**
106      * Listener for the hotword trigger event.
107      * @private {Function}
108      */
109     this.hotwordTriggerListener_ =
110           this.handleHotwordTrigger_.bind(this);
112     // Listen for the user locking the screen.
113     chrome.idle.onStateChanged.addListener(
114         this.handleIdleStateChanged_.bind(this));
116     // Listen for hotword settings changes. This used to detect when the user
117     // switches to a different profile.
118     if (chrome.hotwordPrivate.onEnabledChanged) {
119       chrome.hotwordPrivate.onEnabledChanged.addListener(
120           this.handleEnabledChanged_.bind(this));
121     }
122   }
124   /**
125    * Advances the current step. Begins training if the speech-training
126    * page has been reached.
127    */
128   Flow.prototype.advanceStep = function() {
129     this.currentStepIndex_++;
130     if (this.currentStepIndex_ < this.currentFlow_.length) {
131       if (this.currentFlow_[this.currentStepIndex_] == SPEECH_TRAINING)
132         this.startTraining();
133       this.showStep_.apply(this);
134     }
135   };
137   /**
138    * Gets the appropriate flow and displays its first page.
139    */
140   Flow.prototype.startFlow = function() {
141     if (chrome.hotwordPrivate && chrome.hotwordPrivate.getLaunchState)
142       chrome.hotwordPrivate.getLaunchState(this.startFlowForMode_.bind(this));
143   };
145   /**
146    * Starts the training process.
147    */
148   Flow.prototype.startTraining = function() {
149     // Don't start a training session if one already exists.
150     if (this.training_)
151       return;
153     this.training_ = true;
155     if (chrome.hotwordPrivate.onHotwordTriggered &&
156         !chrome.hotwordPrivate.onHotwordTriggered.hasListener(
157             this.hotwordTriggerListener_)) {
158       chrome.hotwordPrivate.onHotwordTriggered.addListener(
159           this.hotwordTriggerListener_);
160     }
162     this.waitForHotwordTrigger_(0);
163     if (chrome.hotwordPrivate.startTraining)
164       chrome.hotwordPrivate.startTraining();
165   };
167   /**
168    * Stops the training process.
169    */
170   Flow.prototype.stopTraining = function() {
171     if (!this.training_)
172       return;
174     this.training_ = false;
175     if (chrome.hotwordPrivate.onHotwordTriggered) {
176       chrome.hotwordPrivate.onHotwordTriggered.
177           removeListener(this.hotwordTriggerListener_);
178     }
179     if (chrome.hotwordPrivate.stopTraining)
180       chrome.hotwordPrivate.stopTraining();
181   };
183   /**
184    * Attempts to enable audio history for the signed-in account.
185    */
186   Flow.prototype.enableAudioHistory = function() {
187     // Update UI
188     $('audio-history-agree').disabled = true;
189     $('audio-history-cancel').disabled = true;
191     $('audio-history-error').hidden = true;
192     $('audio-history-wait').hidden = false;
194     if (chrome.hotwordPrivate.setAudioHistoryEnabled) {
195       chrome.hotwordPrivate.setAudioHistoryEnabled(
196           true, this.onAudioHistoryRequestCompleted_.bind(this));
197     }
198   };
200   // ---- private methods:
202   /**
203    * Shows an error if the audio history setting was not enabled successfully.
204    * @private
205    */
206   Flow.prototype.handleAudioHistoryError_ = function() {
207     $('audio-history-agree').disabled = false;
208     $('audio-history-cancel').disabled = false;
210     $('audio-history-wait').hidden = true;
211     $('audio-history-error').hidden = false;
213     // Set a timeout before focusing the Enable button so that screenreaders
214     // have time to announce the error first.
215     this.setTimeout_(function() {
216         $('audio-history-agree').focus();
217     }.bind(this), 50);
218   };
220   /**
221    * Callback for when an audio history request completes.
222    * @param {chrome.hotwordPrivate.AudioHistoryState} state The audio history
223    *     request state.
224    * @private
225    */
226   Flow.prototype.onAudioHistoryRequestCompleted_ = function(state) {
227     if (!state.success || !state.enabled) {
228       this.handleAudioHistoryError_();
229       return;
230     }
232     this.advanceStep();
233   };
235   /**
236    * Shows an error if the speaker model has not been finalized.
237    * @private
238    */
239   Flow.prototype.handleSpeakerModelFinalizedError_ = function() {
240     if (!this.training_)
241       return;
243     if (this.speakerModelFinalized_)
244       return;
246     this.updateTrainingState_(TrainingState.ERROR);
247     this.stopTraining();
248   };
250   /**
251    * Handles the speaker model finalized event.
252    * @private
253    */
254   Flow.prototype.onSpeakerModelFinalized_ = function() {
255     this.speakerModelFinalized_ = true;
256     if (chrome.hotwordPrivate.onSpeakerModelSaved) {
257       chrome.hotwordPrivate.onSpeakerModelSaved.removeListener(
258           this.speakerModelFinalizedListener_);
259     }
260     this.stopTraining();
261     this.setTimeout_(this.finishFlow_.bind(this), 2000);
262   };
264   /**
265    * Completes the training process.
266    * @private
267    */
268   Flow.prototype.finishFlow_ = function() {
269     if (chrome.hotwordPrivate.setHotwordAlwaysOnSearchEnabled) {
270       chrome.hotwordPrivate.setHotwordAlwaysOnSearchEnabled(true,
271           this.advanceStep.bind(this));
272     }
273   };
275   /**
276    * Handles a user clicking on the retry button.
277    */
278   Flow.prototype.handleRetry = function() {
279     if (!(this.trainingState_ == TrainingState.TIMEOUT ||
280         this.trainingState_ == TrainingState.ERROR))
281       return;
283     this.startTraining();
284     this.updateTrainingState_(TrainingState.RESET);
285   };
287   // ---- private methods:
289   /**
290    * Completes the training process.
291    * @private
292    */
293   Flow.prototype.finalizeSpeakerModel_ = function() {
294     if (!this.training_)
295       return;
297     // Listen for the success event from the NaCl module.
298     if (chrome.hotwordPrivate.onSpeakerModelSaved &&
299         !chrome.hotwordPrivate.onSpeakerModelSaved.hasListener(
300             this.speakerModelFinalizedListener_)) {
301       chrome.hotwordPrivate.onSpeakerModelSaved.addListener(
302           this.speakerModelFinalizedListener_);
303     }
305     this.speakerModelFinalized_ = false;
306     this.setTimeout_(this.handleSpeakerModelFinalizedError_.bind(this), 30000);
307     if (chrome.hotwordPrivate.finalizeSpeakerModel)
308       chrome.hotwordPrivate.finalizeSpeakerModel();
309   };
311   /**
312    * Returns the current training step.
313    * @param {string} curStepClassName The name of the class of the current
314    *     training step.
315    * @return {Object} The current training step, its index, and an array of
316    *     all training steps. Any of these can be undefined.
317    * @private
318    */
319   Flow.prototype.getCurrentTrainingStep_ = function(curStepClassName) {
320     var steps =
321         $(this.trainingPagePrefix_ + '-training').querySelectorAll('.train');
322     var curStep =
323         $(this.trainingPagePrefix_ + '-training').querySelector('.listening');
325     return {current: curStep,
326             index: Array.prototype.indexOf.call(steps, curStep),
327             steps: steps};
328   };
330   /**
331    * Updates the training state.
332    * @param {TrainingState} state The training state.
333    * @private
334    */
335   Flow.prototype.updateTrainingState_ = function(state) {
336     this.trainingState_ = state;
337     this.updateErrorUI_();
338   };
340   /**
341    * Waits two minutes and then checks for a training error.
342    * @param {number} index The index of the training step.
343    * @private
344    */
345   Flow.prototype.waitForHotwordTrigger_ = function(index) {
346     if (!this.training_)
347       return;
349     this.hotwordTriggerReceived_[index] = false;
350     this.setTimeout_(this.handleTrainingTimeout_.bind(this, index), 120000);
351   };
353   /**
354    * Checks for and handles a training error.
355    * @param {number} index The index of the training step.
356    * @private
357    */
358   Flow.prototype.handleTrainingTimeout_ = function(index) {
359     if (this.hotwordTriggerReceived_[index])
360       return;
362     this.timeoutTraining_();
363   };
365   /**
366    * Times out training and updates the UI to show a "retry" message, if
367    * currently training.
368    * @private
369    */
370   Flow.prototype.timeoutTraining_ = function() {
371     if (!this.training_)
372       return;
374     this.clearTimeout_();
375     this.updateTrainingState_(TrainingState.TIMEOUT);
376     this.stopTraining();
377   };
379   /**
380    * Sets a timeout. If any timeout is active, clear it.
381    * @param {Function} func The function to invoke when the timeout occurs.
382    * @param {number} delay Timeout delay in milliseconds.
383    * @private
384    */
385   Flow.prototype.setTimeout_ = function(func, delay) {
386     this.clearTimeout_();
387     this.timeoutId_ = setTimeout(function() {
388       this.timeoutId_ = null;
389       func();
390     }, delay);
391   };
393   /**
394    * Clears any currently active timeout.
395    * @private
396    */
397   Flow.prototype.clearTimeout_ = function() {
398     if (this.timeoutId_ != null) {
399       clearTimeout(this.timeoutId_);
400       this.timeoutId_ = null;
401     }
402   };
404   /**
405    * Updates the training error UI.
406    * @private
407    */
408   Flow.prototype.updateErrorUI_ = function() {
409     if (!this.training_)
410       return;
412     var trainingSteps = this.getCurrentTrainingStep_('listening');
413     var steps = trainingSteps.steps;
415     $(this.trainingPagePrefix_ + '-toast').hidden =
416         this.trainingState_ != TrainingState.TIMEOUT;
417     if (this.trainingState_ == TrainingState.RESET) {
418       // We reset the training to begin at the first step.
419       // The first step is reset to 'listening', while the rest
420       // are reset to 'not-started'.
421       var prompt = loadTimeData.getString('trainingFirstPrompt');
422       for (var i = 0; i < steps.length; ++i) {
423         steps[i].classList.remove('recorded');
424         if (i == 0) {
425           steps[i].classList.remove('not-started');
426           steps[i].classList.add('listening');
427         } else {
428           steps[i].classList.add('not-started');
429           if (i == steps.length - 1)
430             prompt = loadTimeData.getString('trainingLastPrompt');
431           else
432             prompt = loadTimeData.getString('trainingMiddlePrompt');
433         }
434         steps[i].querySelector('.text').textContent = prompt;
435       }
437       // Reset the buttonbar.
438       $(this.trainingPagePrefix_ + '-processing').hidden = true;
439       $(this.trainingPagePrefix_ + '-wait').hidden = false;
440       $(this.trainingPagePrefix_ + '-error').hidden = true;
441       $(this.trainingPagePrefix_ + '-retry').hidden = true;
442     } else if (this.trainingState_ == TrainingState.TIMEOUT) {
443       var curStep = trainingSteps.current;
444       if (curStep) {
445         curStep.classList.remove('listening');
446         curStep.classList.add('not-started');
447       }
449       // Set a timeout before focusing the Retry button so that screenreaders
450       // have time to announce the timeout first.
451       this.setTimeout_(function() {
452         $(this.trainingPagePrefix_ + '-toast').children[1].focus();
453       }.bind(this), 50);
454     } else if (this.trainingState_ == TrainingState.ERROR) {
455       // Update the buttonbar.
456       $(this.trainingPagePrefix_ + '-wait').hidden = true;
457       $(this.trainingPagePrefix_ + '-error').hidden = false;
458       $(this.trainingPagePrefix_ + '-retry').hidden = false;
459       $(this.trainingPagePrefix_ + '-processing').hidden = false;
461       // Set a timeout before focusing the Retry button so that screenreaders
462       // have time to announce the error first.
463       this.setTimeout_(function() {
464         $(this.trainingPagePrefix_ + '-retry').children[0].focus();
465       }.bind(this), 50);
466     }
467   };
469   /**
470    * Handles a hotword trigger event and updates the training UI.
471    * @private
472    */
473   Flow.prototype.handleHotwordTrigger_ = function() {
474     var trainingSteps = this.getCurrentTrainingStep_('listening');
476     if (!trainingSteps.current)
477       return;
479     var index = trainingSteps.index;
480     this.hotwordTriggerReceived_[index] = true;
482     trainingSteps.current.querySelector('.text').textContent =
483         loadTimeData.getString('trainingRecorded');
484     trainingSteps.current.classList.remove('listening');
485     trainingSteps.current.classList.add('recorded');
487     if (trainingSteps.steps[index + 1]) {
488       trainingSteps.steps[index + 1].classList.remove('not-started');
489       trainingSteps.steps[index + 1].classList.add('listening');
490       this.waitForHotwordTrigger_(index + 1);
491       return;
492     }
494     // Only the last step makes it here.
495     var buttonElem = $(this.trainingPagePrefix_ + '-processing').hidden = false;
496     this.finalizeSpeakerModel_();
497   };
499   /**
500    * Handles a chrome.idle.onStateChanged event and times out the training if
501    * the state is "locked".
502    * @param {!string} state State, one of "active", "idle", or "locked".
503    * @private
504    */
505   Flow.prototype.handleIdleStateChanged_ = function(state) {
506     if (state == 'locked')
507       this.timeoutTraining_();
508   };
510   /**
511    * Handles a chrome.hotwordPrivate.onEnabledChanged event and times out
512    * training if the user is no longer the active user (user switches profiles).
513    * @private
514    */
515   Flow.prototype.handleEnabledChanged_ = function() {
516     if (chrome.hotwordPrivate.getStatus) {
517       chrome.hotwordPrivate.getStatus(function(status) {
518         if (status.userIsActive)
519           return;
521         this.timeoutTraining_();
522       }.bind(this));
523     }
524   };
526   /**
527    * Gets and starts the appropriate flow for the launch mode.
528    * @param {chrome.hotwordPrivate.LaunchState} state Launch state of the
529    *     Hotword Audio Verification App.
530    * @private
531    */
532   Flow.prototype.startFlowForMode_ = function(state) {
533     this.launchMode_ = state.launchMode;
534     assert(state.launchMode >= 0 && state.launchMode < FLOWS.length,
535            'Invalid Launch Mode.');
536     this.currentFlow_ = FLOWS[state.launchMode];
537     if (state.launchMode == LaunchMode.HOTWORD_ONLY) {
538       $('intro-description-audio-history-enabled').hidden = false;
539     } else if (state.launchMode == LaunchMode.HOTWORD_AND_AUDIO_HISTORY) {
540       $('intro-description').hidden = false;
541     }
543     this.advanceStep();
544   };
546   /**
547    * Displays the current step. If the current step is not the first step,
548    * also hides the previous step. Focuses the current step's first button.
549    * @private
550    */
551   Flow.prototype.showStep_ = function() {
552     var currentStepId = this.currentFlow_[this.currentStepIndex_];
553     var currentStep = document.getElementById(currentStepId);
554     currentStep.hidden = false;
556     cr.ui.setInitialFocus(currentStep);
558     var previousStep = null;
559     if (this.currentStepIndex_ > 0)
560       previousStep = this.currentFlow_[this.currentStepIndex_ - 1];
562     if (previousStep)
563       document.getElementById(previousStep).hidden = true;
565     chrome.app.window.current().show();
566   };
568   window.Flow = Flow;
569 })();