Commit 2e597d99 authored by Zain Afzal's avatar Zain Afzal Committed by Commit Bot

Added saveas delegate function.

cl/303855538 will add a save copy function to the clientAPI delegate,
this CL adds the chromium side code to implement this function. The save
copy delegate function simply prompts the user to pick a file location,
dumps a provided blob into the file specified, then relaunches the media
app with this new file.

Bug: 996088, b/141587270
Change-Id: I97b253fbffa9e1efb75448c98d41c3cee893bf19
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2134028
Commit-Queue: Zain Afzal <zafzal@google.com>
Reviewed-by: default avatarTrent Apted <tapted@chromium.org>
Cr-Commit-Position: refs/heads/master@{#758688}
parent 41d2eaa4
...@@ -7,5 +7,5 @@ ...@@ -7,5 +7,5 @@
* Externs for draft interfaces not yet upstreamed to closure. * Externs for draft interfaces not yet upstreamed to closure.
*/ */
/** @type {function(): Promise<ArrayBuffer>} */ /** @type {function(): !Promise<!ArrayBuffer>} */
Blob.prototype.arrayBuffer; Blob.prototype.arrayBuffer;
...@@ -55,10 +55,8 @@ guestMessagePipe.registerHandler(Message.OVERWRITE_FILE, async (message) => { ...@@ -55,10 +55,8 @@ guestMessagePipe.registerHandler(Message.OVERWRITE_FILE, async (message) => {
if (!currentlyWritableFile || overwrite.token !== fileToken) { if (!currentlyWritableFile || overwrite.token !== fileToken) {
throw new Error('File not current.'); throw new Error('File not current.');
} }
const writer = await currentlyWritableFile.handle.createWritable();
await writer.write(overwrite.blob); await saveToFile(currentlyWritableFile.handle, overwrite.blob);
await writer.truncate(overwrite.blob.size);
await writer.close();
}); });
guestMessagePipe.registerHandler(Message.DELETE_FILE, async (message) => { guestMessagePipe.registerHandler(Message.DELETE_FILE, async (message) => {
...@@ -90,11 +88,8 @@ guestMessagePipe.registerHandler(Message.RENAME_FILE, async (message) => { ...@@ -90,11 +88,8 @@ guestMessagePipe.registerHandler(Message.RENAME_FILE, async (message) => {
renameMsg.newFilename, {create: true}); renameMsg.newFilename, {create: true});
// Copy file data over to the new file. // Copy file data over to the new file.
const writer = await renamedFileHandle.createWritable();
// TODO(b/153021155): Use originalFile.stream(). // TODO(b/153021155): Use originalFile.stream().
await writer.write(await originalFile.arrayBuffer()); await saveToFile(renamedFileHandle, await originalFile.arrayBuffer());
await writer.truncate(originalFile.size);
await writer.close();
// Remove the old file since the new file has all the data & the new name. // Remove the old file since the new file has all the data & the new name.
// Note even though removing an entry that doesn't exist is considered // Note even though removing an entry that doesn't exist is considered
...@@ -117,6 +112,71 @@ guestMessagePipe.registerHandler(Message.NAVIGATE, async (message) => { ...@@ -117,6 +112,71 @@ guestMessagePipe.registerHandler(Message.NAVIGATE, async (message) => {
await advance(navigate.direction); await advance(navigate.direction);
}); });
guestMessagePipe.registerHandler(Message.SAVE_COPY, async (message) => {
const {blob, suggestedName} = /** @type {!SaveCopyMessage} */ (message);
const extension = suggestedName.split('.').reverse()[0];
// TODO(b/141587270): Add a default filename when it's supported by the native
// file api.
/** @type {!ChooseFileSystemEntriesOptions} */
const options = {
type: 'save-file',
accepts: [{extension, mimeTypes: [blob.type]}]
};
/** @type {!FileSystemHandle} */
let fileSystemHandle;
// chooseFileSystem is where recoverable errors happen, errors in the write
// process should be treated as unexpected and propagated through
// MessagePipe's standard exception handling.
try {
fileSystemHandle =
/** @type {!FileSystemHandle} */ (
await window.chooseFileSystemEntries(options));
} catch (/** @type {!DOMException} */ err) {
if (err.name !== 'SecurityError' && err.name !== 'AbortError') {
// Unknown error.
throw err;
}
console.log(`Aborting SAVE_COPY: ${err.message}`);
return err.name;
}
const {handle} = await getFileFromHandle(fileSystemHandle);
if (await handle.isSameEntry(currentlyWritableFile.handle)) {
return 'attemptedCurrentlyWritableFileOverwrite';
}
// Load this file into the app.
await saveToFile(handle, blob);
});
/**
* Saves the provided blob or arrayBuffer to the provided fileHandle. Assumes
* the handle is writable.
* @param {!FileSystemFileHandle} handle
* @param {!Blob|!ArrayBuffer} data
* @return {!Promise<undefined>}
*/
async function saveToFile(handle, data) {
const writer = await handle.createWritable();
await writer.write(data);
await writer.truncate(data.size);
await writer.close();
}
/**
* Loads a single file into the guest.
* @param {{file: !File, handle: !FileSystemFileHandle}} fileHandle
* @returns {!Promise<undefined>}
*/
async function loadSingleFile(fileHandle) {
/** @type {!FileDescriptor} */
const fd = {token: -1, file: fileHandle.file, handle: fileHandle.handle};
currentFiles.length = 0;
currentFiles.push(fd);
entryIndex = 0;
await sendFilesToGuest();
}
/** /**
* Loads the current file list into the guest. * Loads the current file list into the guest.
* @return {!Promise<undefined>} * @return {!Promise<undefined>}
...@@ -205,7 +265,7 @@ async function getFileFromHandle(fileSystemHandle) { ...@@ -205,7 +265,7 @@ async function getFileFromHandle(fileSystemHandle) {
if (!fileSystemHandle || !fileSystemHandle.isFile) { if (!fileSystemHandle || !fileSystemHandle.isFile) {
return null; return null;
} }
const handle = /** @type{!FileSystemFileHandle} */ (fileSystemHandle); const handle = /** @type {!FileSystemFileHandle} */ (fileSystemHandle);
const file = await handle.getFile(); const file = await handle.getFile();
return {file, handle}; return {file, handle};
} }
......
...@@ -92,6 +92,15 @@ mediaApp.ClientApiDelegate = function() {}; ...@@ -92,6 +92,15 @@ mediaApp.ClientApiDelegate = function() {};
* an error message, resolves with null otherwise. * an error message, resolves with null otherwise.
*/ */
mediaApp.ClientApiDelegate.prototype.openFeedbackDialog = function() {}; mediaApp.ClientApiDelegate.prototype.openFeedbackDialog = function() {};
/**
* Saves a copy of `file` in a custom location with a custom
* name which the user is prompted for via a native save file dialog.
* @param {!mediaApp.AbstractFile} file
* @return {!Promise<?string>} Promise which resolves when the request has been
* acknowledged. If the dialog could not be opened the promise resolves with
* an error message. Otherwise, with null after writing is complete.
*/
mediaApp.ClientApiDelegate.prototype.saveCopy = function(file) {};
/** /**
* The client Api for interacting with the media app instance. * The client Api for interacting with the media app instance.
......
...@@ -18,6 +18,7 @@ const Message = { ...@@ -18,6 +18,7 @@ const Message = {
OPEN_FEEDBACK_DIALOG: 'open-feedback-dialog', OPEN_FEEDBACK_DIALOG: 'open-feedback-dialog',
OVERWRITE_FILE: 'overwrite-file', OVERWRITE_FILE: 'overwrite-file',
RENAME_FILE: 'rename-file', RENAME_FILE: 'rename-file',
SAVE_COPY: 'save-copy'
}; };
/** /**
...@@ -83,9 +84,30 @@ const RenameResult = { ...@@ -83,9 +84,30 @@ const RenameResult = {
* Message sent by the unprivileged context to request the privileged context to * Message sent by the unprivileged context to request the privileged context to
* rename the currently writable file. * rename the currently writable file.
* If the supplied file `token` is invalid the request is rejected. * If the supplied file `token` is invalid the request is rejected.
@typedef {{token: number, newFilename: string}} * @typedef {{token: number, newFilename: string}}
*/ */
let RenameFileMessage; let RenameFileMessage;
/** @typedef {{renameResult: RenameResult}} */ /** @typedef {{renameResult: RenameResult}} */
let RenameFileResponse; let RenameFileResponse;
/**
* Message sent by the unprivileged context to the privileged context requesting
* for the provided file to be copied and saved in a new location which the user
* is prompted for, i.e a 'save as' operation. Once the native filesystem api
* allows, this save as dialog will have the filename input be pre-filled with
* `suggestedName`. `suggestedName` is additionally used to determine the file
* extension which helps inform the save as dialog as to which files should be
* overwritable. This method can be called with any file, not just the currently
* writable file.
* @typedef {{blob: !Blob, suggestedName: string}}
*/
let SaveCopyMessage;
/**
* Response message sent by the privileged context indicating the error message
* if the associated save as operation could not be performed. Returns nothing
* if the operation was successful.
* @typedef {{ errorMessage: ?string }}
*/
let SaveCopyResponse;
...@@ -133,7 +133,7 @@ class ReceivedFileList { ...@@ -133,7 +133,7 @@ class ReceivedFileList {
} }
parentMessagePipe.registerHandler(Message.LOAD_FILES, async (message) => { parentMessagePipe.registerHandler(Message.LOAD_FILES, async (message) => {
const filesMessage = /** @type{!LoadFilesMessage} */ (message); const filesMessage = /** @type {!LoadFilesMessage} */ (message);
await loadFiles(new ReceivedFileList(filesMessage)); await loadFiles(new ReceivedFileList(filesMessage));
}); });
...@@ -143,11 +143,18 @@ parentMessagePipe.registerHandler(Message.LOAD_FILES, async (message) => { ...@@ -143,11 +143,18 @@ parentMessagePipe.registerHandler(Message.LOAD_FILES, async (message) => {
* @type {!mediaApp.ClientApiDelegate} * @type {!mediaApp.ClientApiDelegate}
*/ */
const DELEGATE = { const DELEGATE = {
/** @override */
async openFeedbackDialog() { async openFeedbackDialog() {
const response = const response =
await parentMessagePipe.sendMessage(Message.OPEN_FEEDBACK_DIALOG); await parentMessagePipe.sendMessage(Message.OPEN_FEEDBACK_DIALOG);
return /** @type {?string} */ (response['errorMessage']); return /** @type {?string} */ (response['errorMessage']);
},
async saveCopy(/** !mediaApp.AbstractFile */ abstractFile) {
/** @type {!SaveCopyMessage} */
const msg = {blob: abstractFile.blob, suggestedName: abstractFile.name};
const response =
/** @type {!SaveCopyResponse} */ (
await parentMessagePipe.sendMessage(Message.SAVE_COPY, msg));
return response.errorMessage;
} }
}; };
......
...@@ -139,7 +139,7 @@ class FileSystemDirectoryHandle extends FileSystemHandle { ...@@ -139,7 +139,7 @@ class FileSystemDirectoryHandle extends FileSystemHandle {
/** /**
* @param {string} name * @param {string} name
* @param {FileSystemGetFileOptions=} options * @param {FileSystemGetFileOptions=} options
* @return {Promise<!FileSystemFileHandle>} * @return {!Promise<!FileSystemFileHandle>}
*/ */
getFile(name, options) {} getFile(name, options) {}
...@@ -204,10 +204,10 @@ let ChooseFileSystemEntriesOptionsAccepts; ...@@ -204,10 +204,10 @@ let ChooseFileSystemEntriesOptionsAccepts;
* excludeAcceptAllOption: (boolean|undefined) * excludeAcceptAllOption: (boolean|undefined)
* }} * }}
*/ */
let chooseFileSystemEntriesOptions; let ChooseFileSystemEntriesOptions;
/** /**
* @param {(!chooseFileSystemEntriesOptions|undefined)} options * @param {(!ChooseFileSystemEntriesOptions|undefined)} options
* @return {!Promise<(!FileSystemHandle|!Array<!FileSystemHandle>)>} * @return {!Promise<(!FileSystemHandle|!Array<!FileSystemHandle>)>}
*/ */
window.chooseFileSystemEntries; window.chooseFileSystemEntries;
......
...@@ -14,6 +14,7 @@ let TestMessageResponseData; ...@@ -14,6 +14,7 @@ let TestMessageResponseData;
* property: (string|undefined), * property: (string|undefined),
* renameLastFile: (string|undefined), * renameLastFile: (string|undefined),
* requestFullscreen: (boolean|undefined), * requestFullscreen: (boolean|undefined),
* saveCopy: (boolean|undefined),
* testQuery: string, * testQuery: string,
* }} * }}
*/ */
......
...@@ -75,6 +75,14 @@ async function runTestQuery(data) { ...@@ -75,6 +75,14 @@ async function runTestQuery(data) {
} catch (/** @type{Error} */ error) { } catch (/** @type{Error} */ error) {
result = `renameOriginalFile failed Error: ${error}`; result = `renameOriginalFile failed Error: ${error}`;
} }
} else if (data.saveCopy) {
const existingFile = lastReceivedFileList.item(0);
if (!existingFile) {
result = 'saveCopy failed, no file loaded';
} else {
DELEGATE.saveCopy(existingFile);
result = 'boo yah!';
}
} }
return {testQueryResult: result}; return {testQueryResult: result};
} }
......
...@@ -72,9 +72,14 @@ var MediaAppUIBrowserTest = class extends testing.Test { ...@@ -72,9 +72,14 @@ var MediaAppUIBrowserTest = class extends testing.Test {
const TEST_IMAGE_WIDTH = 123; const TEST_IMAGE_WIDTH = 123;
const TEST_IMAGE_HEIGHT = 456; const TEST_IMAGE_HEIGHT = 456;
/** @return {Promise<File>} A 123x456 transparent encoded image/png. */ /**
async function createTestImageFile() { * @param {number=} width
const canvas = new OffscreenCanvas(TEST_IMAGE_WIDTH, TEST_IMAGE_HEIGHT); * @param {number=} height
* @return {Promise<File>} A {width}x{height} transparent encoded image/png.
*/
async function createTestImageFile(
width = TEST_IMAGE_WIDTH, height = TEST_IMAGE_HEIGHT) {
const canvas = new OffscreenCanvas(width, height);
canvas.getContext('2d'); // convertToBlob fails without a rendering context. canvas.getContext('2d'); // convertToBlob fails without a rendering context.
const blob = await canvas.convertToBlob(); const blob = await canvas.convertToBlob();
return new File([blob], 'test_file.png', {type: 'image/png'}); return new File([blob], 'test_file.png', {type: 'image/png'});
...@@ -108,7 +113,7 @@ TEST_F('MediaAppUIBrowserTest', 'LoadFile', async () => { ...@@ -108,7 +113,7 @@ TEST_F('MediaAppUIBrowserTest', 'LoadFile', async () => {
}); });
// Tests that chrome://media-app can successfully send a request to open the // Tests that chrome://media-app can successfully send a request to open the
// feedback dialog and recieve a response. // feedback dialog and receive a response.
TEST_F('MediaAppUIBrowserTest', 'CanOpenFeedbackDialog', async () => { TEST_F('MediaAppUIBrowserTest', 'CanOpenFeedbackDialog', async () => {
const result = await mediaAppPageHandler.openFeedbackDialog(); const result = await mediaAppPageHandler.openFeedbackDialog();
...@@ -297,6 +302,35 @@ TEST_F('MediaAppUIBrowserTest', 'RenameOriginalIPC', async () => { ...@@ -297,6 +302,35 @@ TEST_F('MediaAppUIBrowserTest', 'RenameOriginalIPC', async () => {
testDone(); testDone();
}); });
// Tests the IPC behind the saveCopy delegate function.
TEST_F('MediaAppUIBrowserTest', 'SaveCopyIPC', async () => {
// Mock out choose file system entries since it can only be interacted with
// via trusted user gestures.
const newFileHandle = new FakeFileSystemFileHandle();
const chooseEntries = new Promise(resolve => {
window.chooseFileSystemEntries = options => {
resolve(options);
return newFileHandle;
};
});
const testImage = await createTestImageFile(10, 10);
loadFile(testImage, new FakeFileSystemFileHandle());
const result = await guestMessagePipe.sendMessage('test', {saveCopy: true});
assertEquals(result.testQueryResult, 'boo yah!');
const options = await chooseEntries;
assertEquals(options.type, 'save-file');
assertEquals(options.accepts.length, 1);
assertEquals(options.accepts[0].extension, 'png');
assertEquals(options.accepts[0].mimeTypes.length, 1);
assertEquals(options.accepts[0].mimeTypes[0], 'image/png');
const writeResult = await newFileHandle.lastWritable.closePromise;
assertEquals(await writeResult.text(), await testImage.text());
testDone();
});
// Test cases injected into the guest context. // Test cases injected into the guest context.
// See implementations in media_app_guest_ui_browsertest.js. // See implementations in media_app_guest_ui_browsertest.js.
......
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