blob: 5a39646462ea8cdfcda33faf052066470c9575c0 [file] [log] [blame]
// Copyright 2016 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.
/**
* VolumeManager is responsible for tracking list of mounted volumes.
* @implements {VolumeManager}
*/
class VolumeManagerImpl extends cr.EventTarget {
constructor() {
super();
/** @override */
this.volumeInfoList = new VolumeInfoListImpl();
/**
* The list of archives requested to mount. We will show contents once
* archive is mounted, but only for mounts from within this filebrowser tab.
* @type {Object<Object>}
* @private
*/
this.requests_ = {};
/**
* Queue for mounting.
* @private @const {AsyncUtil.Queue}
*/
this.mountQueue_ = new AsyncUtil.Queue();
// The status should be merged into VolumeManager.
// TODO(hidehiko): Remove them after the migration.
/**
* Connection state of the Drive.
* @type {VolumeManagerCommon.DriveConnectionState}
* @private
*/
this.driveConnectionState_ = {
type: VolumeManagerCommon.DriveConnectionType.OFFLINE,
reason: VolumeManagerCommon.DriveConnectionReason.NO_SERVICE,
hasCellularNetworkAccess: false
};
chrome.fileManagerPrivate.onDriveConnectionStatusChanged.addListener(
this.onDriveConnectionStatusChanged_.bind(this));
this.onDriveConnectionStatusChanged_();
}
/** @override */
dispose() {}
/**
* Invoked when the drive connection status is changed.
* @private
*/
onDriveConnectionStatusChanged_() {
chrome.fileManagerPrivate.getDriveConnectionState(state => {
// TODO(crbug.com/931971): Convert private API to use enum.
this.driveConnectionState_ =
/** @type {VolumeManagerCommon.DriveConnectionState} */ (state);
cr.dispatchSimpleEvent(this, 'drive-connection-changed');
});
}
/** @override */
getDriveConnectionState() {
return this.driveConnectionState_;
}
/**
* Adds new volume info from the given volumeMetadata. If the corresponding
* volume info has already been added, the volumeMetadata is ignored.
* @param {!chrome.fileManagerPrivate.VolumeMetadata} volumeMetadata
* @return {!Promise<!VolumeInfo>}
* @private
*/
async addVolumeMetadata_(volumeMetadata) {
const volumeInfo = await volumeManagerUtil.createVolumeInfo(volumeMetadata);
// We don't show Downloads and Drive on volume list if they have
// mount error, since users can do nothing in this situation. We
// show Removable and Provided volumes regardless of mount error
// so that users can unmount or format the volume.
// TODO(fukino): Once the Files app gets ready, show erroneous
// Drive volume so that users can see auth warning banner on the
// volume. crbug.com/517772.
let shouldShow = true;
switch (volumeInfo.volumeType) {
case VolumeManagerCommon.VolumeType.DOWNLOADS:
case VolumeManagerCommon.VolumeType.DRIVE:
shouldShow = !!volumeInfo.fileSystem;
break;
}
if (!shouldShow) {
return volumeInfo;
}
if (this.volumeInfoList.findIndex(volumeInfo.volumeId) === -1) {
this.volumeInfoList.add(volumeInfo);
// Update the network connection status, because until the drive
// is initialized, the status is set to not ready.
// TODO(mtomasz): The connection status should be migrated into
// chrome.fileManagerPrivate.VolumeMetadata.
if (volumeMetadata.volumeType === VolumeManagerCommon.VolumeType.DRIVE) {
this.onDriveConnectionStatusChanged_();
}
} else if (
volumeMetadata.volumeType ===
VolumeManagerCommon.VolumeType.REMOVABLE) {
// Update for remounted USB external storage, because they were
// remounted to switch read-only policy.
this.volumeInfoList.add(volumeInfo);
}
return volumeInfo;
}
/**
* Initializes mount points.
* @return {!Promise<void>}
*/
initialize() {
return new Promise((resolve, reject) => {
chrome.fileManagerPrivate.onMountCompleted.addListener(
this.onMountCompleted_.bind(this));
console.warn('Getting volume list');
chrome.fileManagerPrivate.getVolumeMetadataList(volumeMetadataList => {
console.warn(`There are ${volumeMetadataList.length} volumes`);
// We must subscribe to the mount completed event in the callback of
// getVolumeMetadataList (crbug.com/330061). But volumes reported by
// onMountCompleted events must be added after the volumes in the
// volumeMetadataList are mounted (crbug.com/135477).
this.mountQueue_.run(async (done) => {
try {
// Create VolumeInfo for each volume.
await Promise.all(volumeMetadataList.map(async (volumeMetadata) => {
console.warn(`Initializing volume ${volumeMetadata.volumeId}`);
const volumeInfo = await this.addVolumeMetadata_(volumeMetadata);
console.warn(`Initialized volume ${volumeInfo.volumeId}`);
}));
console.warn('Initialized all volumes');
resolve();
} catch (e) {
reject(e);
} finally {
// Call the callback of AsyncQueue. Maybe it invokes callbacks
// registered by mountCompleted events.
done();
}
});
});
});
}
/**
* Event handler called when some volume was mounted or unmounted.
* @param {chrome.fileManagerPrivate.MountCompletedEvent} event Received
* event.
* @private
*/
onMountCompleted_(event) {
this.mountQueue_.run(async (done) => {
try {
switch (event.eventType) {
case 'mount':
var requestKey = this.makeRequestKey_(
'mount', event.volumeMetadata.sourcePath || '');
if (event.status === 'success' ||
event.status ===
VolumeManagerCommon.VolumeError.UNKNOWN_FILESYSTEM ||
event.status ===
VolumeManagerCommon.VolumeError.UNSUPPORTED_FILESYSTEM) {
const volumeInfo =
await this.addVolumeMetadata_(event.volumeMetadata);
this.finishRequest_(requestKey, event.status, volumeInfo);
} else if (
event.status ===
VolumeManagerCommon.VolumeError.ALREADY_MOUNTED) {
const navigationEvent =
new Event(VolumeManagerCommon.VOLUME_ALREADY_MOUNTED);
navigationEvent.volumeId = event.volumeMetadata.volumeId;
this.dispatchEvent(navigationEvent);
this.finishRequest_(requestKey, event.status);
} else {
console.warn('Failed to mount a volume: ' + event.status);
this.finishRequest_(requestKey, event.status);
}
break;
case 'unmount':
const volumeId = event.volumeMetadata.volumeId;
const status = event.status;
var requestKey = this.makeRequestKey_('unmount', volumeId);
const requested = requestKey in this.requests_;
const volumeInfoIndex = this.volumeInfoList.findIndex(volumeId);
const volumeInfo = volumeInfoIndex !== -1 ?
this.volumeInfoList.item(volumeInfoIndex) :
null;
if (event.status === 'success' && !requested && volumeInfo) {
console.warn('Unmounted volume without a request: ' + volumeId);
this.dispatchEvent(new CustomEvent(
'externally-unmounted', {detail: volumeInfo}));
}
this.finishRequest_(requestKey, status);
if (event.status === 'success') {
this.volumeInfoList.remove(event.volumeMetadata.volumeId);
}
console.warn('unmounted volume: ' + volumeId);
break;
}
} finally {
done();
}
});
}
/**
* Creates string to match mount events with requests.
* @param {string} requestType 'mount' | 'unmount'. TODO(hidehiko): Replace by
* enum.
* @param {string} argument Argument describing the request, eg. source file
* path of the archive to be mounted, or a volumeId for unmounting.
* @return {string} Key for |this.requests_|.
* @private
*/
makeRequestKey_(requestType, argument) {
return requestType + ':' + argument;
}
/** @override */
mountArchive(fileUrl, successCallback, errorCallback) {
chrome.fileManagerPrivate.addMount(fileUrl, sourcePath => {
console.info(
'Mount request: url=' + fileUrl + '; sourcePath=' + sourcePath);
const requestKey = this.makeRequestKey_('mount', sourcePath);
this.startRequest_(requestKey, successCallback, errorCallback);
});
}
/** @override */
unmount(volumeInfo, successCallback, errorCallback) {
chrome.fileManagerPrivate.removeMount(volumeInfo.volumeId);
const requestKey = this.makeRequestKey_('unmount', volumeInfo.volumeId);
this.startRequest_(requestKey, successCallback, errorCallback);
}
/** @override */
configure(volumeInfo) {
return new Promise((fulfill, reject) => {
chrome.fileManagerPrivate.configureVolume(volumeInfo.volumeId, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError.message);
} else {
fulfill();
}
});
});
}
/** @override */
getVolumeInfo(entry) {
if (!entry) {
console.error('Invalid entry passed to getVolumeInfo: ' + entry);
return null;
}
for (let i = 0; i < this.volumeInfoList.length; i++) {
const volumeInfo = this.volumeInfoList.item(i);
if (volumeInfo.fileSystem &&
util.isSameFileSystem(volumeInfo.fileSystem, entry.filesystem)) {
return volumeInfo;
}
// Additionally, check fake entries.
for (let key in volumeInfo.fakeEntries) {
const fakeEntry = volumeInfo.fakeEntries[key];
if (util.isSameEntry(fakeEntry, entry)) {
return volumeInfo;
}
}
}
return null;
}
/** @override */
getCurrentProfileVolumeInfo(volumeType) {
for (let i = 0; i < this.volumeInfoList.length; i++) {
const volumeInfo = this.volumeInfoList.item(i);
if (volumeInfo.profile.isCurrentProfile &&
volumeInfo.volumeType === volumeType) {
return volumeInfo;
}
}
return null;
}
/** @override */
getLocationInfo(entry) {
if (!entry) {
console.error('Invalid entry passed to getLocationInfo: ' + entry);
return null;
}
const volumeInfo = this.getVolumeInfo(entry);
if (util.isFakeEntry(entry)) {
return new EntryLocationImpl(
volumeInfo, assert(entry.rootType),
true /* the entry points a root directory. */,
true /* fake entries are read only. */);
}
if (!volumeInfo) {
return null;
}
let rootType;
let isReadOnly;
let isRootEntry;
if (volumeInfo.volumeType === VolumeManagerCommon.VolumeType.DRIVE) {
// For Drive, the roots are /root, /team_drives, /Computers and /other,
// instead of /. Root URLs contain trailing slashes.
if (entry.fullPath == '/root' || entry.fullPath.indexOf('/root/') === 0) {
rootType = VolumeManagerCommon.RootType.DRIVE;
isReadOnly = volumeInfo.isReadOnly;
isRootEntry = entry.fullPath === '/root';
} else if (
entry.fullPath == VolumeManagerCommon.SHARED_DRIVES_DIRECTORY_PATH ||
entry.fullPath.indexOf(
VolumeManagerCommon.SHARED_DRIVES_DIRECTORY_PATH + '/') === 0) {
if (entry.fullPath ==
VolumeManagerCommon.SHARED_DRIVES_DIRECTORY_PATH) {
rootType = VolumeManagerCommon.RootType.SHARED_DRIVES_GRAND_ROOT;
isReadOnly = true;
isRootEntry = true;
} else {
rootType = VolumeManagerCommon.RootType.SHARED_DRIVE;
if (util.isTeamDriveRoot(entry)) {
isReadOnly = false;
isRootEntry = true;
} else {
// Regular files/directories under Shared Drives.
isRootEntry = false;
isReadOnly = volumeInfo.isReadOnly;
}
}
} else if (
entry.fullPath == VolumeManagerCommon.COMPUTERS_DIRECTORY_PATH ||
entry.fullPath.indexOf(
VolumeManagerCommon.COMPUTERS_DIRECTORY_PATH + '/') === 0) {
if (entry.fullPath == VolumeManagerCommon.COMPUTERS_DIRECTORY_PATH) {
rootType = VolumeManagerCommon.RootType.COMPUTERS_GRAND_ROOT;
isReadOnly = true;
isRootEntry = true;
} else {
rootType = VolumeManagerCommon.RootType.COMPUTER;
if (util.isComputersRoot(entry)) {
isReadOnly = true;
isRootEntry = true;
} else {
// Regular files/directories under a Computer entry.
isRootEntry = false;
isReadOnly = volumeInfo.isReadOnly;
}
}
} else if (
entry.fullPath == '/other' ||
entry.fullPath.indexOf('/other/') === 0) {
rootType = VolumeManagerCommon.RootType.DRIVE_OTHER;
isReadOnly = true;
isRootEntry = entry.fullPath === '/other';
} else if (
entry.fullPath === '/.files-by-id' ||
entry.fullPath.indexOf('/.files-by-id/') === 0) {
rootType = VolumeManagerCommon.RootType.DRIVE_OTHER;
// /.files-by-id/<id> is read-only, but /.files-by-id/<id>/foo is
// read-write.
isReadOnly = entry.fullPath.split('/').length < 4;
isRootEntry = entry.fullPath === '/.files-by-id';
} else {
// Accessing Drive files outside of /drive/root and /drive/other is not
// allowed, but can happen. Therefore returning null.
return null;
}
} else {
rootType =
VolumeManagerCommon.getRootTypeFromVolumeType(volumeInfo.volumeType);
isRootEntry = util.isSameEntry(entry, volumeInfo.fileSystem.root);
// Although "Play files" root directory is writable in file system level,
// we prohibit write operations on it in the UI level to avoid confusion.
// Users can still have write access in sub directories like
// /Play files/Pictures, /Play files/DCIM, etc...
if (volumeInfo.volumeType ==
VolumeManagerCommon.VolumeType.ANDROID_FILES &&
isRootEntry) {
isReadOnly = true;
} else {
isReadOnly = volumeInfo.isReadOnly;
}
}
return new EntryLocationImpl(volumeInfo, rootType, isRootEntry, isReadOnly);
}
/** @override */
findByDevicePath(devicePath) {
for (let i = 0; i < this.volumeInfoList.length; i++) {
const volumeInfo = this.volumeInfoList.item(i);
if (volumeInfo.devicePath && volumeInfo.devicePath === devicePath) {
return volumeInfo;
}
}
return null;
}
/** @override */
whenVolumeInfoReady(volumeId) {
return new Promise((fulfill) => {
const handler = () => {
const index = this.volumeInfoList.findIndex(volumeId);
if (index !== -1) {
fulfill(this.volumeInfoList.item(index));
this.volumeInfoList.removeEventListener('splice', handler);
}
};
this.volumeInfoList.addEventListener('splice', handler);
handler();
});
}
/** @override */
getDefaultDisplayRoot(callback) {
console.error(
'Unexpectedly called VolumeManagerImpl.getDefaultDisplayRoot.');
callback(null);
}
/**
* @param {string} key Key produced by |makeRequestKey_|.
* @param {function(VolumeInfo)} successCallback To be called when the request
* finishes successfully.
* @param {function(VolumeManagerCommon.VolumeError)} errorCallback To be
* called when the request fails.
* @private
*/
startRequest_(key, successCallback, errorCallback) {
if (key in this.requests_) {
const request = this.requests_[key];
request.successCallbacks.push(successCallback);
request.errorCallbacks.push(errorCallback);
} else {
this.requests_[key] = {
successCallbacks: [successCallback],
errorCallbacks: [errorCallback],
timeout: setTimeout(
this.onTimeout_.bind(this, key), volumeManagerUtil.TIMEOUT)
};
}
}
/**
* Called if no response received in |TIMEOUT|.
* @param {string} key Key produced by |makeRequestKey_|.
* @private
*/
onTimeout_(key) {
this.invokeRequestCallbacks_(
this.requests_[key], VolumeManagerCommon.VolumeError.TIMEOUT);
delete this.requests_[key];
}
/**
* @param {string} key Key produced by |makeRequestKey_|.
* @param {VolumeManagerCommon.VolumeError|string} status Status received
* from the API.
* @param {VolumeInfo=} opt_volumeInfo Volume info of the mounted volume.
* @private
*/
finishRequest_(key, status, opt_volumeInfo) {
const request = this.requests_[key];
if (!request) {
return;
}
clearTimeout(request.timeout);
this.invokeRequestCallbacks_(request, status, opt_volumeInfo);
delete this.requests_[key];
}
/**
* @param {Object} request Structure created in |startRequest_|.
* @param {VolumeManagerCommon.VolumeError|string} status If status ===
* 'success' success callbacks are called.
* @param {VolumeInfo=} opt_volumeInfo Volume info of the mounted volume.
* @private
*/
invokeRequestCallbacks_(request, status, opt_volumeInfo) {
const callEach = (callbacks, self, args) => {
for (let i = 0; i < callbacks.length; i++) {
callbacks[i].apply(self, args);
}
};
if (status === 'success') {
callEach(request.successCallbacks, this, [opt_volumeInfo]);
} else {
volumeManagerUtil.validateError(status);
callEach(request.errorCallbacks, this, [status]);
}
}
}