Commit 7f5a8b83 authored by Joel Hockey's avatar Joel Hockey Committed by Commit Bot

FilesApp: support being a file drop target to crostini apps

Add support for FilesApp to be a drop target for files which have
originated dragging from other apps such as crostini, plugin vm, or arc.

FilesApp seems to be using custom fs/* mime types such as fs/sources
for DataTransfer used in drag and drop.  This appears to pass around
lists of entry URLs as strings. The URLs are later resolved to FileEntry
using webkitResolveLocalFileSystemURL.

When files are provided from other systems besides FilesApp, the URLs
provided are not compatible, but DataTransferItem.webkitGetAsEntry() can
be used to get a FileEntry which can be used in the drop-copy operation.

This CL now allows DataTransfer with type 'Files', and refactors
FileTransferController.PastePlan to allow sourceEntries to be provided
as an alternative to sourceURLs.

Bug: 1144138
Change-Id: I6b160eef7140d824b816076c71f1bd3a455a4415
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2542938Reviewed-by: default avatarLuciano Pacheco <lucmult@chromium.org>
Commit-Queue: Joel Hockey <joelhockey@chromium.org>
Cr-Commit-Position: refs/heads/master@{#829073}
parent 0fa6b72f
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
// #import * as wrappedVolumeManagerFactory from './volume_manager_factory.m.js'; const {volumeManagerFactory} = wrappedVolumeManagerFactory; // #import * as wrappedVolumeManagerFactory from './volume_manager_factory.m.js'; const {volumeManagerFactory} = wrappedVolumeManagerFactory;
// #import {VolumeManagerImpl} from './volume_manager_impl.m.js'; // #import {VolumeManagerImpl} from './volume_manager_impl.m.js';
// #import * as wrappedVolumeManagerCommon from '../../../base/js/volume_manager_types.m.js'; const {VolumeManagerCommon} = wrappedVolumeManagerCommon; // #import * as wrappedVolumeManagerCommon from '../../../base/js/volume_manager_types.m.js'; const {VolumeManagerCommon} = wrappedVolumeManagerCommon;
// #import {MockFileSystem} from '../../common/js/mock_entry.m.js'; // #import {MockEntry, MockFileSystem} from '../../common/js/mock_entry.m.js';
// #import * as wrappedUtil from '../../common/js/util.m.js'; const {util} = wrappedUtil; // #import * as wrappedUtil from '../../common/js/util.m.js'; const {util} = wrappedUtil;
// #import {str} from '../../common/js/util.m.js'; // #import {str} from '../../common/js/util.m.js';
// #import {EntryLocation} from '../../../externs/entry_location.m.js'; // #import {EntryLocation} from '../../../externs/entry_location.m.js';
...@@ -258,3 +258,43 @@ MockVolumeManager.prototype.findByDevicePath = ...@@ -258,3 +258,43 @@ MockVolumeManager.prototype.findByDevicePath =
/** @override */ /** @override */
MockVolumeManager.prototype.whenVolumeInfoReady = MockVolumeManager.prototype.whenVolumeInfoReady =
VolumeManagerImpl.prototype.whenVolumeInfoReady; VolumeManagerImpl.prototype.whenVolumeInfoReady;
/**
* Used to override window.webkitResolveLocalFileSystemURL for testing. This
* emulates the real function by parsing `url` and finding the matching entry
* in `volumeManager`. E.g. filesystem:downloads/dir/file.txt will look up the
* 'downloads' volume for /dir/file.txt.
*
* @param {VolumeManager} volumeManager VolumeManager to resolve URLs with.
* @param {string} url URL to resolve.
* @param {function(!MockEntry)} successCallback Success callback.
* @param {function(!FileError)=} errorCallback Error callback.
*/
MockVolumeManager.resolveLocalFileSystemURL =
(volumeManager, url, successCallback, errorCallback) => {
const match = url.match(/^filesystem:(\w+)(\/.*)/);
if (match) {
const volumeType =
/** @type {VolumeManagerCommon.VolumeType} */ (match[1]);
let path = match[2];
const volume = volumeManager.getCurrentProfileVolumeInfo(volumeType);
if (volume) {
// Decode URI in file paths.
path = path.split('/').map(decodeURIComponent).join('/');
const entry = volume.fileSystem.entries[path];
if (entry) {
setTimeout(successCallback, 0, entry);
return;
}
}
}
const message =
`MockVolumeManager.resolveLocalFileSystemURL not found: ${url}`;
console.warn(message);
const error = new DOMException(message, 'NotFoundError');
if (errorCallback) {
setTimeout(errorCallback, 0, error);
} else {
throw error;
}
};
...@@ -693,7 +693,7 @@ class FileTasks { ...@@ -693,7 +693,7 @@ class FileTasks {
this.fileTransferController_.executePaste( this.fileTransferController_.executePaste(
new FileTransferController.PastePlan( new FileTransferController.PastePlan(
this.entries_.map(e => e.toURL()), pvmDir, this.entries_.map(e => e.toURL()), [], pvmDir,
assert(this.volumeManager_.getLocationInfo(pvmDir)), assert(this.volumeManager_.getLocationInfo(pvmDir)),
toMove)); toMove));
this.directoryModel_.changeDirectoryEntry(pvmDir); this.directoryModel_.changeDirectoryEntry(pvmDir);
......
...@@ -425,20 +425,38 @@ class FileTransferController { ...@@ -425,20 +425,38 @@ class FileTransferController {
* Collects parameters of paste operation by the given command and the current * Collects parameters of paste operation by the given command and the current
* system clipboard. * system clipboard.
* *
* @param {!DataTransfer} clipboardData System data transfer object.
* @param {DirectoryEntry=} opt_destinationEntry Paste destination.
* @param {string=} opt_effect Desired drop/paste effect. Could be
* 'move'|'copy' (default is copy). Ignored if conflicts with
* |clipboardData.effectAllowed|.
* @return {!FileTransferController.PastePlan} * @return {!FileTransferController.PastePlan}
*/ */
preparePaste(clipboardData, opt_destinationEntry, opt_effect) { preparePaste(clipboardData, opt_destinationEntry, opt_effect) {
// When FilesApp does drag and drop to itself, it uses fs/sources to
// populate sourceURLs, and it will resolve sourceEntries later using
// webkitResolveLocalFileSystemURL().
const sourceURLs = clipboardData.getData('fs/sources') ? const sourceURLs = clipboardData.getData('fs/sources') ?
clipboardData.getData('fs/sources').split('\n') : clipboardData.getData('fs/sources').split('\n') :
[]; [];
// When FilesApp is the paste target for other apps such as crostini,
// the file URL is either not provided, or it is not compatible. We use
// DataTransferItem.webkitGetAsEntry() to get the entry now.
const sourceEntries = sourceURLs.length === 0 ?
Array.prototype.filter.call(clipboardData.items, i => i.kind === 'file')
.map(i => i.webkitGetAsEntry()) :
[];
// effectAllowed set in copy/paste handlers stay uninitialized. DnD handlers // effectAllowed set in copy/paste handlers stay uninitialized. DnD handlers
// work fine. // work fine.
const effectAllowed = clipboardData.effectAllowed !== 'uninitialized' ? const effectAllowed = clipboardData.effectAllowed !== 'uninitialized' ?
clipboardData.effectAllowed : clipboardData.effectAllowed :
clipboardData.getData('fs/effectallowed'); clipboardData.getData('fs/effectallowed');
const destinationEntry = opt_destinationEntry || const destinationEntry = assert(
opt_destinationEntry ||
/** @type {DirectoryEntry} */ /** @type {DirectoryEntry} */
(this.directoryModel_.getCurrentDirEntry()); (this.directoryModel_.getCurrentDirEntry()));
const toMove = util.isDropEffectAllowed(effectAllowed, 'move') && const toMove = util.isDropEffectAllowed(effectAllowed, 'move') &&
(!util.isDropEffectAllowed(effectAllowed, 'copy') || (!util.isDropEffectAllowed(effectAllowed, 'copy') ||
opt_effect === 'move'); opt_effect === 'move');
...@@ -447,11 +465,12 @@ class FileTransferController { ...@@ -447,11 +465,12 @@ class FileTransferController {
this.volumeManager_.getLocationInfo(destinationEntry); this.volumeManager_.getLocationInfo(destinationEntry);
if (!destinationLocationInfo) { if (!destinationLocationInfo) {
console.log( console.log(
'Failed to get destination location for ' + destinationEntry.title() + 'Failed to get destination location for ' + destinationEntry.toURL() +
' while attempting to paste files.'); ' while attempting to paste files.');
} }
return new FileTransferController.PastePlan( return new FileTransferController.PastePlan(
sourceURLs, destinationEntry, assert(destinationLocationInfo), toMove); sourceURLs, sourceEntries, destinationEntry,
assert(destinationLocationInfo), toMove);
} }
/** /**
...@@ -469,10 +488,8 @@ class FileTransferController { ...@@ -469,10 +488,8 @@ class FileTransferController {
const pastePlan = const pastePlan =
this.preparePaste(clipboardData, opt_destinationEntry, opt_effect); this.preparePaste(clipboardData, opt_destinationEntry, opt_effect);
return FileTransferController.URLsToEntriesWithAccess(pastePlan.sourceURLs) return pastePlan.resolveEntries().then(
.then(entriesResult => { sourceEntries => {
const sourceEntries = entriesResult.entries;
if (sourceEntries.length == 0) { if (sourceEntries.length == 0) {
// This can happen when copied files were deleted before pasting // This can happen when copied files were deleted before pasting
// them. We execute the plan as-is, so as to share the post-copy // them. We execute the plan as-is, so as to share the post-copy
...@@ -480,13 +497,12 @@ class FileTransferController { ...@@ -480,13 +497,12 @@ class FileTransferController {
// same-directory entries. // same-directory entries.
return Promise.resolve(this.executePaste(pastePlan)); return Promise.resolve(this.executePaste(pastePlan));
} }
const confirmationType = pastePlan.getConfirmationType(sourceEntries); const confirmationType = pastePlan.getConfirmationType();
if (confirmationType == if (confirmationType ==
FileTransferController.ConfirmationType.NONE) { FileTransferController.ConfirmationType.NONE) {
return Promise.resolve(this.executePaste(pastePlan)); return Promise.resolve(this.executePaste(pastePlan));
} }
const messages = pastePlan.getConfirmationMessages( const messages = pastePlan.getConfirmationMessages(confirmationType);
confirmationType, sourceEntries);
this.confirmationCallback_(pastePlan.isMove, messages) this.confirmationCallback_(pastePlan.isMove, messages)
.then(userApproved => { .then(userApproved => {
if (userApproved) { if (userApproved) {
...@@ -508,21 +524,16 @@ class FileTransferController { ...@@ -508,21 +524,16 @@ class FileTransferController {
const destinationEntry = pastePlan.destinationEntry; const destinationEntry = pastePlan.destinationEntry;
let entries = []; let entries = [];
let failureUrls;
let shareEntries; let shareEntries;
const taskId = this.fileOperationManager_.generateTaskId(); const taskId = this.fileOperationManager_.generateTaskId();
FileTransferController.URLsToEntriesWithAccess(sourceURLs) pastePlan.resolveEntries()
.then(/** .then(sourceEntries => {
* @param {Object} result // The promise is not rejected, so it's safe to not remove the
*/ // early progress center item here.
result => { return this.fileOperationManager_.filterSameDirectoryEntry(
failureUrls = result.failureUrls; sourceEntries, destinationEntry, toMove);
// The promise is not rejected, so it's safe to not remove the })
// early progress center item here.
return this.fileOperationManager_.filterSameDirectoryEntry(
result.entries, destinationEntry, toMove);
})
.then(/** .then(/**
* @param {!Array<Entry>} filteredEntries * @param {!Array<Entry>} filteredEntries
*/ */
...@@ -579,7 +590,7 @@ class FileTransferController { ...@@ -579,7 +590,7 @@ class FileTransferController {
entries, destinationEntry, toMove, taskId); entries, destinationEntry, toMove, taskId);
this.pendingTaskIds.splice( this.pendingTaskIds.splice(
this.pendingTaskIds.indexOf(taskId), 1); this.pendingTaskIds.indexOf(taskId), 1);
}) })
.catch(error => { .catch(error => {
if (error !== 'ABORT') { if (error !== 'ABORT') {
console.error(error.stack ? error.stack : error); console.error(error.stack ? error.stack : error);
...@@ -587,9 +598,9 @@ class FileTransferController { ...@@ -587,9 +598,9 @@ class FileTransferController {
}) })
.finally(() => { .finally(() => {
// Publish source not found error item. // Publish source not found error item.
for (let i = 0; i < failureUrls.length; i++) { for (let i = 0; i < pastePlan.failureUrls.length; i++) {
const fileName = const fileName = decodeURIComponent(
decodeURIComponent(failureUrls[i].replace(/^.+\//, '')); pastePlan.failureUrls[i].replace(/^.+\//, ''));
const item = new ProgressCenterItem(); const item = new ProgressCenterItem();
item.id = 'source-not-found-' + this.sourceNotFoundErrorCount_; item.id = 'source-not-found-' + this.sourceNotFoundErrorCount_;
if (toMove) { if (toMove) {
...@@ -1338,7 +1349,10 @@ class FileTransferController { ...@@ -1338,7 +1349,10 @@ class FileTransferController {
return false; return false;
} }
if (!clipboardData.types || clipboardData.types.indexOf('fs/tag') === -1) { // DataTransfer type will be 'fs/tag' when the source was FilesApp, or
// 'Files' when the source was any other app.
const types = clipboardData.types;
if (!types || !(types.includes('fs/tag') || types.includes('Files'))) {
return false; // Unsupported type of content. return false; // Unsupported type of content.
} }
...@@ -1640,18 +1654,32 @@ FileTransferController.ConfirmationType = { ...@@ -1640,18 +1654,32 @@ FileTransferController.ConfirmationType = {
FileTransferController.PastePlan = class { FileTransferController.PastePlan = class {
/** /**
* @param {!Array<string>} sourceURLs URLs of source entries. * @param {!Array<string>} sourceURLs URLs of source entries.
* @param {!Array<!Entry>} sourceEntries Entries of source entries.
* @param {!DirectoryEntry} destinationEntry Destination directory. * @param {!DirectoryEntry} destinationEntry Destination directory.
* @param {!EntryLocation} destinationLocationInfo Location info of the * @param {!EntryLocation} destinationLocationInfo Location info of the
* destination directory. * destination directory.
* @param {boolean} isMove true if move, false if copy. * @param {boolean} isMove true if move, false if copy.
*/ */
constructor(sourceURLs, destinationEntry, destinationLocationInfo, isMove) { constructor(
sourceURLs, sourceEntries, destinationEntry, destinationLocationInfo,
isMove) {
/** /**
* @type {!Array<string>} * @type {!Array<string>}
* @const * @const
*/ */
this.sourceURLs = sourceURLs; this.sourceURLs = sourceURLs;
/**
* @type {!Array<!Entry>}
*/
this.sourceEntries = sourceEntries;
/**
* Any URLs from sourceURLs which failed resolving to into sourceEntries.
* @type {!Array<string>}
*/
this.failureUrls = [];
/** /**
* @type {!DirectoryEntry} * @type {!DirectoryEntry}
*/ */
...@@ -1669,20 +1697,34 @@ FileTransferController.PastePlan = class { ...@@ -1669,20 +1697,34 @@ FileTransferController.PastePlan = class {
this.isMove = isMove; this.isMove = isMove;
} }
/**
* Resolves sourceEntries from sourceURLs if needed and returns them.
*
* @return {!Promise<!Array<!Entry>>}
*/
async resolveEntries() {
if (!this.sourceEntries.length) {
const result =
await FileTransferController.URLsToEntriesWithAccess(this.sourceURLs);
this.sourceEntries = result.entries;
this.failureUrls = result.failureUrls;
}
return this.sourceEntries;
}
/** /**
* Obtains whether the planned operation requires user's confirmation, as well * Obtains whether the planned operation requires user's confirmation, as well
* as its type. * as its type.
* *
* @param {!Array<!Entry>} sourceEntries
* @return {FileTransferController.ConfirmationType} type of the confirmation * @return {FileTransferController.ConfirmationType} type of the confirmation
* required for the operation. If no confirmation is needed, * required for the operation. If no confirmation is needed,
* FileTransferController.ConfirmationType.NONE will be returned. * FileTransferController.ConfirmationType.NONE will be returned.
*/ */
getConfirmationType(sourceEntries) { getConfirmationType() {
assert(sourceEntries.length != 0); assert(this.sourceEntries.length != 0);
const source = { const source = {
isTeamDrive: util.isSharedDriveEntry(sourceEntries[0]), isTeamDrive: util.isSharedDriveEntry(this.sourceEntries[0]),
teamDriveName: util.getTeamDriveName(sourceEntries[0]) teamDriveName: util.getTeamDriveName(this.sourceEntries[0])
}; };
const destination = { const destination = {
isTeamDrive: util.isSharedDriveEntry(this.destinationEntry), isTeamDrive: util.isSharedDriveEntry(this.destinationEntry),
...@@ -1727,9 +1769,9 @@ FileTransferController.PastePlan = class { ...@@ -1727,9 +1769,9 @@ FileTransferController.PastePlan = class {
* @param {FileTransferController.ConfirmationType} confirmationType * @param {FileTransferController.ConfirmationType} confirmationType
* @return {!Array<string>} sentences for a confirmation dialog box. * @return {!Array<string>} sentences for a confirmation dialog box.
*/ */
getConfirmationMessages(confirmationType, sourceEntries) { getConfirmationMessages(confirmationType) {
assert(sourceEntries.length != 0); assert(this.sourceEntries.length != 0);
const sourceName = util.getTeamDriveName(sourceEntries[0]); const sourceName = util.getTeamDriveName(this.sourceEntries[0]);
const destinationName = util.getTeamDriveName(this.destinationEntry); const destinationName = util.getTeamDriveName(this.destinationEntry);
switch (confirmationType) { switch (confirmationType) {
case FileTransferController.ConfirmationType.MOVE_BETWEEN_SHARED_DRIVES: case FileTransferController.ConfirmationType.MOVE_BETWEEN_SHARED_DRIVES:
......
...@@ -65,7 +65,10 @@ function setUp() { ...@@ -65,7 +65,10 @@ function setUp() {
enableExternalFileScheme: () => {}, enableExternalFileScheme: () => {},
getProfiles: (callback) => { getProfiles: (callback) => {
setTimeout(callback, 0, [], '', ''); setTimeout(callback, 0, [], '', '');
} },
grantAccess: (entryURLs, callback) => {
setTimeout(callback, 0);
},
}, },
}; };
installMockChrome(mockChrome); installMockChrome(mockChrome);
...@@ -91,8 +94,11 @@ function setUp() { ...@@ -91,8 +94,11 @@ function setUp() {
// Fake DirectoryModel. // Fake DirectoryModel.
const directoryModel = createFakeDirectoryModel(); const directoryModel = createFakeDirectoryModel();
// Fake VolumeManager. // Create fake VolumeManager and install webkitResolveLocalFileSystemURL.
volumeManager = new MockVolumeManager(); volumeManager = new MockVolumeManager();
window.webkitResolveLocalFileSystemURL =
MockVolumeManager.resolveLocalFileSystemURL.bind(null, volumeManager);
// Fake FileSelectionHandler. // Fake FileSelectionHandler.
selectionHandler = new FakeFileSelectionHandler(); selectionHandler = new FakeFileSelectionHandler();
...@@ -231,3 +237,55 @@ function testCanMoveDownloads() { ...@@ -231,3 +237,55 @@ function testCanMoveDownloads() {
selectionHandler.updateSelection([otherFolderEntry], []); selectionHandler.updateSelection([otherFolderEntry], []);
assertTrue(fileTransferController.canCutOrDrag()); assertTrue(fileTransferController.canCutOrDrag());
} }
/**
* Tests preparePaste() with FilesApp fs/sources and standard DataTransfer.
*/
async function testPreparePaste(done) {
const myFilesVolume = volumeManager.volumeInfoList.item(1);
const myFilesMockFs =
/** @type {!MockFileSystem} */ (myFilesVolume.fileSystem);
myFilesMockFs.populate(['/testfile.txt', '/testdir/']);
const testFile = MockFileEntry.create(myFilesMockFs, '/testfile.txt');
const testDir = MockDirectoryEntry.create(myFilesMockFs, '/testdir');
// FilesApp internal drag and drop should populate sourceURLs at first, and
// only populate sourceEntries after calling resolveEntries().
const filesAppDataTransfer = new DataTransfer();
filesAppDataTransfer.setData('fs/sources', testFile.toURL());
const filesAppPastePlan =
fileTransferController.preparePaste(filesAppDataTransfer, testDir);
assertEquals(filesAppPastePlan.sourceURLs.length, 1);
assertEquals(filesAppPastePlan.sourceEntries.length, 0);
await filesAppPastePlan.resolveEntries();
assertEquals(filesAppPastePlan.sourceEntries.length, 1);
assertEquals(filesAppPastePlan.sourceEntries[0], testFile);
// Drag and drop from other apps will use DataTransfer.item with
// item.kind === 'file', and use webkitGetAsEntry() to populate sourceEntries.
const otherMockFs = new MockFileSystem('not-filesapp');
const otherFile = MockFileEntry.create(otherMockFs, '/otherfile.txt');
const otherDataTransfer = /** @type {!DataTransfer} */ ({
effectAllowed: 'copy',
getData: () => {
return '';
},
items: [{
kind: 'file',
webkitGetAsEntry: () => {
return otherFile;
},
}],
});
const otherPastePlan =
fileTransferController.preparePaste(otherDataTransfer, testDir);
assertEquals(otherPastePlan.sourceURLs.length, 0);
assertEquals(otherPastePlan.sourceEntries.length, 1);
assertEquals(otherPastePlan.sourceEntries[0], otherFile);
await otherPastePlan.resolveEntries();
assertEquals(otherPastePlan.sourceURLs.length, 0);
assertEquals(otherPastePlan.sourceEntries.length, 1);
assertEquals(otherPastePlan.sourceEntries[0], otherFile);
done();
}
...@@ -316,35 +316,5 @@ chrome.fileSystem = { ...@@ -316,35 +316,5 @@ chrome.fileSystem = {
}, },
}; };
/** window.webkitResolveLocalFileSystemURL =
* Override webkitResolveLocalFileSystemURL for testing. MockVolumeManager.resolveLocalFileSystemURL.bind(null, mockVolumeManager);
* @param {string} url URL to resolve.
* @param {function(!MockEntry)} successCallback Success callback.
* @param {function(!Error)} errorCallback Error callback.
*/
// eslint-disable-next-line
var webkitResolveLocalFileSystemURL = (url, successCallback, errorCallback) => {
const match = url.match(/^filesystem:(\w+)(\/.*)/);
if (match) {
const volumeType = /** @type {VolumeManagerCommon.VolumeType} */ (match[1]);
let path = match[2];
const volume = mockVolumeManager.getCurrentProfileVolumeInfo(volumeType);
if (volume) {
// Decode URI in file paths.
path = path.split('/').map(decodeURIComponent).join('/');
const entry = volume.fileSystem.entries[path];
if (entry) {
setTimeout(successCallback, 0, entry);
return;
}
}
}
const message = `webkitResolveLocalFileSystemURL not found: ${url}`;
console.warn(message);
const error = new DOMException(message, 'NotFoundError');
if (errorCallback) {
setTimeout(errorCallback, 0, error);
} else {
throw error;
}
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment