Commit fea937f2 authored by Trent Apted's avatar Trent Apted Committed by Commit Bot

Add same-directory navigation in chrome://media-app's privileged UI.

This adds the necessary logic using some placeholder UI. The logic reads
the directory contents and allows files whose MIME type matches the
chosen file to be cycled through.

The SWA config for chrome://media-app is updated to set
include_launch_directory = true. This guarantees that at least 2
handles will now be passed to the launchQueue consumer: a folder, and
a file in that folder (then possibly more files which we ignore for now).

web_app_file_handling.externs.js is updated with definitions for the new
parts of the NativeFileSystem API that we need for directory access. This
uses `AsyncIterable`, which requires ES2018 to properly use it. Update
our closure compile settings to accept `for await`.

Bug: 1030988, b/144865794
Change-Id: I1f2185374fbd0c691a3782b430cc2cbd70027823
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2034389Reviewed-by: default avatarGiovanni Ortuño Urquidi <ortuno@chromium.org>
Commit-Queue: Trent Apted <tapted@chromium.org>
Cr-Commit-Position: refs/heads/master@{#738811}
parent 74c2b542
...@@ -27,6 +27,7 @@ ...@@ -27,6 +27,7 @@
#include "extensions/browser/extension_system.h" #include "extensions/browser/extension_system.h"
#include "testing/gtest/include/gtest/gtest.h" #include "testing/gtest/include/gtest/gtest.h"
using platform_util::OpenOperationResult;
using web_app::SystemAppType; using web_app::SystemAppType;
namespace { namespace {
...@@ -41,6 +42,9 @@ constexpr char kFilePng800x600[] = "image.png"; ...@@ -41,6 +42,9 @@ constexpr char kFilePng800x600[] = "image.png";
// A 640x480 image/jpeg (all green pixels). // A 640x480 image/jpeg (all green pixels).
constexpr char kFileJpeg640x480[] = "image3.jpg"; constexpr char kFileJpeg640x480[] = "image3.jpg";
// A 100x100 image/jpeg (all blue pixels).
constexpr char kFileJpeg100x100[] = "small.jpg";
// A 1-second long 648x486 VP9-encoded video with stereo Opus-encoded audio. // A 1-second long 648x486 VP9-encoded video with stereo Opus-encoded audio.
constexpr char kFileVideoVP9[] = "world.webm"; constexpr char kFileVideoVP9[] = "world.webm";
...@@ -80,6 +84,22 @@ base::FilePath TestFile(const std::string& ascii_name) { ...@@ -80,6 +84,22 @@ base::FilePath TestFile(const std::string& ascii_name) {
return path; return path;
} }
// Use platform_util::OpenItem() on the given |path| to simulate a user request
// to open that path, e.g., from the Files app or chrome://downloads.
OpenOperationResult OpenPathWithPlatformUtil(Profile* profile,
const base::FilePath& path) {
base::RunLoop run_loop;
OpenOperationResult open_result;
platform_util::OpenItem(
profile, path, platform_util::OPEN_FILE,
base::BindLambdaForTesting([&](OpenOperationResult result) {
open_result = result;
run_loop.Quit();
}));
run_loop.Run();
return open_result;
}
// Runs |script| in the unprivileged app frame of |web_ui|. // Runs |script| in the unprivileged app frame of |web_ui|.
content::EvalJsResult EvalJsInAppFrame(content::WebContents* web_ui, content::EvalJsResult EvalJsInAppFrame(content::WebContents* web_ui,
const std::string& script) { const std::string& script) {
...@@ -103,6 +123,14 @@ void PrepareAppForTest(content::WebContents* web_ui) { ...@@ -103,6 +123,14 @@ void PrepareAppForTest(content::WebContents* web_ui) {
web_ui, MediaAppUiBrowserTest::AppJsTestLibrary())); web_ui, MediaAppUiBrowserTest::AppJsTestLibrary()));
} }
content::WebContents* PrepareActiveBrowserForTest() {
Browser* app_browser = chrome::FindBrowserWithActiveWindow();
content::WebContents* web_ui =
app_browser->tab_strip_model()->GetActiveWebContents();
PrepareAppForTest(web_ui);
return web_ui;
}
// Waits for a promise that resolves with image dimensions, once an <img> // Waits for a promise that resolves with image dimensions, once an <img>
// element appears in the light DOM that is backed by a blob URL. // element appears in the light DOM that is backed by a blob URL.
content::EvalJsResult WaitForOpenedImage(content::WebContents* web_ui) { content::EvalJsResult WaitForOpenedImage(content::WebContents* web_ui) {
...@@ -165,7 +193,7 @@ IN_PROC_BROWSER_TEST_F(MediaAppIntegrationTest, MediaAppLaunchWithFile) { ...@@ -165,7 +193,7 @@ IN_PROC_BROWSER_TEST_F(MediaAppIntegrationTest, MediaAppLaunchWithFile) {
// Ensures that chrome://media-app is available as a file task for the ChromeOS // Ensures that chrome://media-app is available as a file task for the ChromeOS
// file manager and eligible for opening appropriate files / mime types. // file manager and eligible for opening appropriate files / mime types.
IN_PROC_BROWSER_TEST_F(MediaAppIntegrationTest, MediaAppElibibleOpenTask) { IN_PROC_BROWSER_TEST_F(MediaAppIntegrationTest, MediaAppEligibleOpenTask) {
constexpr bool kIsDirectory = false; constexpr bool kIsDirectory = false;
const extensions::EntryInfo image_entry(TestFile(kFilePng800x600), const extensions::EntryInfo image_entry(TestFile(kFilePng800x600),
"image/png", kIsDirectory); "image/png", kIsDirectory);
...@@ -203,20 +231,11 @@ IN_PROC_BROWSER_TEST_F(MediaAppIntegrationWithFilesAppTest, ...@@ -203,20 +231,11 @@ IN_PROC_BROWSER_TEST_F(MediaAppIntegrationWithFilesAppTest,
WaitForTestSystemAppInstall(); WaitForTestSystemAppInstall();
Browser* test_browser = chrome::FindBrowserWithActiveWindow(); Browser* test_browser = chrome::FindBrowserWithActiveWindow();
using platform_util::OpenOperationResult;
file_manager::test::FolderInMyFiles folder(profile()); file_manager::test::FolderInMyFiles folder(profile());
folder.Add({TestFile(kFilePng800x600)}); folder.Add({TestFile(kFilePng800x600)});
base::RunLoop run_loop; OpenOperationResult open_result =
OpenOperationResult open_result; OpenPathWithPlatformUtil(profile(), folder.files()[0]);
platform_util::OpenItem(
profile(), folder.files()[0], platform_util::OPEN_FILE,
base::BindLambdaForTesting([&](OpenOperationResult result) {
open_result = result;
run_loop.Quit();
}));
run_loop.Run();
// Window focus changes on ChromeOS are synchronous, so just get the newly // Window focus changes on ChromeOS are synchronous, so just get the newly
// focused window. // focused window.
...@@ -233,3 +252,61 @@ IN_PROC_BROWSER_TEST_F(MediaAppIntegrationWithFilesAppTest, ...@@ -233,3 +252,61 @@ IN_PROC_BROWSER_TEST_F(MediaAppIntegrationWithFilesAppTest,
*GetManager().GetAppIdForSystemApp(web_app::SystemAppType::MEDIA)); *GetManager().GetAppIdForSystemApp(web_app::SystemAppType::MEDIA));
EXPECT_EQ("800x600", WaitForOpenedImage(web_ui)); EXPECT_EQ("800x600", WaitForOpenedImage(web_ui));
} }
// Test that the MediaApp can navigate other files in the directory of a file
// that was opened.
IN_PROC_BROWSER_TEST_F(MediaAppIntegrationWithFilesAppTest,
FileOpenCanTraverseDirectory) {
WaitForTestSystemAppInstall();
// Initialize a folder with 3 files: 2 JPEG, 1 PNG. Note this approach doesn't
// guarantee the modification times of the files so, and therefore does not
// suggest an ordering to the files of the directory contents. But by having
// at most two active files, we can still write a robust test.
file_manager::test::FolderInMyFiles folder(profile());
folder.Add({
TestFile(kFilePng800x600),
TestFile(kFileJpeg640x480),
TestFile(kFileJpeg100x100),
});
const base::FilePath copied_png_800x600 = folder.files()[0];
const base::FilePath copied_jpeg_640x480 = folder.files()[1];
// Sent an open request using only the 640x480 JPEG file.
OpenPathWithPlatformUtil(profile(), copied_jpeg_640x480);
content::WebContents* web_ui = PrepareActiveBrowserForTest();
EXPECT_EQ("640x480", WaitForOpenedImage(web_ui));
// Clear the <img> src attribute to ensure we can detect changes reliably.
// TODO(crbug/893226): Use the alt-text to find the image instead.
ClearOpenedImage(web_ui);
// Navigate to the next file in the directory.
EXPECT_EQ(true, ExecuteScript(web_ui, "advance(1)"));
EXPECT_EQ("100x100", WaitForOpenedImage(web_ui));
// Navigating again should wraparound, but skip the 800x600 PNG because it is
// a different mime type to the original open request.
ClearOpenedImage(web_ui);
EXPECT_EQ(true, ExecuteScript(web_ui, "advance(1)"));
EXPECT_EQ("640x480", WaitForOpenedImage(web_ui));
// Navigate backwards.
ClearOpenedImage(web_ui);
EXPECT_EQ(true, ExecuteScript(web_ui, "advance(-1)"));
EXPECT_EQ("100x100", WaitForOpenedImage(web_ui));
// Now open the png.
ClearOpenedImage(web_ui);
OpenPathWithPlatformUtil(profile(), copied_png_800x600);
EXPECT_EQ("800x600", WaitForOpenedImage(web_ui));
// Navigating should stay on this file. Note currently, this will "reload" the
// file. It would also be acceptable to "do nothing", but that will be tackled
// on the UI layer by hiding the buttons.
ClearOpenedImage(web_ui);
EXPECT_EQ(true, ExecuteScript(web_ui, "advance(1)"));
EXPECT_EQ("800x600", WaitForOpenedImage(web_ui));
}
...@@ -87,6 +87,7 @@ base::flat_map<SystemAppType, SystemAppInfo> CreateSystemWebApps() { ...@@ -87,6 +87,7 @@ base::flat_map<SystemAppType, SystemAppInfo> CreateSystemWebApps() {
if (SystemWebAppManager::IsAppEnabled(SystemAppType::MEDIA)) { if (SystemWebAppManager::IsAppEnabled(SystemAppType::MEDIA)) {
infos.emplace(SystemAppType::MEDIA, infos.emplace(SystemAppType::MEDIA,
SystemAppInfo("Media", GURL("chrome://media-app/pwa.html"))); SystemAppInfo("Media", GURL("chrome://media-app/pwa.html")));
infos.at(SystemAppType::MEDIA).include_launch_directory = true;
} }
#if !defined(OFFICIAL_BUILD) #if !defined(OFFICIAL_BUILD)
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
height: 100vh; height: 100vh;
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
position: relative;
} }
/* /*
* This is the <iframe> style set for sandboxed guests that use * This is the <iframe> style set for sandboxed guests that use
...@@ -20,7 +21,42 @@ ...@@ -20,7 +21,42 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
/*
* Container for the UI that overlays the sandboxed guest. Used for hosting UI
* components deemed to be privileged (e.g. to detect user gestures).
*/
div#overlay {
display: grid;
grid-template-rows: auto 40px auto;
grid-template-columns: 40px auto 40px;
height: 100%;
justify-items: stretch;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
}
div.navigation-button {
pointer-events: auto;
grid-row: 2;
background-color: green;
opacity: 0;
}
div.navigation-button:hover {
opacity: 0.5;
}
div#prev-container {
grid-column: 1;
}
div#next-container {
grid-column: 3;
}
</style> </style>
<script src="chrome://resources/mojo/mojo/public/js/mojo_bindings_lite.js"></script> <script src="chrome://resources/mojo/mojo/public/js/mojo_bindings_lite.js"></script>
<iframe src="chrome://media-app-guest/app.html"></iframe> <iframe src="chrome://media-app-guest/app.html"></iframe>
<div id="overlay">
<div id="prev-container" class="navigation-button"></div>
<div id="next-container" class="navigation-button"></div>
</div>
<script src="/launch.js"></script> <script src="/launch.js"></script>
...@@ -10,6 +10,7 @@ js_type_check("closure_compile") { ...@@ -10,6 +10,7 @@ js_type_check("closure_compile") {
closure_flags = default_closure_args + [ closure_flags = default_closure_args + [
"jscomp_error=strictCheckTypes", "jscomp_error=strictCheckTypes",
"jscomp_error=reportUnknownTypes", "jscomp_error=reportUnknownTypes",
"language_in=ECMASCRIPT_2018",
] ]
deps = [ deps = [
":launch", ":launch",
......
...@@ -2,6 +2,20 @@ ...@@ -2,6 +2,20 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
/**
* Array of entries available in the current directory.
*
* @type {Array<!File>}
*/
const currentFiles = [];
/**
* Index into `currentFiles` of the current file.
*
* @type {number}
*/
let entryIndex = -1;
/** /**
* Helper to call postMessage on the guest frame. * Helper to call postMessage on the guest frame.
* *
...@@ -29,25 +43,83 @@ async function loadFile(file) { ...@@ -29,25 +43,83 @@ async function loadFile(file) {
* Loads a file from a handle received via the fileHandling API. * Loads a file from a handle received via the fileHandling API.
* *
* @param {FileSystemHandle} handle * @param {FileSystemHandle} handle
* @return {Promise<?File>}
*/ */
async function loadFileFromHandle(handle) { async function loadFileFromHandle(handle) {
if (!handle.isFile) { if (!handle.isFile) {
return; return null;
} }
const fileHandle = /** @type{FileSystemFileHandle} */ (handle); const fileHandle = /** @type{FileSystemFileHandle} */ (handle);
const file = await fileHandle.getFile(); const file = await fileHandle.getFile();
loadFile(file); loadFile(file);
return file;
} }
/**
* Changes the working directory and initializes file iteration according to
* the type of the opened file.
*
* @param {FileSystemDirectoryHandle} directory
* @param {?File} focusFile
*/
async function setCurrentDirectory(directory, focusFile) {
if (!focusFile) {
return;
}
currentFiles.length = 0;
for await (const /** !FileSystemHandle */ handle of directory.getEntries()) {
if (!handle.isFile) {
continue;
}
const fileHandle = /** @type{FileSystemFileHandle} */ (handle);
const file = await fileHandle.getFile();
// Only allow traversal of matching mime types.
if (file.type === focusFile.type) {
currentFiles.push(file);
}
}
entryIndex = currentFiles.findIndex(i => i.name == focusFile.name);
}
/**
* Advance to another file.
*
* @param {number} direction How far to advance (e.g. +/-1).
*/
async function advance(direction) {
if (!currentFiles.length || entryIndex < 0) {
return;
}
entryIndex = (entryIndex + direction) % currentFiles.length;
if (entryIndex < 0) {
entryIndex += currentFiles.length;
}
loadFile(currentFiles[entryIndex]);
}
document.getElementById('prev-container')
.addEventListener('click', () => advance(-1));
document.getElementById('next-container')
.addEventListener('click', () => advance(1));
// Wait for 'load' (and not DOMContentLoaded) to ensure the subframe has been // Wait for 'load' (and not DOMContentLoaded) to ensure the subframe has been
// loaded and is ready to respond to postMessage. // loaded and is ready to respond to postMessage.
window.addEventListener('load', () => { window.addEventListener('load', () => {
window.launchQueue.setConsumer(params => { window.launchQueue.setConsumer(params => {
if (!params || !params.files || params.files.length == 0) { if (!params || !params.files || params.files.length < 2) {
console.error('Invalid launch (missing files): ', params);
return;
}
if (!params.files[0].isDirectory) {
console.error('Invalid launch: files[0] is not a directory: ', params);
return; return;
} }
loadFileFromHandle(params.files[0]); const directory = /** @type{FileSystemDirectoryHandle} */ (params.files[0]);
loadFileFromHandle(params.files[1])
.then(file => setCurrentDirectory(directory, file));
}); });
}); });
...@@ -68,6 +68,51 @@ class FileSystemFileHandle extends FileSystemHandle { ...@@ -68,6 +68,51 @@ class FileSystemFileHandle extends FileSystemHandle {
getFile() {} getFile() {}
} }
/** @typedef {{create: boolean}} */
var FileSystemGetFileOptions;
/** @typedef {{create: boolean}} */
var FileSystemGetDirectoryOptions;
/** @typedef {{recursive: boolean}} */
var FileSystemRemoveOptions;
/** @typedef {{type: string}} */
var GetSystemDirectoryOptions;
/** @interface */
class FileSystemDirectoryHandle extends FileSystemHandle {
/**
* @param {string} name
* @param {FileSystemGetFileOptions=} options
* @return {Promise<!FileSystemFileHandle>}
*/
getFile(name, options) {}
/**
* @param {string} name
* @param {FileSystemGetDirectoryOptions=} options
* @return {Promise<!FileSystemDirectoryHandle>}
*/
getDirectory(name, options) {}
/** @return {!AsyncIterable<!FileSystemHandle>} */
getEntries() {}
/**
* @param {string} name
* @param {FileSystemRemoveOptions=} options
* @return {Promise<undefined>}
*/
removeEntry(name, options) {}
/**
* @param {GetSystemDirectoryOptions} options
* @return {Promise<!FileSystemDirectoryHandle>}
*/
static getSystemDirectory(options) {}
};
/** @interface */ /** @interface */
class LaunchParams { class LaunchParams {
constructor() { constructor() {
......
...@@ -12,6 +12,7 @@ js_type_check("closure_compile") { ...@@ -12,6 +12,7 @@ js_type_check("closure_compile") {
closure_flags = default_closure_args + [ closure_flags = default_closure_args + [
"jscomp_error=strictCheckTypes", "jscomp_error=strictCheckTypes",
"jscomp_error=reportUnknownTypes", "jscomp_error=reportUnknownTypes",
"language_in=ECMASCRIPT_2018",
] ]
deps = [ deps = [
":app_main", ":app_main",
......
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