1// Copyright (c) 2013 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.
4
5'use strict';
6
7/**
8 * SuggestAppsDialog contains a list box to select an app to be opened the file
9 * with. This dialog should be used as action picker for file operations.
10 */
11
12/**
13 * The width of the widget (in pixel).
14 * @type {number}
15 * @const
16 */
17var WEBVIEW_WIDTH = 735;
18/**
19 * The height of the widget (in pixel).
20 * @type {number}
21 * @const
22 */
23var WEBVIEW_HEIGHT = 480;
24
25/**
26 * The URL of the widget.
27 * @type {string}
28 * @const
29 */
30var CWS_WIDGET_URL =
31    'https://clients5.google.com/webstore/wall/cros-widget-container';
32/**
33 * The origin of the widget.
34 * @type {string}
35 * @const
36 */
37var CWS_WIDGET_ORIGIN = 'https://clients5.google.com';
38
39/**
40 * Creates dialog in DOM tree.
41 *
42 * @param {HTMLElement} parentNode Node to be parent for this dialog.
43 * @param {Object} state Static state of suggest app dialog.
44 * @constructor
45 * @extends {FileManagerDialogBase}
46 */
47function SuggestAppsDialog(parentNode, state) {
48  FileManagerDialogBase.call(this, parentNode);
49
50  this.frame_.id = 'suggest-app-dialog';
51
52  this.webviewContainer_ = this.document_.createElement('div');
53  this.webviewContainer_.id = 'webview-container';
54  this.webviewContainer_.style.width = WEBVIEW_WIDTH + 'px';
55  this.webviewContainer_.style.height = WEBVIEW_HEIGHT + 'px';
56  this.frame_.insertBefore(this.webviewContainer_, this.text_.nextSibling);
57
58  var spinnerLayer = this.document_.createElement('div');
59  spinnerLayer.className = 'spinner-layer';
60  this.webviewContainer_.appendChild(spinnerLayer);
61
62  this.buttons_ = this.document_.createElement('div');
63  this.buttons_.id = 'buttons';
64  this.frame_.appendChild(this.buttons_);
65
66  this.webstoreButton_ = this.document_.createElement('div');
67  this.webstoreButton_.id = 'webstore-button';
68  this.webstoreButton_.innerHTML = str('SUGGEST_DIALOG_LINK_TO_WEBSTORE');
69  this.webstoreButton_.addEventListener(
70      'click', this.onWebstoreLinkClicked_.bind(this));
71  this.buttons_.appendChild(this.webstoreButton_);
72
73  this.initialFocusElement_ = this.webviewContainer_;
74
75  this.webview_ = null;
76  this.accessToken_ = null;
77  this.widgetUrl_ =
78      state.overrideCwsContainerUrlForTest || CWS_WIDGET_URL;
79  this.widgetOrigin_ =
80      state.overrideCwsContainerOriginForTest || CWS_WIDGET_ORIGIN;
81
82  this.extension_ = null;
83  this.mime_ = null;
84  this.installingItemId_ = null;
85  this.state_ = SuggestAppsDialog.State.UNINITIALIZED;
86
87  this.initializationTask_ = new AsyncUtil.Group();
88  this.initializationTask_.add(this.retrieveAuthorizeToken_.bind(this));
89  this.initializationTask_.run();
90}
91
92SuggestAppsDialog.prototype = {
93  __proto__: FileManagerDialogBase.prototype
94};
95
96/**
97 * @enum {string}
98 * @const
99 */
100SuggestAppsDialog.State = {
101  UNINITIALIZED: 'SuggestAppsDialog.State.UNINITIALIZED',
102  INITIALIZING: 'SuggestAppsDialog.State.INITIALIZING',
103  INITIALIZE_FAILED_CLOSING:
104      'SuggestAppsDialog.State.INITIALIZE_FAILED_CLOSING',
105  INITIALIZED: 'SuggestAppsDialog.State.INITIALIZED',
106  INSTALLING: 'SuggestAppsDialog.State.INSTALLING',
107  INSTALLED_CLOSING: 'SuggestAppsDialog.State.INSTALLED_CLOSING',
108  OPENING_WEBSTORE_CLOSING: 'SuggestAppsDialog.State.OPENING_WEBSTORE_CLOSING',
109  CANCELED_CLOSING: 'SuggestAppsDialog.State.CANCELED_CLOSING'
110};
111Object.freeze(SuggestAppsDialog.State);
112
113/**
114 * @enum {string}
115 * @const
116 */
117SuggestAppsDialog.Result = {
118  // Install is done. The install app should be opened.
119  INSTALL_SUCCESSFUL: 'SuggestAppsDialog.Result.INSTALL_SUCCESSFUL',
120  // User cancelled the suggest app dialog. No message should be shown.
121  USER_CANCELL: 'SuggestAppsDialog.Result.USER_CANCELL',
122  // User clicked the link to web store so the dialog is closed.
123  WEBSTORE_LINK_OPENED: 'SuggestAppsDialog.Result.WEBSTORE_LINK_OPENED',
124  // Failed to load the widget. Error message should be shown.
125  FAILED: 'SuggestAppsDialog.Result.FAILED'
126};
127Object.freeze(SuggestAppsDialog.Result);
128
129/**
130 * @override
131 */
132SuggestAppsDialog.prototype.onInputFocus = function() {
133  this.webviewContainer_.select();
134};
135
136/**
137 * Injects headers into the passed request.
138 *
139 * @param {Event} e Request event.
140 * @return {{requestHeaders: HttpHeaders}} Modified headers.
141 * @private
142 */
143SuggestAppsDialog.prototype.authorizeRequest_ = function(e) {
144  e.requestHeaders.push({
145    name: 'Authorization',
146    value: 'Bearer ' + this.accessToken_
147  });
148  return {requestHeaders: e.requestHeaders};
149};
150
151/**
152 * Retrieves the authorize token. This method should be called in
153 * initialization of the dialog.
154 *
155 * @param {function()} callback Called when the token is retrieved.
156 * @private
157 */
158SuggestAppsDialog.prototype.retrieveAuthorizeToken_ = function(callback) {
159  if (window.IN_TEST) {
160    // In test, use a dummy string as token. This must be a non-empty string.
161    this.accessToken_ = 'DUMMY_ACCESS_TOKEN_FOR_TEST';
162  }
163
164  if (this.accessToken_) {
165    callback();
166    return;
167  }
168
169  // Fetch or update the access token.
170  chrome.fileManagerPrivate.requestWebStoreAccessToken(
171      function(accessToken) {
172        // In case of error, this.accessToken_ will be set to null.
173        this.accessToken_ = accessToken;
174        callback();
175      }.bind(this));
176};
177
178/**
179 * Dummy function for SuggestAppsDialog.show() not to be called unintentionally.
180 */
181SuggestAppsDialog.prototype.show = function() {
182  console.error('SuggestAppsDialog.show() shouldn\'t be called directly.');
183};
184
185/**
186 * Shows suggest-apps dialog by file extension and mime.
187 *
188 * @param {string} extension Extension of the file.
189 * @param {string} mime Mime of the file.
190 * @param {function(boolean)} onDialogClosed Called when the dialog is closed.
191 *     The argument is the result of installation: true if an app is installed,
192 *     false otherwise.
193 */
194SuggestAppsDialog.prototype.showByExtensionAndMime =
195    function(extension, mime, onDialogClosed) {
196  this.text_.hidden = true;
197  this.dialogText_ = '';
198  this.showInternal_(null, extension, mime, onDialogClosed);
199};
200
201/**
202 * Shows suggest-apps dialog by the filename.
203 *
204 * @param {string} filename Filename (without extension) of the file.
205 * @param {function(boolean)} onDialogClosed Called when the dialog is closed.
206 *     The argument is the result of installation: true if an app is installed,
207 *     false otherwise.
208 */
209SuggestAppsDialog.prototype.showByFilename =
210    function(filename, onDialogClosed) {
211  this.text_.hidden = false;
212  this.dialogText_ = str('SUGGEST_DIALOG_MESSAGE_FOR_EXECUTABLE');
213  this.showInternal_(filename, null, null, onDialogClosed);
214};
215
216/**
217 * Internal method to show a dialog. This should be called only from 'Suggest.
218 * appDialog.showXxxx()' functions.
219 *
220 * @param {string} filename Filename (without extension) of the file.
221 * @param {string} extension Extension of the file.
222 * @param {string} mime Mime of the file.
223 * @param {function(boolean)} onDialogClosed Called when the dialog is closed.
224 *     The argument is the result of installation: true if an app is installed,
225 *     false otherwise.
226 *     @private
227 */
228SuggestAppsDialog.prototype.showInternal_ =
229    function(filename, extension, mime, onDialogClosed) {
230  if (this.state_ != SuggestAppsDialog.State.UNINITIALIZED) {
231    console.error('Invalid state.');
232    return;
233  }
234
235  this.extension_ = extension;
236  this.mimeType_ = mime;
237  this.onDialogClosed_ = onDialogClosed;
238  this.state_ = SuggestAppsDialog.State.INITIALIZING;
239
240  SuggestAppsDialog.Metrics.recordShowDialog();
241  SuggestAppsDialog.Metrics.startLoad();
242
243  // Makes it sure that the initialization is completed.
244  this.initializationTask_.run(function() {
245    if (!this.accessToken_) {
246      this.state_ = SuggestAppsDialog.State.INITIALIZE_FAILED_CLOSING;
247      this.onHide_();
248      return;
249    }
250
251    var title = str('SUGGEST_DIALOG_TITLE');
252    var show = this.dialogText_ ?
253        FileManagerDialogBase.prototype.showTitleAndTextDialog.call(
254            this, title, this.dialogText_) :
255        FileManagerDialogBase.prototype.showTitleOnlyDialog.call(
256            this, title);
257    if (!show) {
258      console.error('SuggestAppsDialog can\'t be shown');
259      this.state_ = SuggestAppsDialog.State.UNINITIALIZED;
260      this.onHide();
261      return;
262    }
263
264    this.webview_ = this.document_.createElement('webview');
265    this.webview_.id = 'cws-widget';
266    this.webview_.partition = 'persist:cwswidgets';
267    this.webview_.style.width = WEBVIEW_WIDTH + 'px';
268    this.webview_.style.height = WEBVIEW_HEIGHT + 'px';
269    this.webview_.request.onBeforeSendHeaders.addListener(
270        this.authorizeRequest_.bind(this),
271        {urls: [this.widgetOrigin_ + '/*']},
272        ['blocking', 'requestHeaders']);
273    this.webview_.addEventListener('newwindow', function(event) {
274      // Discard the window object and reopen in an external window.
275      event.window.discard();
276      util.visitURL(event.targetUrl);
277      event.preventDefault();
278    });
279    this.webviewContainer_.appendChild(this.webview_);
280
281    this.frame_.classList.add('show-spinner');
282
283    this.webviewClient_ = new CWSContainerClient(
284        this.webview_,
285        extension, mime, filename,
286        WEBVIEW_WIDTH, WEBVIEW_HEIGHT,
287        this.widgetUrl_, this.widgetOrigin_);
288    this.webviewClient_.addEventListener(CWSContainerClient.Events.LOADED,
289                                         this.onWidgetLoaded_.bind(this));
290    this.webviewClient_.addEventListener(CWSContainerClient.Events.LOAD_FAILED,
291                                         this.onWidgetLoadFailed_.bind(this));
292    this.webviewClient_.addEventListener(
293        CWSContainerClient.Events.REQUEST_INSTALL,
294        this.onInstallRequest_.bind(this));
295    this.webviewClient_.load();
296  }.bind(this));
297};
298
299/**
300 * Called when the 'See more...' link is clicked to be navigated to Webstore.
301 * @param {Event} e Event.
302 * @private
303 */
304SuggestAppsDialog.prototype.onWebstoreLinkClicked_ = function(e) {
305  var webStoreUrl =
306      FileTasks.createWebStoreLink(this.extension_, this.mimeType_);
307  util.visitURL(webStoreUrl);
308  this.state_ = SuggestAppsDialog.State.OPENING_WEBSTORE_CLOSING;
309  this.hide();
310};
311
312/**
313 * Called when the widget is loaded successfully.
314 * @param {Event} event Event.
315 * @private
316 */
317SuggestAppsDialog.prototype.onWidgetLoaded_ = function(event) {
318  SuggestAppsDialog.Metrics.finishLoad();
319  SuggestAppsDialog.Metrics.recordLoad(
320      SuggestAppsDialog.Metrics.LOAD.SUCCEEDED);
321
322  this.frame_.classList.remove('show-spinner');
323  this.state_ = SuggestAppsDialog.State.INITIALIZED;
324
325  this.webview_.focus();
326};
327
328/**
329 * Called when the widget is failed to load.
330 * @param {Event} event Event.
331 * @private
332 */
333SuggestAppsDialog.prototype.onWidgetLoadFailed_ = function(event) {
334  SuggestAppsDialog.Metrics.recordLoad(SuggestAppsDialog.Metrics.LOAD.FAILURE);
335
336  this.frame_.classList.remove('show-spinner');
337  this.state_ = SuggestAppsDialog.State.INITIALIZE_FAILED_CLOSING;
338
339  this.hide();
340};
341
342/**
343 * Called when the connection status is changed.
344 * @param {VolumeManagerCommon.DriveConnectionType} connectionType Current
345 *     connection type.
346 */
347SuggestAppsDialog.prototype.onDriveConnectionChanged =
348    function(connectionType) {
349  if (this.state_ !== SuggestAppsDialog.State.UNINITIALIZED &&
350      connectionType === VolumeManagerCommon.DriveConnectionType.OFFLINE) {
351    this.state_ = SuggestAppsDialog.State.INITIALIZE_FAILED_CLOSING;
352    this.hide();
353  }
354};
355
356/**
357 * Called when receiving the install request from the webview client.
358 * @param {Event} e Event.
359 * @private
360 */
361SuggestAppsDialog.prototype.onInstallRequest_ = function(e) {
362  var itemId = e.itemId;
363  this.installingItemId_ = itemId;
364
365  this.appInstaller_ = new AppInstaller(itemId);
366  this.appInstaller_.install(this.onInstallCompleted_.bind(this));
367
368  this.frame_.classList.add('show-spinner');
369  this.state_ = SuggestAppsDialog.State.INSTALLING;
370};
371
372/**
373 * Called when the installation is completed from the app installer.
374 * @param {AppInstaller.Result} result Result of the installation.
375 * @param {string} error Detail of the error.
376 * @private
377 */
378SuggestAppsDialog.prototype.onInstallCompleted_ = function(result, error) {
379  var success = (result === AppInstaller.Result.SUCCESS);
380
381  this.frame_.classList.remove('show-spinner');
382  this.state_ = success ?
383                SuggestAppsDialog.State.INSTALLED_CLOSING :
384                SuggestAppsDialog.State.INITIALIZED;  // Back to normal state.
385  this.webviewClient_.onInstallCompleted(success, this.installingItemId_);
386  this.installingItemId_ = null;
387
388  switch (result) {
389    case AppInstaller.Result.SUCCESS:
390      SuggestAppsDialog.Metrics.recordInstall(
391          SuggestAppsDialog.Metrics.INSTALL.SUCCESS);
392      this.hide();
393      break;
394    case AppInstaller.Result.CANCELLED:
395      SuggestAppsDialog.Metrics.recordInstall(
396          SuggestAppsDialog.Metrics.INSTALL.CANCELLED);
397      // User cancelled the installation. Do nothing.
398      break;
399    case AppInstaller.Result.ERROR:
400      SuggestAppsDialog.Metrics.recordInstall(
401          SuggestAppsDialog.Metrics.INSTALL.FAILED);
402      fileManager.error.show(str('SUGGEST_DIALOG_INSTALLATION_FAILED'));
403      break;
404  }
405};
406
407/**
408 * @override
409 */
410SuggestAppsDialog.prototype.hide = function(opt_originalOnHide) {
411  switch (this.state_) {
412    case SuggestAppsDialog.State.INSTALLING:
413      // Install is being aborted. Send the failure result.
414      // Cancels the install.
415      if (this.webviewClient_)
416        this.webviewClient_.onInstallCompleted(false, this.installingItemId_);
417      this.installingItemId_ = null;
418
419      // Assumes closing the dialog as canceling the install.
420      this.state_ = SuggestAppsDialog.State.CANCELED_CLOSING;
421      break;
422    case SuggestAppsDialog.State.INITIALIZING:
423      SuggestAppsDialog.Metrics.recordLoad(
424          SuggestAppsDialog.Metrics.LOAD.CANCELLED);
425      this.state_ = SuggestAppsDialog.State.CANCELED_CLOSING;
426      break;
427    case SuggestAppsDialog.State.INSTALLED_CLOSING:
428    case SuggestAppsDialog.State.INITIALIZE_FAILED_CLOSING:
429    case SuggestAppsDialog.State.OPENING_WEBSTORE_CLOSING:
430      // Do nothing.
431      break;
432    case SuggestAppsDialog.State.INITIALIZED:
433      this.state_ = SuggestAppsDialog.State.CANCELED_CLOSING;
434      break;
435    default:
436      this.state_ = SuggestAppsDialog.State.CANCELED_CLOSING;
437      console.error('Invalid state.');
438  }
439
440  if (this.webviewClient_) {
441    this.webviewClient_.dispose();
442    this.webviewClient_ = null;
443  }
444
445  this.webviewContainer_.removeChild(this.webview_);
446  this.webview_ = null;
447  this.extension_ = null;
448  this.mime_ = null;
449
450  FileManagerDialogBase.prototype.hide.call(
451      this,
452      this.onHide_.bind(this, opt_originalOnHide));
453};
454
455/**
456 * @param {function()=} opt_originalOnHide Original onHide function passed to
457 *     SuggestAppsDialog.hide().
458 * @private
459 */
460SuggestAppsDialog.prototype.onHide_ = function(opt_originalOnHide) {
461  // Calls the callback after the dialog hides.
462  if (opt_originalOnHide)
463    opt_originalOnHide();
464
465  var result;
466  switch (this.state_) {
467    case SuggestAppsDialog.State.INSTALLED_CLOSING:
468      result = SuggestAppsDialog.Result.INSTALL_SUCCESSFUL;
469      SuggestAppsDialog.Metrics.recordCloseDialog(
470          SuggestAppsDialog.Metrics.CLOSE_DIALOG.ITEM_INSTALLED);
471      break;
472    case SuggestAppsDialog.State.INITIALIZE_FAILED_CLOSING:
473      result = SuggestAppsDialog.Result.FAILED;
474      break;
475    case SuggestAppsDialog.State.CANCELED_CLOSING:
476      result = SuggestAppsDialog.Result.USER_CANCELL;
477      SuggestAppsDialog.Metrics.recordCloseDialog(
478          SuggestAppsDialog.Metrics.CLOSE_DIALOG.USER_CANCELL);
479      break;
480    case SuggestAppsDialog.State.OPENING_WEBSTORE_CLOSING:
481      result = SuggestAppsDialog.Result.WEBSTORE_LINK_OPENED;
482      SuggestAppsDialog.Metrics.recordCloseDialog(
483          SuggestAppsDialog.Metrics.CLOSE_DIALOG.WEB_STORE_LINK);
484      break;
485    default:
486      result = SuggestAppsDialog.Result.USER_CANCELL;
487      SuggestAppsDialog.Metrics.recordCloseDialog(
488          SuggestAppsDialog.Metrics.CLOSE_DIALOG.UNKNOWN_ERROR);
489      console.error('Invalid state.');
490  }
491  this.state_ = SuggestAppsDialog.State.UNINITIALIZED;
492
493  this.onDialogClosed_(result);
494};
495
496/**
497 * Utility methods and constants to record histograms.
498 */
499SuggestAppsDialog.Metrics = Object.freeze({
500  LOAD: Object.freeze({
501    SUCCEEDED: 0,
502    CANCELLED: 1,
503    FAILED: 2,
504  }),
505
506  /**
507   * @param {SuggestAppsDialog.Metrics.LOAD} result Result of load.
508   */
509  recordLoad: function(result) {
510    if (0 <= result && result < 3)
511      metrics.recordEnum('SuggestApps.Load', result, 3);
512  },
513
514  CLOSE_DIALOG: Object.freeze({
515    UNKOWN_ERROR: 0,
516    ITEM_INSTALLED: 1,
517    USER_CANCELLED: 2,
518    WEBSTORE_LINK_OPENED: 3,
519  }),
520
521  /**
522   * @param {SuggestAppsDialog.Metrics.CLOSE_DIALOG} reason Reason of closing
523   * dialog.
524   */
525  recordCloseDialog: function(reason) {
526    if (0 <= reason && reason < 4)
527      metrics.recordEnum('SuggestApps.CloseDialog', reason, 4);
528  },
529
530  INSTALL: Object.freeze({
531    SUCCEEDED: 0,
532    CANCELLED: 1,
533    FAILED: 2,
534  }),
535
536  /**
537   * @param {SuggestAppsDialog.Metrics.INSTALL} result Result of installation.
538   */
539  recordInstall: function(result) {
540    if (0 <= result && result < 3)
541      metrics.recordEnum('SuggestApps.Install', result, 3);
542  },
543
544  recordShowDialog: function() {
545    metrics.recordUserAction('SuggestApps.ShowDialog');
546  },
547
548  startLoad: function() {
549    metrics.startInterval('SuggestApps.LoadTime');
550  },
551
552  finishLoad: function() {
553    metrics.recordInterval('SuggestApps.LoadTime');
554  },
555});
556