blob: 7bde88ca9c24d254d940de4e0fd2d658c2e952c4 [file] [log] [blame]
// Copyright (c) 2012 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.
'use strict';
/**
* This object encapsulates everything related to tasks execution.
*
* @param {FileManager} fileManager FileManager instance.
* @param {Object=} opt_params File manager load parameters.
* @constructor
*/
function FileTasks(fileManager, opt_params) {
this.fileManager_ = fileManager;
this.params_ = opt_params;
this.tasks_ = null;
this.defaultTask_ = null;
this.entries_ = null;
/**
* List of invocations to be called once tasks are available.
*
* @private
* @type {Array.<Object>}
*/
this.pendingInvocations_ = [];
}
/**
* Location of the FAQ about the file actions.
*
* @const
* @type {string}
*/
FileTasks.NO_ACTION_FOR_FILE_URL = 'http://support.google.com/chromeos/bin/' +
'answer.py?answer=1700055&topic=29026&ctx=topic';
/**
* Location of the Chrome Web Store.
*
* @const
* @type {string}
*/
FileTasks.CHROME_WEB_STORE_URL = 'https://chrome.google.com/webstore';
/**
* Base URL of apps list in the Chrome Web Store. This constant is used in
* FileTasks.createWebStoreLink().
*
* @const
* @type {string}
*/
FileTasks.WEB_STORE_HANDLER_BASE_URL =
'https://chrome.google.com/webstore/category/collection/file_handlers';
/**
* Returns URL of the Chrome Web Store which show apps supporting the given
* file-extension and mime-type.
*
* @param {string} extension Extension of the file (with the first dot).
* @param {string} mimeType Mime type of the file.
* @return {string} URL
*/
FileTasks.createWebStoreLink = function(extension, mimeType) {
if (!extension)
return FileTasks.CHROME_WEB_STORE_URL;
if (extension[0] === '.')
extension = extension.substr(1);
else
console.warn('Please pass an extension with a dot to createWebStoreLink.');
var url = FileTasks.WEB_STORE_HANDLER_BASE_URL;
url += '?_fe=' + extension.toLowerCase().replace(/[^\w]/g, '');
// If a mime is given, add it into the URL.
if (mimeType)
url += '&_fmt=' + mimeType.replace(/[^-\w\/]/g, '');
return url;
};
/**
* Complete the initialization.
*
* @param {Array.<Entry>} entries List of file entries.
* @param {Array.<string>=} opt_mimeTypes List of MIME types for each
* of the files.
*/
FileTasks.prototype.init = function(entries, opt_mimeTypes) {
this.entries_ = entries;
this.mimeTypes_ = opt_mimeTypes || [];
// TODO(mtomasz): Move conversion from entry to url to custom bindings.
var urls = util.entriesToURLs(entries);
if (urls.length > 0) {
chrome.fileBrowserPrivate.getFileTasks(urls, this.mimeTypes_,
this.onTasks_.bind(this));
}
};
/**
* Returns amount of tasks.
*
* @return {number} amount of tasks.
*/
FileTasks.prototype.size = function() {
return (this.tasks_ && this.tasks_.length) || 0;
};
/**
* Callback when tasks found.
*
* @param {Array.<Object>} tasks The tasks.
* @private
*/
FileTasks.prototype.onTasks_ = function(tasks) {
this.processTasks_(tasks);
for (var index = 0; index < this.pendingInvocations_.length; index++) {
var name = this.pendingInvocations_[index][0];
var args = this.pendingInvocations_[index][1];
this[name].apply(this, args);
}
this.pendingInvocations_ = [];
};
/**
* The list of known extensions to record UMA.
* Note: Because the data is recorded by the index, so new item shouldn't be
* inserted.
*
* @const
* @type {Array.<string>}
* @private
*/
FileTasks.knownExtensions_ = [
'other', '.3ga', '.3gp', '.aac', '.alac', '.asf', '.avi', '.bmp', '.csv',
'.doc', '.docx', '.flac', '.gif', '.ico', '.jpeg', '.jpg', '.log', '.m3u',
'.m3u8', '.m4a', '.m4v', '.mid', '.mkv', '.mov', '.mp3', '.mp4', '.mpg',
'.odf', '.odp', '.ods', '.odt', '.oga', '.ogg', '.ogv', '.pdf', '.png',
'.ppt', '.pptx', '.ra', '.ram', '.rar', '.rm', '.rtf', '.wav', '.webm',
'.webp', '.wma', '.wmv', '.xls', '.xlsx',
];
/**
* The list of excutable file extensions.
*
* @const
* @type {Array.<string>}
*/
FileTasks.EXECUTABLE_EXTENSIONS = Object.freeze([
'.exe', '.lnk', '.deb', '.dmg', '.jar', '.msi',
]);
/**
* The list of extensions to skip the suggest app dialog.
* @const
* @type {Array.<string>}
* @private
*/
FileTasks.EXTENSIONS_TO_SKIP_SUGGEST_APPS_ = Object.freeze([
'.crdownload', '.dsc', '.inf', '.crx',
]);
/**
* Records trial of opening file grouped by extensions.
*
* @param {Array.<Entry>} entries The entries to be opened.
* @private
*/
FileTasks.recordViewingFileTypeUMA_ = function(entries) {
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
var extension = FileType.getExtension(entry).toLowerCase();
if (FileTasks.knownExtensions_.indexOf(extension) < 0) {
extension = 'other';
}
metrics.recordEnum(
'ViewingFileType', extension, FileTasks.knownExtensions_);
}
};
/**
* Returns true if the taskId is for an internal task.
*
* @param {string} taskId Task identifier.
* @return {boolean} True if the task ID is for an internal task.
* @private
*/
FileTasks.isInternalTask_ = function(taskId) {
var taskParts = taskId.split('|');
var appId = taskParts[0];
var taskType = taskParts[1];
var actionId = taskParts[2];
// The action IDs here should match ones used in executeInternalTask_().
return (appId === chrome.runtime.id &&
taskType === 'file' &&
(actionId === 'play' ||
actionId === 'watch' ||
actionId === 'mount-archive' ||
actionId === 'gallery'));
};
/**
* Processes internal tasks.
*
* @param {Array.<Object>} tasks The tasks.
* @private
*/
FileTasks.prototype.processTasks_ = function(tasks) {
this.tasks_ = [];
var id = chrome.runtime.id;
var isOnDrive = false;
for (var index = 0; index < this.entries_.length; ++index) {
if (FileType.isOnDrive(this.entries_[index])) {
isOnDrive = true;
break;
}
}
for (var i = 0; i < tasks.length; i++) {
var task = tasks[i];
var taskParts = task.taskId.split('|');
// Skip internal Files.app's handlers.
if (taskParts[0] === id && (taskParts[2] === 'auto-open' ||
taskParts[2] === 'select' || taskParts[2] === 'open')) {
continue;
}
// Tweak images, titles of internal tasks.
if (taskParts[0] === id && taskParts[1] === 'file') {
if (taskParts[2] === 'play') {
// TODO(serya): This hack needed until task.iconUrl is working
// (see GetFileTasksFileBrowserFunction::RunImpl).
task.iconType = 'audio';
task.title = loadTimeData.getString('ACTION_LISTEN');
} else if (taskParts[2] === 'mount-archive') {
task.iconType = 'archive';
task.title = loadTimeData.getString('MOUNT_ARCHIVE');
} else if (taskParts[2] === 'gallery') {
task.iconType = 'image';
task.title = loadTimeData.getString('ACTION_OPEN');
} else if (taskParts[2] === 'watch') {
task.iconType = 'video';
task.title = loadTimeData.getString('ACTION_WATCH');
} else if (taskParts[2] === 'open-hosted-generic') {
if (this.entries_.length > 1)
task.iconType = 'generic';
else // Use specific icon.
task.iconType = FileType.getIcon(this.entries_[0]);
task.title = loadTimeData.getString('ACTION_OPEN');
} else if (taskParts[2] === 'open-hosted-gdoc') {
task.iconType = 'gdoc';
task.title = loadTimeData.getString('ACTION_OPEN_GDOC');
} else if (taskParts[2] === 'open-hosted-gsheet') {
task.iconType = 'gsheet';
task.title = loadTimeData.getString('ACTION_OPEN_GSHEET');
} else if (taskParts[2] === 'open-hosted-gslides') {
task.iconType = 'gslides';
task.title = loadTimeData.getString('ACTION_OPEN_GSLIDES');
} else if (taskParts[2] === 'view-swf') {
// Do not render this task if disabled.
if (!loadTimeData.getBoolean('SWF_VIEW_ENABLED'))
continue;
task.iconType = 'generic';
task.title = loadTimeData.getString('ACTION_VIEW');
} else if (taskParts[2] === 'view-pdf') {
// Do not render this task if disabled.
if (!loadTimeData.getBoolean('PDF_VIEW_ENABLED'))
continue;
task.iconType = 'pdf';
task.title = loadTimeData.getString('ACTION_VIEW');
} else if (taskParts[2] === 'view-in-browser') {
task.iconType = 'generic';
task.title = loadTimeData.getString('ACTION_VIEW');
}
}
if (!task.iconType && taskParts[1] === 'web-intent') {
task.iconType = 'generic';
}
this.tasks_.push(task);
if (this.defaultTask_ === null && task.isDefault) {
this.defaultTask_ = task;
}
}
if (!this.defaultTask_ && this.tasks_.length > 0) {
// If we haven't picked a default task yet, then just pick the first one.
// This is not the preferred way we want to pick this, but better this than
// no default at all if the C++ code didn't set one.
this.defaultTask_ = this.tasks_[0];
}
};
/**
* Executes default task.
*
* @param {function(boolean, Array.<string>)=} opt_callback Called when the
* default task is executed, or the error is occurred.
* @private
*/
FileTasks.prototype.executeDefault_ = function(opt_callback) {
FileTasks.recordViewingFileTypeUMA_(this.entries_);
this.executeDefaultInternal_(this.entries_, opt_callback);
};
/**
* Executes default task.
*
* @param {Array.<Entry>} entries Entries to execute.
* @param {function(boolean, Array.<Entry>)=} opt_callback Called when the
* default task is executed, or the error is occurred.
* @private
*/
FileTasks.prototype.executeDefaultInternal_ = function(entries, opt_callback) {
var callback = opt_callback || function(arg1, arg2) {};
if (this.defaultTask_ !== null) {
this.executeInternal_(this.defaultTask_.taskId, entries);
callback(true, entries);
return;
}
// We don't have tasks, so try to show a file in a browser tab.
// We only do that for single selection to avoid confusion.
if (entries.length !== 1 || !entries[0])
return;
var filename = entries[0].name;
var extension = PathUtil.splitExtension(filename)[1];
var mimeType = this.mimeTypes_[0];
var showAlert = function() {
var textMessageId;
var titleMessageId;
switch (extension) {
case '.exe':
textMessageId = 'NO_ACTION_FOR_EXECUTABLE';
break;
case '.crx':
textMessageId = 'NO_ACTION_FOR_CRX';
titleMessageId = 'NO_ACTION_FOR_CRX_TITLE';
break;
default:
textMessageId = 'NO_ACTION_FOR_FILE';
}
var webStoreUrl = FileTasks.createWebStoreLink(extension, mimeType);
var text = strf(textMessageId,
webStoreUrl,
FileTasks.NO_ACTION_FOR_FILE_URL);
var title = titleMessageId ? str(titleMessageId) : filename;
this.fileManager_.alert.showHtml(title, text, function() {});
callback(false, urls);
}.bind(this);
var onViewFilesFailure = function() {
var fm = this.fileManager_;
if (fm.enableExperimentalWebStoreIntegration_) {
showAlert();
return;
}
if (!fm.isOnDrive() ||
!entries[0] ||
FileTasks.EXTENSIONS_TO_SKIP_SUGGEST_APPS_.indexOf(extension) !== -1) {
showAlert();
return;
}
fm.openSuggestAppsDialog(
entries[0],
function() {
var newTasks = new FileTasks(fm);
newTasks.init(entries, this.mimeTypes_);
newTasks.executeDefault();
callback(true, entries);
}.bind(this),
// Cancelled callback.
function() {
callback(false, entries);
},
showAlert);
}.bind(this);
var onViewFiles = function(success) {
if (success)
callback(success, entries);
else
onViewFilesFailure();
}.bind(this);
this.checkAvailability_(function() {
// TODO(mtomasz): Pass entries instead.
var urls = util.entriesToURLs(entries);
util.viewFilesInBrowser(urls, onViewFiles);
}.bind(this));
};
/**
* Executes a single task.
*
* @param {string} taskId Task identifier.
* @param {Array.<Entry>=} opt_entries Entries to xecute on instead of
* this.entries_|.
* @private
*/
FileTasks.prototype.execute_ = function(taskId, opt_entries) {
var entries = opt_entries || this.entries_;
FileTasks.recordViewingFileTypeUMA_(entries);
this.executeInternal_(taskId, entries);
};
/**
* The core implementation to execute a single task.
*
* @param {string} taskId Task identifier.
* @param {Array.<Entry>} entries Entries to execute.
* @private
*/
FileTasks.prototype.executeInternal_ = function(taskId, entries) {
this.checkAvailability_(function() {
if (FileTasks.isInternalTask_(taskId)) {
var taskParts = taskId.split('|');
this.executeInternalTask_(taskParts[2], entries);
} else {
// TODO(mtomasz): Pass entries instead.
var urls = util.entriesToURLs(entries);
chrome.fileBrowserPrivate.executeTask(taskId, urls);
}
}.bind(this));
};
/**
* Checks whether the remote files are available right now.
*
* @param {function} callback The callback.
* @private
*/
FileTasks.prototype.checkAvailability_ = function(callback) {
var areAll = function(props, name) {
var isOne = function(e) {
// If got no properties, we safely assume that item is unavailable.
return e && e[name];
};
return props.filter(isOne).length === props.length;
};
var fm = this.fileManager_;
var entries = this.entries_;
if (fm.isOnDrive() && fm.isDriveOffline()) {
fm.metadataCache_.get(entries, 'drive', function(props) {
if (areAll(props, 'availableOffline')) {
callback();
return;
}
fm.alert.showHtml(
loadTimeData.getString('OFFLINE_HEADER'),
props[0].hosted ?
loadTimeData.getStringF(
entries.length === 1 ?
'HOSTED_OFFLINE_MESSAGE' :
'HOSTED_OFFLINE_MESSAGE_PLURAL') :
loadTimeData.getStringF(
entries.length === 1 ?
'OFFLINE_MESSAGE' :
'OFFLINE_MESSAGE_PLURAL',
loadTimeData.getString('OFFLINE_COLUMN_LABEL')));
});
return;
}
if (fm.isOnDrive() && fm.isDriveOnMeteredConnection()) {
fm.metadataCache_.get(entries, 'drive', function(driveProps) {
if (areAll(driveProps, 'availableWhenMetered')) {
callback();
return;
}
fm.metadataCache_.get(entries, 'filesystem', function(fileProps) {
var sizeToDownload = 0;
for (var i = 0; i !== entries.length; i++) {
if (!driveProps[i].availableWhenMetered)
sizeToDownload += fileProps[i].size;
}
fm.confirm.show(
loadTimeData.getStringF(
entries.length === 1 ?
'CONFIRM_MOBILE_DATA_USE' :
'CONFIRM_MOBILE_DATA_USE_PLURAL',
util.bytesToString(sizeToDownload)),
callback);
});
});
return;
}
callback();
};
/**
* Executes an internal task.
*
* @param {string} id The short task id.
* @param {Array.<Entry>} entries The entries to execute on.
* @private
*/
FileTasks.prototype.executeInternalTask_ = function(id, entries) {
var fm = this.fileManager_;
if (id === 'play') {
var position = 0;
if (entries.length === 1) {
// If just a single audio file is selected pass along every audio file
// in the directory.
var selectedEntries = entries[0];
entries = fm.getAllEntriesInCurrentDirectory().filter(FileType.isAudio);
position = entries.indexOf(selectedEntries);
}
// TODO(mtomasz): Pass entries instead.
var urls = util.entriesToURLs(entries);
fm.backgroundPage.launchAudioPlayer({items: urls, position: position});
return;
}
if (id === 'watch') {
console.assert(entries.length === 1, 'Cannot open multiple videos');
// TODO(mtomasz): Pass an entry instead.
fm.backgroundPage.launchVideoPlayer(entries[0].toURL());
return;
}
if (id === 'mount-archive') {
this.mountArchivesInternal_(entries);
return;
}
if (id === 'gallery') {
this.openGalleryInternal_(entries);
return;
}
console.error('Unexpected action ID: ' + id);
};
/**
* Mounts archives.
*
* @param {Array.<Entry>} entries Mount file entries list.
*/
FileTasks.prototype.mountArchives = function(entries) {
FileTasks.recordViewingFileTypeUMA_(entries);
this.mountArchivesInternal_(entries);
};
/**
* The core implementation of mounts archives.
*
* @param {Array.<Entry>} entries Mount file entries list.
* @private
*/
FileTasks.prototype.mountArchivesInternal_ = function(entries) {
var fm = this.fileManager_;
var tracker = fm.directoryModel.createDirectoryChangeTracker();
tracker.start();
// TODO(mtomasz): Pass Entries instead of URLs.
var urls = util.entriesToURLs(entries);
fm.resolveSelectResults_(urls, function(resolvedURLs) {
for (var index = 0; index < resolvedURLs.length; ++index) {
// TODO(mtomasz): Pass Entry instead of URL.
fm.volumeManager.mountArchive(resolvedURLs[index],
function(mountPath) {
tracker.stop();
if (!tracker.hasChanged)
fm.directoryModel.changeDirectory(mountPath);
}, function(url, error) {
tracker.stop();
var path = util.extractFilePath(url);
var namePos = path.lastIndexOf('/');
fm.alert.show(strf('ARCHIVE_MOUNT_FAILED',
path.substr(namePos + 1), error));
}.bind(null, resolvedURLs[index]));
}
});
};
/**
* Open the Gallery.
*
* @param {Array.<Entry>} entries List of selected entries.
*/
FileTasks.prototype.openGallery = function(entries) {
FileTasks.recordViewingFileTypeUMA_(entries);
this.openGalleryInternal_(entries);
};
/**
* The core implementation to open the Gallery.
*
* @param {Array.<Entry>} entries List of selected entries.
* @private
*/
FileTasks.prototype.openGalleryInternal_ = function(entries) {
var fm = this.fileManager_;
var allEntries =
fm.getAllEntriesInCurrentDirectory().filter(FileType.isImageOrVideo);
var galleryFrame = fm.document_.createElement('iframe');
galleryFrame.className = 'overlay-pane';
galleryFrame.scrolling = 'no';
galleryFrame.setAttribute('webkitallowfullscreen', true);
if (this.params_ && this.params_.gallery) {
// Remove the Gallery state from the location, we do not need it any more.
util.updateAppState(null /* keep path */, '' /* remove search. */);
}
var savedAppState = window.appState;
var savedTitle = document.title;
// Push a temporary state which will be replaced every time the selection
// changes in the Gallery and popped when the Gallery is closed.
util.updateAppState();
var onBack = function(selectedEntries) {
fm.directoryModel.selectEntries(selectedEntries);
fm.closeFilePopup(); // Will call Gallery.unload.
window.appState = savedAppState;
util.saveAppState();
document.title = savedTitle;
};
var onClose = function() {
fm.onClose();
};
var onMaximize = function() {
fm.onMaximize();
};
var onAppRegionChanged = function(visible) {
fm.onFilePopupAppRegionChanged(visible);
};
galleryFrame.onload = function() {
galleryFrame.contentWindow.ImageUtil.metrics = metrics;
// TODO(haruki): isOnReadonlyDirectory() only checks the permission for the
// root. We should check more granular permission to know whether the file
// is writable or not.
var readonly = fm.isOnReadonlyDirectory();
var currentDir = fm.getCurrentDirectoryEntry();
var downloadsVolume =
fm.volumeManager.getCurrentProfileVolumeInfo(RootType.DOWNLOADS);
var downloadsDir = downloadsVolume && downloadsVolume.root;
var readonlyDirName = null;
if (readonly && currentDir) {
var rootPath = PathUtil.getRootPath(currentDir.fullPath);
readonlyDirName = fm.isOnDrive() ?
PathUtil.getRootLabel(rootPath) :
PathUtil.basename(rootPath);
}
var context = {
// We show the root label in readonly warning (e.g. archive name).
readonlyDirName: readonlyDirName,
curDirEntry: currentDir,
saveDirEntry: readonly ? downloadsDir : null,
searchResults: fm.directoryModel.isSearching(),
metadataCache: fm.metadataCache_,
pageState: this.params_,
appWindow: chrome.app.window.current(),
onBack: onBack,
onClose: onClose,
onMaximize: onMaximize,
onAppRegionChanged: onAppRegionChanged,
displayStringFunction: strf
};
galleryFrame.contentWindow.Gallery.open(
context, fm.volumeManager, allEntries, entries);
}.bind(this);
galleryFrame.src = 'gallery.html';
fm.openFilePopup(galleryFrame, fm.updateTitle_.bind(fm));
};
/**
* Displays the list of tasks in a task picker combobutton.
*
* @param {cr.ui.ComboButton} combobutton The task picker element.
* @private
*/
FileTasks.prototype.display_ = function(combobutton) {
if (this.tasks_.length === 0) {
combobutton.hidden = true;
return;
}
combobutton.clear();
combobutton.hidden = false;
combobutton.defaultItem = this.createCombobuttonItem_(this.defaultTask_);
var items = this.createItems_();
if (items.length > 1) {
var defaultIdx = 0;
for (var j = 0; j < items.length; j++) {
combobutton.addDropDownItem(items[j]);
if (items[j].task.taskId === this.defaultTask_.taskId)
defaultIdx = j;
}
combobutton.addSeparator();
var changeDefaultMenuItem = combobutton.addDropDownItem({
label: loadTimeData.getString('CHANGE_DEFAULT_MENU_ITEM')
});
changeDefaultMenuItem.classList.add('change-default');
}
};
/**
* Creates sorted array of available task descriptions such as title and icon.
*
* @return {Array} created array can be used to feed combobox, menus and so on.
* @private
*/
FileTasks.prototype.createItems_ = function() {
var items = [];
var title = this.defaultTask_.title + ' ' +
loadTimeData.getString('DEFAULT_ACTION_LABEL');
items.push(this.createCombobuttonItem_(this.defaultTask_, title, true));
for (var index = 0; index < this.tasks_.length; index++) {
var task = this.tasks_[index];
if (task !== this.defaultTask_)
items.push(this.createCombobuttonItem_(task));
}
items.sort(function(a, b) {
return a.label.localeCompare(b.label);
});
return items;
};
/**
* Updates context menu with default item.
* @private
*/
FileTasks.prototype.updateMenuItem_ = function() {
this.fileManager_.updateContextMenuActionItems(this.defaultTask_,
this.tasks_.length > 1);
};
/**
* Creates combobutton item based on task.
*
* @param {Object} task Task to convert.
* @param {string=} opt_title Title.
* @param {boolean=} opt_bold Make a menu item bold.
* @return {Object} Item appendable to combobutton drop-down list.
* @private
*/
FileTasks.prototype.createCombobuttonItem_ = function(task, opt_title,
opt_bold) {
return {
label: opt_title || task.title,
iconUrl: task.iconUrl,
iconType: task.iconType,
task: task,
bold: opt_bold || false
};
};
/**
* Decorates a FileTasks method, so it will be actually executed after the tasks
* are available.
* This decorator expects an implementation called |method + '_'|.
*
* @param {string} method The method name.
*/
FileTasks.decorate = function(method) {
var privateMethod = method + '_';
FileTasks.prototype[method] = function() {
if (this.tasks_) {
this[privateMethod].apply(this, arguments);
} else {
this.pendingInvocations_.push([privateMethod, arguments]);
}
return this;
};
};
/**
* Shows modal action picker dialog with currently available list of tasks.
*
* @param {DefaultActionDialog} actionDialog Action dialog to show and update.
* @param {string} title Title to use.
* @param {string} message Message to use.
* @param {function(Object)} onSuccess Callback to pass selected task.
*/
FileTasks.prototype.showTaskPicker = function(actionDialog, title, message,
onSuccess) {
var items = this.createItems_();
var defaultIdx = 0;
for (var j = 0; j < items.length; j++) {
if (items[j].task.taskId === this.defaultTask_.taskId)
defaultIdx = j;
}
actionDialog.show(
title,
message,
items, defaultIdx,
function(item) {
onSuccess(item.task);
});
};
FileTasks.decorate('display');
FileTasks.decorate('updateMenuItem');
FileTasks.decorate('execute');
FileTasks.decorate('executeDefault');