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