chromium/chrome/browser/ui/web_applications/web_share_target_browsertest.cc

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#ifdef UNSAFE_BUFFERS_BUILD
// TODO(crbug.com/40285824): Remove this and convert code to safer constructs.
#pragma allow_unsafe_buffers
#endif

#include <limits>
#include <string>
#include <string_view>
#include <vector>

#include "base/files/file.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/safe_base_name.h"
#include "base/memory/raw_ptr.h"
#include "base/strings/stringprintf.h"
#include "base/test/bind.h"
#include "build/chromeos_buildflags.h"
#include "chrome/browser/apps/app_service/app_service_proxy.h"
#include "chrome/browser/apps/app_service/app_service_proxy_factory.h"
#include "chrome/browser/apps/app_service/browser_app_launcher.h"
#include "chrome/browser/apps/app_service/intent_util.h"
#include "chrome/browser/apps/app_service/launch_utils.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/browser/ui/web_applications/test/web_app_browsertest_util.h"
#include "chrome/browser/ui/web_applications/web_app_browsertest_base.h"
#include "chrome/browser/web_applications/web_app_provider.h"
#include "chrome/browser/web_applications/web_app_registrar.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/services/app_service/public/cpp/app_launch_util.h"
#include "components/services/app_service/public/cpp/intent.h"
#include "components/services/app_service/public/cpp/intent_util.h"
#include "components/services/app_service/public/cpp/share_target.h"
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "net/base/filename_util.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "services/network/public/cpp/resource_request_body.h"
#include "ui/display/types/display_constants.h"
#include "url/gurl.h"

#if BUILDFLAG(IS_CHROMEOS_ASH)
#include "base/threading/thread_restrictions.h"
#include "chrome/browser/ash/file_manager/fileapi_util.h"
#include "chrome/browser/ash/file_manager/path_util.h"
#include "chrome/browser/ash/fileapi/recent_file.h"
#include "chrome/browser/ash/fileapi/recent_model.h"
#include "chrome/browser/ash/fileapi/recent_model_factory.h"
#include "chrome/browser/sharesheet/sharesheet_service.h"
#include "storage/browser/file_system/file_system_context.h"
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

#if BUILDFLAG(IS_CHROMEOS_LACROS)
#include "chromeos/crosapi/mojom/app_service_types.mojom.h"
#include "chromeos/crosapi/mojom/sharesheet.mojom.h"
#include "chromeos/crosapi/mojom/sharesheet_mojom_traits.h"
#include "chromeos/lacros/lacros_service.h"
#endif  // BUILDFLAG(IS_CHROMEOS_LACROS)

namespace {

// TODO(crbug.com/40776025): Support file sharing from Lacros.
#if BUILDFLAG(IS_CHROMEOS_ASH)
base::FilePath PrepareWebShareDirectory(Profile* profile) {
  constexpr base::FilePath::CharType kWebShareDirname[] =
      FILE_PATH_LITERAL(".WebShare");
  const base::FilePath directory =
      file_manager::util::GetMyFilesFolderForProfile(profile).Append(
          kWebShareDirname);

  base::ScopedAllowBlockingForTesting allow_blocking;
  base::File::Error result = base::File::FILE_OK;
  EXPECT_TRUE(base::CreateDirectoryAndGetError(directory, &result));
  return directory;
}

void RemoveWebShareDirectory(const base::FilePath& directory) {
  base::ScopedAllowBlockingForTesting allow_blocking;
  EXPECT_TRUE(base::DeletePathRecursively(directory));
}
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

base::FilePath StoreSharedFile(const base::FilePath& directory,
                               const std::string_view name,
                               const std::string_view content) {
  const base::FilePath path = directory.Append(name);
  base::ScopedAllowBlockingForTesting allow_blocking;
  base::File file(path,
                  base::File::FLAG_CREATE_ALWAYS | base::File::FLAG_WRITE);
  EXPECT_EQ(file.WriteAtCurrentPos(content.data(), content.size()),
            static_cast<int>(content.size()));
  return path;
}

content::WebContents* LaunchWebAppWithIntent(Profile* profile,
                                             const webapps::AppId& app_id,
                                             apps::IntentPtr&& intent) {
  apps::AppLaunchParams params = apps::CreateAppLaunchParamsForIntent(
      app_id,
      /*event_flags=*/0, apps::LaunchSource::kFromSharesheet,
      display::kDefaultDisplayId, apps::LaunchContainer::kLaunchContainerWindow,
      std::move(intent), profile);

  content::WebContents* const web_contents =
      apps::AppServiceProxyFactory::GetForProfile(profile)
          ->BrowserAppLauncher()
          ->LaunchAppWithParamsForTesting(std::move(params));
  DCHECK(web_contents);
  return web_contents;
}

content::EvalJsResult ReadTextContent(content::WebContents* web_contents,
                                      const char* id) {
  const std::string script =
      base::StringPrintf("document.getElementById('%s').textContent", id);
  return content::EvalJs(web_contents, script);
}

#if BUILDFLAG(IS_CHROMEOS_LACROS)
class FakeSharesheet : public crosapi::mojom::Sharesheet {
 public:
  FakeSharesheet() = default;
  FakeSharesheet(const FakeSharesheet&) = delete;
  FakeSharesheet& operator=(const FakeSharesheet&) = delete;
  ~FakeSharesheet() override = default;

  void set_profile(Profile* profile) { profile_ = profile; }

  void set_selected_app_id(const webapps::AppId& app_id) {
    selected_app_id_ = app_id;
  }

 private:
  // crosapi::mojom::Sharesheet:
  void ShowBubble(
      const std::string& window_id,
      sharesheet::LaunchSource source,
      crosapi::mojom::IntentPtr intent,
      crosapi::mojom::Sharesheet::ShowBubbleCallback callback) override {
    LaunchWebAppWithIntent(
        profile_, selected_app_id_,
        apps_util::CreateAppServiceIntentFromCrosapi(intent, profile_));
  }
  void ShowBubbleWithOnClosed(
      const std::string& window_id,
      sharesheet::LaunchSource source,
      crosapi::mojom::IntentPtr intent,
      crosapi::mojom::Sharesheet::ShowBubbleWithOnClosedCallback callback)
      override {}
  void CloseBubble(const std::string& window_id) override {}

  raw_ptr<Profile, DanglingUntriaged> profile_ = nullptr;
  webapps::AppId selected_app_id_;
};
#endif  // BUILDFLAG(IS_CHROMEOS_LACROS)

}  // namespace

namespace web_app {

class WebShareTargetBrowserTest : public WebAppBrowserTestBase {
 public:
  GURL share_target_url() const {
    return embedded_test_server()->GetURL("/web_share_target/share.html");
  }

  content::WebContents* LaunchAppWithIntent(const webapps::AppId& app_id,
                                            apps::IntentPtr&& intent,
                                            const GURL& expected_url) {
    DCHECK(intent);
    ui_test_utils::UrlLoadObserver url_observer(expected_url);

    content::WebContents* const web_contents =
        LaunchWebAppWithIntent(profile(), app_id, std::move(intent));
    url_observer.Wait();
    EXPECT_EQ(expected_url, web_contents->GetVisibleURL());
    return web_contents;
  }

#if BUILDFLAG(IS_CHROMEOS_ASH)
  unsigned NumRecentFiles(content::WebContents* contents) {
    unsigned result = std::numeric_limits<unsigned>::max();
    base::RunLoop run_loop;

    const scoped_refptr<storage::FileSystemContext> file_system_context =
        file_manager::util::GetFileSystemContextForRenderFrameHost(
            profile(), contents->GetPrimaryMainFrame());
    ash::RecentModelOptions options;
    options.source_specs.emplace_back(ash::RecentSourceSpec{
        .volume_type =
            extensions::api::file_manager_private::VolumeType::kTesting,
    });
    ash::RecentModelFactory::GetForProfile(profile())->GetRecentFiles(
        file_system_context.get(),
        /*origin=*/GURL(),
        /*query=*/"", options,
        base::BindLambdaForTesting(
            [&result, &run_loop](const std::vector<ash::RecentFile>& files) {
              result = files.size();
              run_loop.Quit();
            }));

    run_loop.Run();
    return result;
  }
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

#if BUILDFLAG(IS_CHROMEOS_LACROS)
  void SetUpOnMainThread() override {
    WebAppBrowserTestBase::SetUpOnMainThread();

    // Replace the production sharesheet with a fake for testing.
    mojo::Remote<crosapi::mojom::Sharesheet>& remote =
        chromeos::LacrosService::Get()->GetRemote<crosapi::mojom::Sharesheet>();
    remote.reset();
    service_.set_profile(profile());
    receiver_.Bind(remote.BindNewPipeAndPassReceiver());
  }

 private:
  FakeSharesheet service_;
  mojo::Receiver<crosapi::mojom::Sharesheet> receiver_{&service_};
#endif  // BUILDFLAG(IS_CHROMEOS_LACROS)
};

IN_PROC_BROWSER_TEST_F(WebShareTargetBrowserTest, ShareUsingFileURL) {
  ASSERT_TRUE(embedded_test_server()->Start());
  const GURL app_url =
      embedded_test_server()->GetURL("/web_share_target/charts.html");
  const webapps::AppId app_id =
      web_app::InstallWebAppFromManifest(browser(), app_url);

  base::ScopedAllowBlockingForTesting allow_blocking;
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());

  auto intent =
      std::make_unique<apps::Intent>(apps_util::kIntentActionSendMultiple);
  {
    const base::FilePath first_csv =
        StoreSharedFile(scoped_temp_dir.GetPath(), "first.csv", "1,2,3,4,5");
    const base::FilePath second_csv =
        StoreSharedFile(scoped_temp_dir.GetPath(), "second.csv", "6,7,8,9,0");

    std::vector<base::FilePath> file_paths({first_csv, second_csv});

    intent->mime_type = "text/csv";
    for (const base::FilePath& file_path : file_paths) {
      int64_t file_size = 0;
      base::GetFileSize(file_path, &file_size);
      auto file =
          std::make_unique<apps::IntentFile>(net::FilePathToFileURL(file_path));
      file->file_name = base::SafeBaseName::Create(file_path);
      file->file_size = file_size;
      file->mime_type = "text/csv";
      intent->files.push_back(std::move(file));
    }
  }

  content::WebContents* const web_contents =
      LaunchAppWithIntent(app_id, std::move(intent), share_target_url());
  EXPECT_EQ("1,2,3,4,5 6,7,8,9,0", ReadTextContent(web_contents, "records"));
}

#if BUILDFLAG(IS_CHROMEOS_ASH)
IN_PROC_BROWSER_TEST_F(WebShareTargetBrowserTest, ShareImageWithText) {
  ASSERT_TRUE(embedded_test_server()->Start());
  const GURL app_url =
      embedded_test_server()->GetURL("/web_share_target/charts.html");
  const webapps::AppId app_id =
      web_app::InstallWebAppFromManifest(browser(), app_url);
  const base::FilePath directory = PrepareWebShareDirectory(profile());

  apps::IntentPtr intent;
  {
    const base::FilePath first_svg =
        StoreSharedFile(directory, "first.svg", "picture");

    std::vector<base::FilePath> file_paths({first_svg});
    std::vector<std::string> content_types(1, "image/svg+xml");
    intent = apps_util::CreateShareIntentFromFiles(
        profile(), std::move(file_paths), std::move(content_types),
        /*share_text=*/"Euclid https://example.org/",
        /*share_title=*/"Elements");
  }

  content::WebContents* const web_contents =
      LaunchAppWithIntent(app_id, std::move(intent), share_target_url());
  EXPECT_EQ("picture", ReadTextContent(web_contents, "graphs"));

  EXPECT_EQ("Elements", ReadTextContent(web_contents, "headline"));
  EXPECT_EQ("Euclid", ReadTextContent(web_contents, "author"));
  EXPECT_EQ("https://example.org/", ReadTextContent(web_contents, "link"));
  EXPECT_EQ(NumRecentFiles(web_contents), 0U);

  RemoveWebShareDirectory(directory);
}

IN_PROC_BROWSER_TEST_F(WebShareTargetBrowserTest, ShareAudio) {
  ASSERT_TRUE(embedded_test_server()->Start());
  const GURL app_url =
      embedded_test_server()->GetURL("/web_share_target/charts.html");
  const webapps::AppId app_id =
      web_app::InstallWebAppFromManifest(browser(), app_url);
  const base::FilePath directory = PrepareWebShareDirectory(profile());

  apps::IntentPtr intent;
  {
    const base::FilePath first_weba =
        StoreSharedFile(directory, "first.weba", "a");
    const base::FilePath second_weba =
        StoreSharedFile(directory, "second.weba", "b");
    const base::FilePath third_weba =
        StoreSharedFile(directory, "third.weba", "c");

    std::vector<base::FilePath> file_paths(
        {first_weba, second_weba, third_weba});
    std::vector<std::string> content_types(3, "audio/webm");
    intent = apps_util::CreateShareIntentFromFiles(
        profile(), std::move(file_paths), std::move(content_types));
    intent->share_text = "";
    intent->share_title = "";
  }

  content::WebContents* const web_contents =
      LaunchAppWithIntent(app_id, std::move(intent), share_target_url());
  EXPECT_EQ("a b c", ReadTextContent(web_contents, "notes"));
  EXPECT_EQ(NumRecentFiles(web_contents), 0U);

  RemoveWebShareDirectory(directory);
}
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

IN_PROC_BROWSER_TEST_F(WebShareTargetBrowserTest, PostBlank) {
  ASSERT_TRUE(embedded_test_server()->Start());
  const GURL app_url =
      embedded_test_server()->GetURL("/web_share_target/poster.html");
  const webapps::AppId app_id =
      web_app::InstallWebAppFromManifest(browser(), app_url);

  apps::IntentPtr intent = apps_util::MakeShareIntent(
      /*text=*/std::string(),
      /*title=*/std::string());

  content::WebContents* const web_contents =
      LaunchAppWithIntent(app_id, std::move(intent), share_target_url());
  // Poster web app's service worker detects omitted values.
  EXPECT_EQ("N/A", ReadTextContent(web_contents, "headline"));
  EXPECT_EQ("N/A", ReadTextContent(web_contents, "author"));
  EXPECT_EQ("N/A", ReadTextContent(web_contents, "link"));
}

IN_PROC_BROWSER_TEST_F(WebShareTargetBrowserTest, PostLink) {
  ASSERT_TRUE(embedded_test_server()->Start());
  const GURL app_url =
      embedded_test_server()->GetURL("/web_share_target/poster.html");
  const webapps::AppId app_id =
      web_app::InstallWebAppFromManifest(browser(), app_url);
  const apps::ShareTarget* share_target =
      WebAppProvider::GetForTest(browser()->profile())
          ->registrar_unsafe()
          .GetAppShareTarget(app_id);
  EXPECT_EQ(share_target->method, apps::ShareTarget::Method::kPost);
  EXPECT_EQ(share_target->enctype, apps::ShareTarget::Enctype::kFormUrlEncoded);

  const std::string shared_title = "Hyperlink";
  const std::string shared_link = "https://example.org/a?b=c&d=e%20#f";

  apps::IntentPtr intent = apps_util::MakeShareIntent(
      /*text=*/shared_link,
      /*title=*/shared_title);

  content::WebContents* const web_contents =
      LaunchAppWithIntent(app_id, std::move(intent), share_target_url());
  EXPECT_EQ("POST", ReadTextContent(web_contents, "method"));
  EXPECT_EQ("application/x-www-form-urlencoded",
            ReadTextContent(web_contents, "type"));

  EXPECT_EQ(shared_title, ReadTextContent(web_contents, "headline"));
  // Poster web app's service worker detects omitted value.
  EXPECT_EQ("N/A", ReadTextContent(web_contents, "author"));
  EXPECT_EQ(shared_link, ReadTextContent(web_contents, "link"));
}

IN_PROC_BROWSER_TEST_F(WebShareTargetBrowserTest, GetLink) {
  ASSERT_TRUE(embedded_test_server()->Start());
  const GURL app_url =
      embedded_test_server()->GetURL("/web_share_target/gatherer.html");
  const webapps::AppId app_id =
      web_app::InstallWebAppFromManifest(browser(), app_url);
  const apps::ShareTarget* share_target =
      WebAppProvider::GetForTest(browser()->profile())
          ->registrar_unsafe()
          .GetAppShareTarget(app_id);
  EXPECT_EQ(share_target->method, apps::ShareTarget::Method::kGet);
  EXPECT_EQ(share_target->enctype, apps::ShareTarget::Enctype::kFormUrlEncoded);

  const std::string shared_title = "My News";
  const std::string shared_link = "http://example.com/news";
  const GURL expected_url(share_target_url().spec() +
                          "?headline=My+News&link=http://example.com/news");

  apps::IntentPtr intent = apps_util::MakeShareIntent(
      /*text=*/shared_link,
      /*title=*/shared_title);

  content::WebContents* const web_contents =
      LaunchAppWithIntent(app_id, std::move(intent), expected_url);
  EXPECT_EQ("GET", ReadTextContent(web_contents, "method"));
  EXPECT_EQ(expected_url.spec(), ReadTextContent(web_contents, "url"));

  EXPECT_EQ(shared_title, ReadTextContent(web_contents, "headline"));
  // Gatherer web app's service worker detects omitted value.
  EXPECT_EQ("N/A", ReadTextContent(web_contents, "author"));
  EXPECT_EQ(shared_link, ReadTextContent(web_contents, "link"));
}

}  // namespace web_app