From ff63a384f42b03168ebbd2aab9ac303ff48eee88 Mon Sep 17 00:00:00 2001 From: "Jorge E. Moreira" Date: Thu, 3 Sep 2026 14:51:07 -0700 Subject: [PATCH 1/4] Add ReadExactOrEof and ReadExactBinaryOrEof --- base/cvd/cuttlefish/io/read_exact.cc | 13 +++++++++++++ base/cvd/cuttlefish/io/read_exact.h | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/base/cvd/cuttlefish/io/read_exact.cc b/base/cvd/cuttlefish/io/read_exact.cc index 82092a9c4e5..1e329159200 100644 --- a/base/cvd/cuttlefish/io/read_exact.cc +++ b/base/cvd/cuttlefish/io/read_exact.cc @@ -46,4 +46,17 @@ Result PReadExact(const ReaderSeeker& reader, char* buf, size_t size, return {}; } +Result ReadExactOrEof(Reader& reader, char* buf, size_t size) { + size_t read = 0; + while (read < size) { + size_t data_read = CF_EXPECT(reader.Read((void*)(buf + read), size - read)); + if (data_read == 0 && read == 0) { + return false; + } + CF_EXPECT_GT(data_read, 0, "EOF, still want to read " << size - read); + read += data_read; + } + return true; +} + } // namespace cuttlefish diff --git a/base/cvd/cuttlefish/io/read_exact.h b/base/cvd/cuttlefish/io/read_exact.h index fb78e563b78..7561f568eed 100644 --- a/base/cvd/cuttlefish/io/read_exact.h +++ b/base/cvd/cuttlefish/io/read_exact.h @@ -17,6 +17,8 @@ #include +#include + #include "cuttlefish/io/io.h" #include "cuttlefish/result/expect.h" #include "cuttlefish/result/result_type.h" @@ -36,6 +38,10 @@ Result ReadExactBinary(Reader& reader) { Result PReadExact(const ReaderSeeker&, char* buf, size_t size, uint64_t offset); +// Similar to ReadExact, but returns false instead of an error if zero bytes are +// read because of EOF. A partial read is still an error. +Result ReadExactOrEof(Reader&, char* buf, size_t size); + template Result PReadExactBinary(const ReaderSeeker& reader, uint64_t offset) { T data; @@ -44,4 +50,14 @@ Result PReadExactBinary(const ReaderSeeker& reader, uint64_t offset) { return data; } +template +Result> ReadExactBinaryOrEof(Reader& reader) { + T data; + char* data_char = reinterpret_cast(&data); + if (!CF_EXPECT(ReadExactOrEof(reader, data_char, sizeof(data)))) { + return std::nullopt; + } + return data; +} + } // namespace cuttlefish From 7018cc39f9265d867e31cd3b4f179fff9446a71f Mon Sep 17 00:00:00 2001 From: "Jorge E. Moreira" Date: Mon, 31 Aug 2026 17:29:22 -0700 Subject: [PATCH 2/4] cvd: Add cvd event_devices subcommand The new subcommand allows listing input devices as well as capturing and injecting input events. Bug: b/554190370 --- .../host/commands/cvd/cli/BUILD.bazel | 1 + .../commands/cvd/cli/commands/BUILD.bazel | 22 ++ .../cvd/cli/commands/event_devices.cpp | 269 ++++++++++++++++++ .../commands/cvd/cli/commands/event_devices.h | 57 ++++ .../host/commands/cvd/cli/request_context.cpp | 3 + .../host/commands/cvd/instances/BUILD.bazel | 1 + .../commands/cvd/instances/local_instance.cpp | 60 ++++ .../commands/cvd/instances/local_instance.h | 9 + 8 files changed, 422 insertions(+) create mode 100644 base/cvd/cuttlefish/host/commands/cvd/cli/commands/event_devices.cpp create mode 100644 base/cvd/cuttlefish/host/commands/cvd/cli/commands/event_devices.h diff --git a/base/cvd/cuttlefish/host/commands/cvd/cli/BUILD.bazel b/base/cvd/cuttlefish/host/commands/cvd/cli/BUILD.bazel index 40d79f9f811..576292db46e 100644 --- a/base/cvd/cuttlefish/host/commands/cvd/cli/BUILD.bazel +++ b/base/cvd/cuttlefish/host/commands/cvd/cli/BUILD.bazel @@ -133,6 +133,7 @@ cf_cc_library( "//cuttlefish/host/commands/cvd/cli/commands:command_handler", "//cuttlefish/host/commands/cvd/cli/commands:display", "//cuttlefish/host/commands/cvd/cli/commands:env", + "//cuttlefish/host/commands/cvd/cli/commands:event_devices", "//cuttlefish/host/commands/cvd/cli/commands:fetch", "//cuttlefish/host/commands/cvd/cli/commands:fleet", "//cuttlefish/host/commands/cvd/cli/commands:host_tool_target", diff --git a/base/cvd/cuttlefish/host/commands/cvd/cli/commands/BUILD.bazel b/base/cvd/cuttlefish/host/commands/cvd/cli/commands/BUILD.bazel index 7286fe902a1..3ace1b6295a 100644 --- a/base/cvd/cuttlefish/host/commands/cvd/cli/commands/BUILD.bazel +++ b/base/cvd/cuttlefish/host/commands/cvd/cli/commands/BUILD.bazel @@ -190,6 +190,28 @@ cf_cc_library( ], ) +cf_cc_library( + name = "event_devices", + srcs = ["event_devices.cpp"], + hdrs = ["event_devices.h"], + deps = [ + "//cuttlefish/common/libs/fs:fd", + "//cuttlefish/flag_parser", + "//cuttlefish/host/commands/cvd/cli:command_request", + "//cuttlefish/host/commands/cvd/cli:help_format", + "//cuttlefish/host/commands/cvd/cli/commands:command_handler", + "//cuttlefish/host/commands/cvd/cli/selector", + "//cuttlefish/host/commands/cvd/instances", + "//cuttlefish/host/commands/cvd/instances:instance_manager", + "//cuttlefish/io", + "//cuttlefish/io:read_exact", + "//cuttlefish/io:write_exact", + "//cuttlefish/result", + "@abseil-cpp//absl/log", + "@abseil-cpp//absl/strings", + ], +) + cf_cc_library( name = "lint", srcs = ["lint.cpp"], diff --git a/base/cvd/cuttlefish/host/commands/cvd/cli/commands/event_devices.cpp b/base/cvd/cuttlefish/host/commands/cvd/cli/commands/event_devices.cpp new file mode 100644 index 00000000000..2a18351c7dc --- /dev/null +++ b/base/cvd/cuttlefish/host/commands/cvd/cli/commands/event_devices.cpp @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "cuttlefish/host/commands/cvd/cli/commands/event_devices.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "absl/log/log.h" +#include "absl/strings/str_join.h" + +#include "cuttlefish/common/libs/fs/fd.h" +#include "cuttlefish/flag_parser/flag.h" +#include "cuttlefish/flag_parser/gflags_compat.h" +#include "cuttlefish/host/commands/cvd/cli/command_request.h" +#include "cuttlefish/host/commands/cvd/cli/help_format.h" +#include "cuttlefish/host/commands/cvd/cli/selector/selector.h" +#include "cuttlefish/host/commands/cvd/instances/instance_manager.h" +#include "cuttlefish/host/commands/cvd/instances/local_instance.h" +#include "cuttlefish/io/io.h" +#include "cuttlefish/io/read_exact.h" +#include "cuttlefish/io/write_exact.h" +#include "cuttlefish/result/result.h" + +namespace cuttlefish { + +namespace { +const char* kListCommand = "list"; +const char* kLsAlias = "ls"; +const char* kCaptureCommand = "capture"; +const char* kInjectCommand = "inject"; + +struct __attribute__((packed)) Event { + // The virtio_input_event structure actually contains 2 16 bits integers and + // one 32 bits one, in little endian representation. However the only + // operation on these events is going to be comparing the first two integers + // with zero, for which endianness doesn't matter and can be done in a single + // comparisson using 32 bits. + uint32_t ev_type; + uint32_t value; +}; +static_assert(sizeof(Event) == 8, + "Event structure doesn't match virtio_input_event size"); + +Result SaveEventsToFile(Reader& conn, Writer& out) { + std::chrono::steady_clock::time_point start = + std::chrono::steady_clock::now(); + std::vector events; + for (;;) { + Event event = CF_EXPECT(ReadExactBinary(conn), + "Failed to read complete event"); + events.push_back(event); + if (event.ev_type != 0) { + continue; + } + uint64_t timestamp = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + CF_EXPECT(WriteExactBinary(out, timestamp), + "Unable to write timestamp to file"); + CF_EXPECT(WriteExact(out, reinterpret_cast(events.data()), + events.size() * sizeof(Event)), + "Unable to write events to file"); + events.clear(); + // The loop never ends. The command stops when the user interrupts it with + // CTRL-C. + } + return {}; +} + +Result InjectEventsFromFile(Writer& conn, Reader& in) { + std::chrono::steady_clock::time_point start = + std::chrono::steady_clock::now(); + for (;;) { + std::optional delay = + CF_EXPECT(ReadExactBinaryOrEof(in)); + if (!delay) { + break; + } + std::this_thread::sleep_until(start + std::chrono::milliseconds(*delay)); + Event event; + do { + event = CF_EXPECT(ReadExactBinary(in)); + CF_EXPECT(WriteExactBinary(conn, event)); + } while (event.ev_type != 0); + } + return {}; +} + +} // namespace + +CvdInputDevicesHandler::CvdInputDevicesHandler( + InstanceManager& instance_manager) + : instance_manager_(instance_manager) {} + +std::vector CvdInputDevicesHandler::CmdList() const { + return {"event_devices"}; +} + +std::string CvdInputDevicesHandler::SummaryHelp() const { + return "List, inject events to or capture events from input devices"; +} + +std::vector CvdInputDevicesHandler::Description() const { + return { + HelpParagraph("Usage"), + + HelpParagraph::Raw( + R"( cvd [SELECTOR_ARGS] event_devices list + cvd [SELECTOR_ARGS] event_devices capture --device=DEVICE_NAME EVENTS_FILE) + cvd [SELECTOR_ARGS] event_devices inject --device=DEVICE_NAME EVENTS_FILE)"), + + HelpParagraph("The `list` subcommand accepts no flags and can target " + "multiple instances and groups. For each selected instance " + "it prints the instance's group and name, followed by a " + "space separated list of input device names. For example:"), + + HelpParagraph::Raw( + R"( cvd-1/instance1: keyboard touch_0 + cvd-1/instance2: keyboard touch_0)"), + + HelpParagraph( + "The `capture` and `inject` subcommands require an events file and " + "the device name. These subcommands arget a single instance. The " + "events file is in binary should not be edited directly. Event " + "files should always be created via the `capture` subcommand."), + + HelpParagraph("Events capture ends when the process is interrupted, " + "typically by pressing CTRL+C in the terminal."), + + HelpParagraph( + "When the special name \"-\" is given as the events file, events " + "will be written to/read from standard output/input."), + }; +} + +Result> CvdInputDevicesHandler::Flags( + const CommandRequest& request) { + return std::vector{ + GflagsCompatFlag("device_name", flags_.device_name) + .ValueNameHint("DEVICE") + .Alias("d") + .Help("Name of input device to capture events from/inject events to. " + "Valid only with `capture` and `inject`. Available device " + "names can be obtained with the `list` subcommand."), + }; +} + +Result CvdInputDevicesHandler::Handle(const CommandRequest& request) { + std::vector args = request.SubcommandArguments(); + std::string cmd = kListCommand; + if (!args.empty()) { + cmd = std::move(args[0]); + args.erase(args.begin()); + } + if (cmd == kListCommand || cmd == kLsAlias) { + CF_EXPECT(ListDevices(request, std::move(args))); + } else if (cmd == kCaptureCommand) { + CF_EXPECT(CaptureEvents(request, std::move(args))); + } else if (cmd == kInjectCommand) { + CF_EXPECT(InjectEvents(request, std::move(args))); + } else { + return CF_ERRF( + "Unknown event_devices subcommand: `{}`. Must be one of `ls`, " + "`inject` or `capture`.", + cmd); + } + return {}; +} + +Result CvdInputDevicesHandler::ListDevices( + const CommandRequest& request, std::vector args) { + CF_EXPECTF(args.empty(), "Unknown arguments to `event_devices ls`: {}", + absl::StrJoin(args, " ")); + const std::vector>> + found_instances = + CF_EXPECT(selector::SelectInstances(instance_manager_, request)); + + if (found_instances.empty()) { + LOG(INFO) << "No devices found"; + return {}; + } + + for (const auto& [group, instances] : found_instances) { + for (const LocalInstance& instance : instances) { + std::cout << group.GroupName() << "/" << instance.Name() << ": " + << absl::StrJoin(CF_EXPECT(instance.InputDevices()), " ") + << std::endl; + } + } + + return {}; +} + +Result CvdInputDevicesHandler::CaptureEvents( + const CommandRequest& request, std::vector args) { + std::vector flags = CF_EXPECT(Flags(request)); + CF_EXPECT(ConsumeFlags(flags, args)); + CF_EXPECTF(args.size() < 2, + "Too many standalone arguments provided ({}), expected " + "`EVENTS_FILE` only", + absl::StrJoin(args, " ")); + CF_EXPECT_EQ(args.size(), 1, "Missing events file name"); + CF_EXPECT(!flags_.device_name.empty(), + "A device name is required to capture events"); + if (args[0] == "-") { + args[0] = "/proc/self/fd/1"; + } + const auto [instance, group] = + CF_EXPECT(selector::SelectInstance(instance_manager_, request), + "Unable to select an instance"); + Fd conn = + CF_EXPECT(instance.NewCaptureInputDeviceEventsConn(flags_.device_name)); + Fd out = CF_EXPECT(Fd::Creat(args[0], 0600), "Unable to create events file"); + std::cerr << "Capturing events from '" << flags_.device_name + << "'. Press CTRL+C to stop" << std::endl; + CF_EXPECT(SaveEventsToFile(conn, out)); + return {}; +} + +Result CvdInputDevicesHandler::InjectEvents( + const CommandRequest& request, std::vector args) { + std::vector flags = CF_EXPECT(Flags(request)); + CF_EXPECT(ConsumeFlags(flags, args)); + CF_EXPECTF(args.size() < 2, + "Too many standalone arguments provided ({}), expected " + "`EVENTS_FILE` only", + absl::StrJoin(args, " ")); + CF_EXPECT_EQ(args.size(), 1, "Missing events file name"); + CF_EXPECT(!flags_.device_name.empty(), + "A device name is required to inject events"); + if (args[0] == "-") { + args[0] = "/proc/self/fd/0"; + } + const auto [instance, _] = + CF_EXPECT(selector::SelectInstance(instance_manager_, request), + "Unable to select an instance"); + Fd conn = + CF_EXPECT(instance.NewInjectInputDeviceEventsConn(flags_.device_name)); + Fd in = + CF_EXPECT(Fd::Open(args[0], O_RDONLY), "Unable to create events file"); + std::cerr << "Injecting events to '" << flags_.device_name << "'." + << std::endl; + CF_EXPECT(InjectEventsFromFile(conn, in)); + return {}; +} +} // namespace cuttlefish diff --git a/base/cvd/cuttlefish/host/commands/cvd/cli/commands/event_devices.h b/base/cvd/cuttlefish/host/commands/cvd/cli/commands/event_devices.h new file mode 100644 index 00000000000..b90e40dcc1e --- /dev/null +++ b/base/cvd/cuttlefish/host/commands/cvd/cli/commands/event_devices.h @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "cuttlefish/flag_parser/flag.h" +#include "cuttlefish/host/commands/cvd/cli/command_request.h" +#include "cuttlefish/host/commands/cvd/cli/commands/command_handler.h" +#include "cuttlefish/host/commands/cvd/cli/help_format.h" +#include "cuttlefish/host/commands/cvd/instances/instance_manager.h" +#include "cuttlefish/result/result.h" + +namespace cuttlefish { + +class CvdInputDevicesHandler : public CvdCommandHandler { + public: + CvdInputDevicesHandler(InstanceManager& instance_manager); + + Result Handle(const CommandRequest& request) override; + std::vector CmdList() const override; + + bool RequiresDeviceExists() const override { return true; } + std::string SummaryHelp() const override; + std::vector Description() const override; + Result> Flags(const CommandRequest& request) override; + + private: + Result ListDevices(const CommandRequest& request, + std::vector args); + Result CaptureEvents(const CommandRequest& request, + std::vector args); + Result InjectEvents(const CommandRequest& request, + std::vector args); + + InstanceManager& instance_manager_; + struct { + std::string device_name; + } flags_; +}; + +} // namespace cuttlefish diff --git a/base/cvd/cuttlefish/host/commands/cvd/cli/request_context.cpp b/base/cvd/cuttlefish/host/commands/cvd/cli/request_context.cpp index a507901f056..ae98f203c18 100644 --- a/base/cvd/cuttlefish/host/commands/cvd/cli/request_context.cpp +++ b/base/cvd/cuttlefish/host/commands/cvd/cli/request_context.cpp @@ -34,6 +34,7 @@ #include "cuttlefish/host/commands/cvd/cli/commands/create.h" #include "cuttlefish/host/commands/cvd/cli/commands/display.h" #include "cuttlefish/host/commands/cvd/cli/commands/env.h" +#include "cuttlefish/host/commands/cvd/cli/commands/event_devices.h" #include "cuttlefish/host/commands/cvd/cli/commands/fetch.h" #include "cuttlefish/host/commands/cvd/cli/commands/fleet.h" #include "cuttlefish/host/commands/cvd/cli/commands/help.h" @@ -149,6 +150,8 @@ RequestContext::RequestContext(InstanceManager& instance_manager, std::make_unique(instance_manager)); request_handlers_.emplace_back( std::make_unique(this->request_handlers_)); + request_handlers_.emplace_back( + std::make_unique(instance_manager)); request_handlers_.emplace_back(std::make_unique()); request_handlers_.emplace_back( std::make_unique(instance_manager)); diff --git a/base/cvd/cuttlefish/host/commands/cvd/instances/BUILD.bazel b/base/cvd/cuttlefish/host/commands/cvd/instances/BUILD.bazel index a56ff1e05c7..89003830768 100644 --- a/base/cvd/cuttlefish/host/commands/cvd/instances/BUILD.bazel +++ b/base/cvd/cuttlefish/host/commands/cvd/instances/BUILD.bazel @@ -113,6 +113,7 @@ cf_cc_library( deps = [ ":cvd_persistent_data", "//cuttlefish/common/libs/fs", + "//cuttlefish/common/libs/fs:fd", "//cuttlefish/common/libs/utils:contains", "//cuttlefish/common/libs/utils:files", "//cuttlefish/common/libs/utils:gflags_xml_parser", diff --git a/base/cvd/cuttlefish/host/commands/cvd/instances/local_instance.cpp b/base/cvd/cuttlefish/host/commands/cvd/instances/local_instance.cpp index 1686f05d09c..09d35947445 100644 --- a/base/cvd/cuttlefish/host/commands/cvd/instances/local_instance.cpp +++ b/base/cvd/cuttlefish/host/commands/cvd/instances/local_instance.cpp @@ -21,10 +21,14 @@ #include #include +#include #include +#include #include #include "absl/log/log.h" +#include "absl/strings/str_join.h" +#include "absl/strings/strip.h" #include "cuttlefish/common/libs/utils/files.h" #include "cuttlefish/files/directory_contents.h" @@ -255,6 +259,19 @@ LocalInstance::GetInstanceConfig() { return config->ForInstance(Id()); } +Result LocalInstance::UDSDirectory() const { + // Newer cuttlefish instances put launcher monitor socket in a directory + // under /tmp, and store this path in the config. Older instances just put + // them in the instance directory. + Json::Value config = CF_EXPECT(ReadJsonConfig()); + if (config.isMember("instances_uds_dir") && + config["instances_uds_dir"].isString()) { + return fmt::format("{}/cvd-{}", config["instances_uds_dir"].asString(), + Id()); + } + return InstanceDirectory(); +} + Result LocalInstance::GetLauncherMonitor( std::chrono::seconds timeout) const { // Newer cuttlefish instances put launcher monitor socket in a directory @@ -296,4 +313,47 @@ Result> LocalInstance::LogsFilenames() const { return result; } +Result> LocalInstance::InputDevices() const { + std::string uds_dir = + CF_EXPECT(UDSDirectory(), "Unable to get instance's UDS directory") + + "/internal"; + std::vector files = CF_EXPECT(DirectoryContents(uds_dir)); + std::set event_devices; + for (std::string_view file : files) { + if (absl::ConsumeSuffix(&file, ".events.in") || + absl::ConsumeSuffix(&file, ".events.out")) { + event_devices.emplace(file); + } + } + return event_devices; +} + +Result LocalInstance::NewCaptureInputDeviceEventsConn( + const std::string& device_name) const { + std::set devices = CF_EXPECT(InputDevices()); + CF_EXPECTF(devices.find(device_name) != devices.end(), + "'{}' not found among existing input devices ({})", device_name, + absl::StrJoin(devices, ", ")); + std::string uds_dir = + CF_EXPECT(UDSDirectory(), "Unable to get instance's UDS directory"); + std::string socket = + fmt::format("{}/internal/{}.events.out", uds_dir, device_name); + return CF_EXPECTF(Fd::SocketLocalClient(socket, false, SOCK_STREAM), + "Unable to connect to {}'s capture socket", device_name); +} + +Result LocalInstance::NewInjectInputDeviceEventsConn( + const std::string& device_name) const { + std::set devices = CF_EXPECT(InputDevices()); + CF_EXPECTF(devices.find(device_name) != devices.end(), + "'{}' not found among existing input devices ({})", device_name, + absl::StrJoin(devices, ", ")); + std::string uds_dir = + CF_EXPECT(UDSDirectory(), "Unable to get instance's UDS directory"); + std::string socket = + fmt::format("{}/internal/{}.events.in", uds_dir, device_name); + return CF_EXPECTF(Fd::SocketLocalClient(socket, false, SOCK_STREAM), + "Unable to connect to {}'s inject socket", device_name); +} + } // namespace cuttlefish diff --git a/base/cvd/cuttlefish/host/commands/cvd/instances/local_instance.h b/base/cvd/cuttlefish/host/commands/cvd/instances/local_instance.h index 87c75991c3e..da97d991761 100644 --- a/base/cvd/cuttlefish/host/commands/cvd/instances/local_instance.h +++ b/base/cvd/cuttlefish/host/commands/cvd/instances/local_instance.h @@ -20,10 +20,12 @@ #include #include +#include #include #include #include +#include "cuttlefish/common/libs/fs/fd.h" #include "cuttlefish/common/libs/fs/shared_fd.h" #include "cuttlefish/host/commands/cvd/instances/cvd_persistent_data.pb.h" #include "cuttlefish/host/libs/config/cuttlefish_config.h" @@ -76,6 +78,12 @@ class LocalInstance { // Return list of filenames of instance-level log files. Result> LogsFilenames() const; + // List names of the instance's input devices. + Result> InputDevices() const; + Result NewCaptureInputDeviceEventsConn( + const std::string& device_name) const; + Result NewInjectInputDeviceEventsConn( + const std::string& device_name) const; private: LocalInstance(std::shared_ptr group_proto, @@ -86,6 +94,7 @@ class LocalInstance { Result ReadJsonConfig() const; Result LoadConfig(); Result GetInstanceConfig(); + Result UDSDirectory() const; // Sharing ownership of the group proto ensures the instance proto reference // doesn't invalidate. From 7471277724e7224d20dc86f2ae3fc914b8e09399 Mon Sep 17 00:00:00 2001 From: "Jorge E. Moreira" Date: Thu, 3 Sep 2026 18:08:23 -0700 Subject: [PATCH 3/4] frontend: Add HO endpoints to interact with event devices Bug: b/554190370 --- .../src/host_orchestrator/api/v1/messages.go | 4 + .../orchestrator/BUILD.bazel | 4 + .../orchestrator/controller.go | 105 +++++++++++ .../orchestrator/controller_test.go | 131 ++++++++++++++ .../host_orchestrator/orchestrator/cvd/cvd.go | 33 ++++ .../orchestrator/cvd/cvd_test.go | 108 ++++++++++++ .../injectinputdeviceeventsaction.go | 73 ++++++++ .../injectinputdeviceeventsaction_test.go | 164 ++++++++++++++++++ .../orchestrator/listinputdevicesaction.go | 57 ++++++ .../listinputdevicesaction_test.go | 103 +++++++++++ 10 files changed, 782 insertions(+) create mode 100644 frontend/src/host_orchestrator/orchestrator/injectinputdeviceeventsaction.go create mode 100644 frontend/src/host_orchestrator/orchestrator/injectinputdeviceeventsaction_test.go create mode 100644 frontend/src/host_orchestrator/orchestrator/listinputdevicesaction.go create mode 100644 frontend/src/host_orchestrator/orchestrator/listinputdevicesaction_test.go diff --git a/frontend/src/host_orchestrator/api/v1/messages.go b/frontend/src/host_orchestrator/api/v1/messages.go index a550d6f6b3c..ebe53bb54b0 100644 --- a/frontend/src/host_orchestrator/api/v1/messages.go +++ b/frontend/src/host_orchestrator/api/v1/messages.go @@ -154,3 +154,7 @@ type DisplayScreenshotResponse struct { type ListScreenRecordingsResponse struct { ScreenRecordings []string `json:"screen_recordings"` } + +type ListInputDevicesResponse struct { + InputDevices []string `json:"event_devices"` +} diff --git a/frontend/src/host_orchestrator/orchestrator/BUILD.bazel b/frontend/src/host_orchestrator/orchestrator/BUILD.bazel index 7fb7dfcdfe1..b9b26f25944 100644 --- a/frontend/src/host_orchestrator/orchestrator/BUILD.bazel +++ b/frontend/src/host_orchestrator/orchestrator/BUILD.bazel @@ -14,8 +14,10 @@ go_library( "execcvdcommandaction.go", "getscreenrecordingaction.go", "imagedirectories.go", + "injectinputdeviceeventsaction.go", "instancemanager.go", "listcvdsaction.go", + "listinputdevicesaction.go", "listscreenrecordingsaction.go", "operation.go", "resetcvdaction.go", @@ -43,7 +45,9 @@ go_test( srcs = [ "controller_test.go", "imagedirectories_test.go", + "injectinputdeviceeventsaction_test.go", "listcvdsaction_test.go", + "listinputdevicesaction_test.go", "operation_test.go", "userartifacts_test.go", "validation_test.go", diff --git a/frontend/src/host_orchestrator/orchestrator/controller.go b/frontend/src/host_orchestrator/orchestrator/controller.go index e4d227bc561..49c6505a469 100644 --- a/frontend/src/host_orchestrator/orchestrator/controller.go +++ b/frontend/src/host_orchestrator/orchestrator/controller.go @@ -17,13 +17,16 @@ package orchestrator import ( "encoding/json" "fmt" + "io" "log" + "mime" "mime/multipart" "net/http" "os" "os/exec" "path/filepath" "strconv" + "strings" "time" apiv1 "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/api/v1" @@ -105,6 +108,12 @@ func (c *Controller) AddRoutes(router *mux.Router) { &getScreenRecordingHandler{Config: c.Config}).Methods("GET") router.Handle("/cvds/{group}/{name}/screen_recordings", httpHandler(newListScreenRecordingsHandler(c.Config))).Methods("GET") + router.Handle("/cvds/{group}/{name}/event_devices", + httpHandler(newListInputDevicesHandler(c.Config))).Methods("GET") + router.Handle("/cvds/{group}/{name}/event_devices/{device_name:[^/:]+}:inject", + httpHandler(newInjectInputDeviceEventsHandler(c.Config, c.OperationManager))).Methods("POST") + router.Handle("/cvds/{group}/{name}/event_devices/{device_name}/:inject", + httpHandler(newInjectInputDeviceEventsHandler(c.Config, c.OperationManager))).Methods("POST") router.Handle("/cvds/{group}/{name}/snapshots", httpHandler(newCreateSnapshotHandler(c.Config, c.OperationManager))).Methods("POST") router.Handle("/operations", httpHandler(&listOperationsHandler{om: c.OperationManager})).Methods("GET") @@ -366,6 +375,102 @@ func (h *listScreenRecordingsHandler) Handle(r *http.Request) (interface{}, erro return NewListScreenRecordingsAction(opts).Run() } +type listInputDevicesHandler struct { + Config Config +} + +func newListInputDevicesHandler(c Config) *listInputDevicesHandler { + return &listInputDevicesHandler{Config: c} +} + +func (h *listInputDevicesHandler) Handle(r *http.Request) (interface{}, error) { + vars := mux.Vars(r) + group := vars["group"] + name := vars["name"] + opts := ListInputDevicesActionOpts{ + Selector: cvd.InstanceSelector{GroupName: group, Name: name}, + ExecContext: exec.CommandContext, + } + return NewListInputDevicesAction(opts).Run() +} + +type injectInputDeviceEventsHandler struct { + Config Config + OM OperationManager +} + +func newInjectInputDeviceEventsHandler(c Config, om OperationManager) *injectInputDeviceEventsHandler { + return &injectInputDeviceEventsHandler{Config: c, OM: om} +} + +func (h *injectInputDeviceEventsHandler) Handle(r *http.Request) (interface{}, error) { + vars := mux.Vars(r) + group := vars["group"] + name := vars["name"] + deviceName := vars["device_name"] + deviceName = strings.TrimSuffix(deviceName, "/") + + tempFile, err := saveTempEventsFile(r) + if err != nil { + return nil, err + } + + opts := InjectInputDeviceEventsActionOpts{ + Selector: cvd.InstanceSelector{GroupName: group, Name: name}, + DeviceName: deviceName, + EventsFilePath: tempFile, + OperationManager: h.OM, + ExecContext: exec.CommandContext, + } + res, err := NewInjectInputDeviceEventsAction(opts).Run() + if err != nil { + os.Remove(tempFile) + return nil, err + } + return res, nil +} + +func saveTempEventsFile(r *http.Request) (string, error) { + if r.Body == nil { + return "", operator.NewBadRequestError("empty request body", nil) + } + + mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + if mediaType != "multipart/form-data" { + return "", operator.NewBadRequestError("Content-Type must be multipart/form-data", nil) + } + + if err := r.ParseMultipartForm(1 << 14 /*16KB*/); err != nil { + return "", operator.NewBadRequestError("Invalid multipart form request", err) + } + if r.MultipartForm != nil { + defer r.MultipartForm.RemoveAll() + } + + file, fHeader, err := r.FormFile("file") + if err != nil { + return "", operator.NewBadRequestError("missing events file in multipart form request", nil) + } + defer file.Close() + + if fHeader.Size == 0 { + return "", operator.NewBadRequestError("empty events file in request", nil) + } + + tempFile, err := os.CreateTemp("", "cvd_events_*.bin") + if err != nil { + return "", operator.NewInternalError("failed to create temporary events file", err) + } + defer tempFile.Close() + + if _, err := io.Copy(tempFile, file); err != nil { + os.Remove(tempFile.Name()) + return "", operator.NewInternalError("failed to write temporary events file", err) + } + + return tempFile.Name(), nil +} + type getScreenRecordingHandler struct { Config Config } diff --git a/frontend/src/host_orchestrator/orchestrator/controller_test.go b/frontend/src/host_orchestrator/orchestrator/controller_test.go index 62ba25b475e..aa419bcc79f 100644 --- a/frontend/src/host_orchestrator/orchestrator/controller_test.go +++ b/frontend/src/host_orchestrator/orchestrator/controller_test.go @@ -21,6 +21,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" @@ -81,6 +82,136 @@ func TestGetCVDIsHandled(t *testing.T) { } } +func TestListInputDevicesIsHandled(t *testing.T) { + for _, path := range []string{"/cvds/foo/bar/event_devices", "/cvds/foo/bar/event_devices"} { + rr := httptest.NewRecorder() + req, err := http.NewRequest("GET", path, nil) + if err != nil { + t.Fatal(err) + } + controller := Controller{} + + makeRequest(rr, req, &controller) + + if rr.Code == http.StatusNotFound && rr.Body.String() == pageNotFoundErrMsg { + t.Errorf("request for path %q was not handled. This failure implies an API breaking change.", path) + } + } +} + +func createMultipartEventsRequest(t *testing.T, method, path string, fileFieldName, fileName string, content []byte) *http.Request { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if fileFieldName != "" { + fw, err := writer.CreateFormFile(fileFieldName, fileName) + if err != nil { + t.Fatal(err) + } + if _, err := io.Copy(fw, bytes.NewReader(content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(method, path, body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + +func TestInjectInputDeviceEventsIsHandled(t *testing.T) { + paths := []string{ + "/cvds/foo/bar/event_devices/mouse:inject", + "/cvds/foo/bar/event_devices/mouse/:inject", + } + for _, path := range paths { + rr := httptest.NewRecorder() + req := createMultipartEventsRequest(t, "POST", path, "file", "events.bin", []byte("fake binary event data")) + controller := Controller{OperationManager: NewMapOM()} + + makeRequest(rr, req, &controller) + + if rr.Code == http.StatusNotFound && rr.Body.String() == pageNotFoundErrMsg { + t.Errorf("request for path %q was not handled. This failure implies an API breaking change.", path) + } + if rr.Code != http.StatusOK { + t.Errorf("expected status 200 for path %q, got %d: %s", path, rr.Code, rr.Body.String()) + } + var op apiv1.Operation + if err := json.Unmarshal(rr.Body.Bytes(), &op); err != nil { + t.Fatalf("failed to decode response as Operation: %v", err) + } + if op.Name == "" { + t.Errorf("expected non-empty operation name in response") + } + } +} + +func TestInjectInputDeviceEventsNonMultipartFails(t *testing.T) { + rr := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/cvds/foo/bar/event_devices/mouse:inject", strings.NewReader("raw binary")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/octet-stream") + controller := Controller{OperationManager: NewMapOM()} + + makeRequest(rr, req, &controller) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status 400 for non-multipart request, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestSaveTempEventsFileNoExecutionPermissions(t *testing.T) { + req := createMultipartEventsRequest(t, "POST", "/dummy", "file", "events.bin", []byte("binary content")) + filePath, err := saveTempEventsFile(req) + if err != nil { + t.Fatalf("saveTempEventsFile failed: %v", err) + } + defer os.Remove(filePath) + + fi, err := os.Stat(filePath) + if err != nil { + t.Fatalf("failed to stat temp file: %v", err) + } + if fi.Mode().Perm()&0111 != 0 { + t.Errorf("file has execution permissions: %v", fi.Mode().Perm()) + } +} + +func TestSaveTempEventsFileNonMultipartFails(t *testing.T) { + req, err := http.NewRequest("POST", "/dummy", strings.NewReader("binary content")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/octet-stream") + _, err = saveTempEventsFile(req) + if err == nil { + t.Fatal("expected error for non-multipart request, got nil") + } +} + +func TestSaveTempEventsFileEmptyFileFails(t *testing.T) { + req := createMultipartEventsRequest(t, "POST", "/dummy", "file", "events.bin", []byte("")) + _, err := saveTempEventsFile(req) + if err == nil { + t.Fatal("expected error for empty file in multipart request, got nil") + } +} + +func TestSaveTempEventsFileMissingFileFails(t *testing.T) { + req := createMultipartEventsRequest(t, "POST", "/dummy", "", "", nil) + _, err := saveTempEventsFile(req) + if err == nil { + t.Fatal("expected error for missing file in multipart request, got nil") + } +} + func TestGetOperationIsHandled(t *testing.T) { rr := httptest.NewRecorder() req, err := http.NewRequest("GET", "/operations/foo", nil) diff --git a/frontend/src/host_orchestrator/orchestrator/cvd/cvd.go b/frontend/src/host_orchestrator/orchestrator/cvd/cvd.go index 88b07755ee4..106ef2c0bd6 100644 --- a/frontend/src/host_orchestrator/orchestrator/cvd/cvd.go +++ b/frontend/src/host_orchestrator/orchestrator/cvd/cvd.go @@ -457,6 +457,39 @@ func (i *Instance) ListScreenRecordings() ([]string, error) { return parsedOutput[0].Recordings, nil } +func (i *Instance) ListInputDevices() ([]string, error) { + args := i.selectorArgs() + args = append(args, "event_devices", "list") + out, err := i.cli.exec(CVDBin, args...) + if err != nil { + return nil, err + } + return parseListInputDevicesOutput(string(out), i.GroupName, i.Name, args) +} + +func parseListInputDevicesOutput(out string, groupName, instanceName string, args []string) ([]string, error) { + target := fmt.Sprintf("%s/%s", strings.TrimSpace(groupName), strings.TrimSpace(instanceName)) + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) == 2 && strings.TrimSpace(parts[0]) == target { + devsStr := strings.TrimSpace(parts[1]) + if devsStr == "" { + return []string{}, nil + } + return strings.Fields(devsStr), nil + } + } + return nil, fmt.Errorf("failed to parse output of `%v`: %q", args, out) +} + +func (i *Instance) InjectInputDeviceEvents(deviceName, eventsFilePath string) error { + args := i.selectorArgs() + args = append(args, "event_devices", "inject", fmt.Sprintf("--device_name=%s", deviceName), eventsFilePath) + _, err := i.cli.exec(CVDBin, args...) + return err +} + func (i *Instance) selectorArgs() []string { return (&InstanceSelector{GroupName: i.GroupName, Name: i.Name}).asArgs() } diff --git a/frontend/src/host_orchestrator/orchestrator/cvd/cvd_test.go b/frontend/src/host_orchestrator/orchestrator/cvd/cvd_test.go index e6415116e08..f8652b0bb41 100644 --- a/frontend/src/host_orchestrator/orchestrator/cvd/cvd_test.go +++ b/frontend/src/host_orchestrator/orchestrator/cvd/cvd_test.go @@ -15,6 +15,8 @@ package cvd import ( + "context" + "os/exec" "testing" "github.com/google/go-cmp/cmp" @@ -42,5 +44,111 @@ func TestSliceItoa(t *testing.T) { t.Errorf("result mismatch (-want +got):\n%s", diff) } } +} + +func TestParseListInputDevicesOutput(t *testing.T) { + tests := []struct { + name string + out string + groupName string + instanceName string + want []string + wantErr bool + }{ + { + name: "multiple devices", + out: "foo/bar: dev1 dev2 dev3\n", + groupName: "foo", + instanceName: "bar", + want: []string{"dev1", "dev2", "dev3"}, + }, + { + name: "single device", + out: "foo/bar: keyboard\n", + groupName: "foo", + instanceName: "bar", + want: []string{"keyboard"}, + }, + { + name: "no devices", + out: "foo/bar:\n", + groupName: "foo", + instanceName: "bar", + want: []string{}, + }, + { + name: "no devices with trailing space", + out: "foo/bar: \n", + groupName: "foo", + instanceName: "bar", + want: []string{}, + }, + { + name: "extra spaces between tokens", + out: " foo/bar : dev1 dev2 \n", + groupName: "foo", + instanceName: "bar", + want: []string{"dev1", "dev2"}, + }, + { + name: "multi-line with logs and multiple instances", + out: "WARNING: some log\nother_group/other_inst: devX devY\nfoo/bar: dev1 dev2\nINFO: finished\n", + groupName: "foo", + instanceName: "bar", + want: []string{"dev1", "dev2"}, + }, + { + name: "instance not found", + out: "other/other: dev1\n", + groupName: "foo", + instanceName: "bar", + wantErr: true, + }, + { + name: "empty output", + out: "", + groupName: "foo", + instanceName: "bar", + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseListInputDevicesOutput(tc.out, tc.groupName, tc.instanceName, []string{"dummy"}) + if (err != nil) != tc.wantErr { + t.Fatalf("wantErr %v, got %v", tc.wantErr, err) + } + if tc.wantErr { + return + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } + }) + } +} +func TestInjectInputDeviceEvents(t *testing.T) { + var capturedArgs []string + execCtx := func(ctx context.Context, name string, args ...string) *exec.Cmd { + capturedArgs = args + return exec.Command("true") + } + cli := NewCLI(execCtx) + inst := cli.LazySelectInstance(InstanceSelector{GroupName: "foo", Name: "1"}) + err := inst.InjectInputDeviceEvents("mouse", "/tmp/events.bin") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + wantArgs := []string{ + "--group_name=foo", + "--instance_name=1", + "event_devices", + "inject", + "--device_name=mouse", + "/tmp/events.bin", + } + if diff := cmp.Diff(wantArgs, capturedArgs); diff != "" { + t.Errorf("args mismatch (-want +got):\n%s", diff) + } } diff --git a/frontend/src/host_orchestrator/orchestrator/injectinputdeviceeventsaction.go b/frontend/src/host_orchestrator/orchestrator/injectinputdeviceeventsaction.go new file mode 100644 index 00000000000..d1503edbc58 --- /dev/null +++ b/frontend/src/host_orchestrator/orchestrator/injectinputdeviceeventsaction.go @@ -0,0 +1,73 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orchestrator + +import ( + "log" + "os" + + apiv1 "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/api/v1" + "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/orchestrator/cvd" + "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/orchestrator/exec" + "github.com/google/android-cuttlefish/frontend/src/liboperator/operator" +) + +type InjectInputDeviceEventsActionOpts struct { + Selector cvd.InstanceSelector + DeviceName string + EventsFilePath string + OperationManager OperationManager + ExecContext exec.ExecContext +} + +type InjectInputDeviceEventsAction struct { + selector cvd.InstanceSelector + deviceName string + eventsFilePath string + om OperationManager + cvdCLI *cvd.CLI +} + +func NewInjectInputDeviceEventsAction(opts InjectInputDeviceEventsActionOpts) *InjectInputDeviceEventsAction { + return &InjectInputDeviceEventsAction{ + selector: opts.Selector, + deviceName: opts.DeviceName, + eventsFilePath: opts.EventsFilePath, + om: opts.OperationManager, + cvdCLI: cvd.NewCLI(opts.ExecContext), + } +} + +func (a *InjectInputDeviceEventsAction) Run() (apiv1.Operation, error) { + if a.selector.GroupName == "" || a.selector.Name == "" { + return apiv1.Operation{}, operator.NewBadRequestError("empty group or instance name", nil) + } + if a.deviceName == "" { + return apiv1.Operation{}, operator.NewBadRequestError("empty device name", nil) + } + op := a.om.New() + go func(op apiv1.Operation, filePath string) { + defer os.Remove(filePath) + result := &OperationResult{} + result.Value = &apiv1.EmptyResponse{} + if err := a.cvdCLI.LazySelectInstance(a.selector).InjectInputDeviceEvents(a.deviceName, filePath); err != nil { + result.Error = operator.NewInternalError("failed to inject input device events", err) + } + if err := a.om.Complete(op.Name, result); err != nil { + log.Printf("error completing operation %q: %v\n", op.Name, err) + } + }(op, a.eventsFilePath) + return op, nil +} diff --git a/frontend/src/host_orchestrator/orchestrator/injectinputdeviceeventsaction_test.go b/frontend/src/host_orchestrator/orchestrator/injectinputdeviceeventsaction_test.go new file mode 100644 index 00000000000..2f7149f123e --- /dev/null +++ b/frontend/src/host_orchestrator/orchestrator/injectinputdeviceeventsaction_test.go @@ -0,0 +1,164 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orchestrator + +import ( + "context" + "os" + "os/exec" + "testing" + "time" + + "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/orchestrator/cvd" +) + +func TestInjectInputDeviceEventsActionSucceeds(t *testing.T) { + tempFile, err := os.CreateTemp("", "test_events_*.bin") + if err != nil { + t.Fatal(err) + } + tempFilePath := tempFile.Name() + tempFile.Close() + + var executedArgs []string + execContext := func(ctx context.Context, name string, args ...string) *exec.Cmd { + executedArgs = args + return exec.Command("true") + } + + om := NewMapOM() + opts := InjectInputDeviceEventsActionOpts{ + Selector: cvd.InstanceSelector{GroupName: "foo", Name: "1"}, + DeviceName: "keyboard", + EventsFilePath: tempFilePath, + OperationManager: om, + ExecContext: execContext, + } + action := NewInjectInputDeviceEventsAction(opts) + + op, err := action.Run() + if err != nil { + t.Fatalf("unexpected error running action: %v", err) + } + + res, err := om.Wait(op.Name, 5*time.Second) + if err != nil { + t.Fatalf("unexpected error waiting for operation: %v", err) + } + if res.Error != nil { + t.Fatalf("operation failed: %v", res.Error) + } + + // Verify command arguments + expectedSubcommand := false + for i := 0; i < len(executedArgs)-1; i++ { + if executedArgs[i] == "event_devices" && executedArgs[i+1] == "inject" { + expectedSubcommand = true + break + } + } + if !expectedSubcommand { + t.Errorf("expected event_devices inject in args: %v", executedArgs) + } + + deviceFlagFound := false + for _, arg := range executedArgs { + if arg == "--device_name=keyboard" { + deviceFlagFound = true + break + } + } + if !deviceFlagFound { + t.Errorf("expected --device_name=keyboard in args: %v", executedArgs) + } + + if len(executedArgs) > 0 && executedArgs[len(executedArgs)-1] != tempFilePath { + t.Errorf("expected events file path %q as last arg, got %q", tempFilePath, executedArgs[len(executedArgs)-1]) + } + + // Verify the temporary file was deleted once cvd is done with it + if _, err := os.Stat(tempFilePath); !os.IsNotExist(err) { + t.Errorf("expected temporary events file to be deleted, but it still exists") + } +} + +func TestInjectInputDeviceEventsActionFails(t *testing.T) { + tempFile, err := os.CreateTemp("", "test_events_*.bin") + if err != nil { + t.Fatal(err) + } + tempFilePath := tempFile.Name() + tempFile.Close() + + execContext := func(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.Command("false") + } + + om := NewMapOM() + opts := InjectInputDeviceEventsActionOpts{ + Selector: cvd.InstanceSelector{GroupName: "foo", Name: "1"}, + DeviceName: "mouse", + EventsFilePath: tempFilePath, + OperationManager: om, + ExecContext: execContext, + } + action := NewInjectInputDeviceEventsAction(opts) + + op, err := action.Run() + if err != nil { + t.Fatalf("unexpected error running action: %v", err) + } + + res, err := om.Wait(op.Name, 5*time.Second) + if err != nil { + t.Fatalf("unexpected error waiting for operation: %v", err) + } + if res.Error == nil { + t.Fatal("expected operation error, got nil") + } +} + +func TestInjectInputDeviceEventsActionValidationFails(t *testing.T) { + om := NewMapOM() + execContext := func(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.Command("true") + } + + // Empty device name + opts := InjectInputDeviceEventsActionOpts{ + Selector: cvd.InstanceSelector{GroupName: "foo", Name: "1"}, + DeviceName: "", + EventsFilePath: "/tmp/foo", + OperationManager: om, + ExecContext: execContext, + } + _, err := NewInjectInputDeviceEventsAction(opts).Run() + if err == nil { + t.Fatal("expected error for empty device name, got nil") + } + + // Empty group name + opts = InjectInputDeviceEventsActionOpts{ + Selector: cvd.InstanceSelector{GroupName: "", Name: "1"}, + DeviceName: "mouse", + EventsFilePath: "/tmp/foo", + OperationManager: om, + ExecContext: execContext, + } + _, err = NewInjectInputDeviceEventsAction(opts).Run() + if err == nil { + t.Fatal("expected error for empty group name, got nil") + } +} diff --git a/frontend/src/host_orchestrator/orchestrator/listinputdevicesaction.go b/frontend/src/host_orchestrator/orchestrator/listinputdevicesaction.go new file mode 100644 index 00000000000..dd9e94e52d8 --- /dev/null +++ b/frontend/src/host_orchestrator/orchestrator/listinputdevicesaction.go @@ -0,0 +1,57 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orchestrator + +import ( + apiv1 "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/api/v1" + "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/orchestrator/cvd" + "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/orchestrator/exec" + "github.com/google/android-cuttlefish/frontend/src/liboperator/operator" +) + +type ListInputDevicesActionOpts struct { + Selector cvd.InstanceSelector + ExecContext exec.ExecContext +} + +type ListInputDevicesAction struct { + selector cvd.InstanceSelector + cvdCLI *cvd.CLI +} + +func NewListInputDevicesAction(opts ListInputDevicesActionOpts) *ListInputDevicesAction { + return &ListInputDevicesAction{ + selector: opts.Selector, + cvdCLI: cvd.NewCLI(opts.ExecContext), + } +} + +func toApiv1ListInputDevicesResponse(devices []string) *apiv1.ListInputDevicesResponse { + response := apiv1.ListInputDevicesResponse{ + InputDevices: []string{}, + } + if devices != nil { + response.InputDevices = append(response.InputDevices, devices...) + } + return &response +} + +func (a *ListInputDevicesAction) Run() (*apiv1.ListInputDevicesResponse, error) { + devices, err := a.cvdCLI.LazySelectInstance(a.selector).ListInputDevices() + if err != nil { + return nil, operator.NewInternalError("failed to list input devices", err) + } + return toApiv1ListInputDevicesResponse(devices), nil +} diff --git a/frontend/src/host_orchestrator/orchestrator/listinputdevicesaction_test.go b/frontend/src/host_orchestrator/orchestrator/listinputdevicesaction_test.go new file mode 100644 index 00000000000..dc91c7993f3 --- /dev/null +++ b/frontend/src/host_orchestrator/orchestrator/listinputdevicesaction_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orchestrator + +import ( + "context" + "os/exec" + "strings" + "testing" + + apiv1 "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/api/v1" + "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/orchestrator/cvd" + "github.com/google/go-cmp/cmp" +) + +func TestListInputDevicesActionSucceeds(t *testing.T) { + output := "foo/1: keyboard mouse touchscreen\n" + execContext := func(ctx context.Context, name string, args ...string) *exec.Cmd { + cmd := exec.Command("true") + if len(args) >= 2 && args[len(args)-2] == "event_devices" && args[len(args)-1] == "list" { + cmd = exec.Command("tee", "/dev/stderr") + cmd.Stdin = strings.NewReader(output) + } + return cmd + } + + opts := ListInputDevicesActionOpts{ + Selector: cvd.InstanceSelector{GroupName: "foo", Name: "1"}, + ExecContext: execContext, + } + action := NewListInputDevicesAction(opts) + + res, err := action.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := &apiv1.ListInputDevicesResponse{ + InputDevices: []string{"keyboard", "mouse", "touchscreen"}, + } + if diff := cmp.Diff(want, res); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } +} + +func TestListInputDevicesActionEmptyDevices(t *testing.T) { + output := "foo/1:\n" + execContext := func(ctx context.Context, name string, args ...string) *exec.Cmd { + cmd := exec.Command("true") + if len(args) >= 2 && args[len(args)-2] == "event_devices" && args[len(args)-1] == "list" { + cmd = exec.Command("tee", "/dev/stderr") + cmd.Stdin = strings.NewReader(output) + } + return cmd + } + + opts := ListInputDevicesActionOpts{ + Selector: cvd.InstanceSelector{GroupName: "foo", Name: "1"}, + ExecContext: execContext, + } + action := NewListInputDevicesAction(opts) + + res, err := action.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := &apiv1.ListInputDevicesResponse{ + InputDevices: []string{}, + } + if diff := cmp.Diff(want, res); diff != "" { + t.Errorf("response mismatch (-want +got):\n%s", diff) + } +} + +func TestListInputDevicesActionCommandFails(t *testing.T) { + execContext := func(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.Command("false") + } + + opts := ListInputDevicesActionOpts{ + Selector: cvd.InstanceSelector{GroupName: "foo", Name: "1"}, + ExecContext: execContext, + } + action := NewListInputDevicesAction(opts) + + _, err := action.Run() + if err == nil { + t.Fatal("expected error, got nil") + } +} From a55e3cf1105aaffe35e17a83201bf858351c25d9 Mon Sep 17 00:00:00 2001 From: "Jorge E. Moreira" Date: Tue, 8 Sep 2026 13:16:43 -0700 Subject: [PATCH 4/4] HO: Client supports injecting input events Bug: b/554190370 Assisted-by: Gemini-Next --- .../fake_host_orchestrator_client.go | 8 ++ .../libhoclient/host_orchestrator_client.go | 20 ++++ .../host_orchestrator_client_test.go | 91 +++++++++++++++++++ frontend/src/libhoclient/httputils.go | 27 ++++++ 4 files changed, 146 insertions(+) diff --git a/frontend/src/libhoclient/fake_host_orchestrator_client.go b/frontend/src/libhoclient/fake_host_orchestrator_client.go index dc361786261..2234ccec467 100644 --- a/frontend/src/libhoclient/fake_host_orchestrator_client.go +++ b/frontend/src/libhoclient/fake_host_orchestrator_client.go @@ -174,6 +174,14 @@ func (c *FakeHostOrchestratorClient) StopScreenRecording(groupName, instanceName return nil } +func (c *FakeHostOrchestratorClient) ListEventDevices(groupName, instanceName string) ([]string, error) { + return []string{}, nil +} + +func (c *FakeHostOrchestratorClient) InjectInputEvents(groupName, instanceName, deviceName string, events io.Reader) error { + return nil +} + func (c *FakeHostOrchestratorClient) createFakeCVDs(total int) ([]*hoapi.CVD, error) { cvds := []*hoapi.CVD{} for cnt := 0; cnt < total; cnt++ { diff --git a/frontend/src/libhoclient/host_orchestrator_client.go b/frontend/src/libhoclient/host_orchestrator_client.go index 9cd297be511..2b9dbbf6a18 100644 --- a/frontend/src/libhoclient/host_orchestrator_client.go +++ b/frontend/src/libhoclient/host_orchestrator_client.go @@ -153,6 +153,10 @@ type InstanceOperationsClient interface { StartScreenRecording(groupName, instanceName string) error // Stop recording the screen StopScreenRecording(groupName, instanceName string) error + // List event devices + ListEventDevices(groupName, instanceName string) ([]string, error) + // Inject input events + InjectInputEvents(groupName, instanceName, deviceName string, events io.Reader) error } // Manage direct two-way communication channels with remote instances. @@ -564,6 +568,22 @@ func (c *HostOrchestratorClientImpl) StopScreenRecording(groupName, instanceName return c.doEmptyResponseRequest(rb) } +func (c *HostOrchestratorClientImpl) ListEventDevices(groupName, instanceName string) ([]string, error) { + path := fmt.Sprintf("/cvds/%s/%s/event_devices", groupName, instanceName) + rb := c.HTTPHelper.NewGetRequest(path) + response := &hoapi.ListInputDevicesResponse{} + if err := rb.JSONResDo(response); err != nil { + return nil, err + } + return response.InputDevices, nil +} + +func (c *HostOrchestratorClientImpl) InjectInputEvents(groupName, instanceName, deviceName string, events io.Reader) error { + path := fmt.Sprintf("/cvds/%s/%s/event_devices/%s:inject", groupName, instanceName, deviceName) + rb := c.HTTPHelper.NewPostFormFileRequest(path, "file", "events.bin", events) + return c.doEmptyResponseRequest(rb) +} + func (c *HostOrchestratorClientImpl) doEmptyResponseRequest(rb *HTTPRequestBuilder) error { op := &hoapi.Operation{} if err := rb.JSONResDo(op); err != nil { diff --git a/frontend/src/libhoclient/host_orchestrator_client_test.go b/frontend/src/libhoclient/host_orchestrator_client_test.go index c2cf0583034..80c6f9b5d43 100644 --- a/frontend/src/libhoclient/host_orchestrator_client_test.go +++ b/frontend/src/libhoclient/host_orchestrator_client_test.go @@ -15,11 +15,14 @@ package libhoclient import ( + "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -368,3 +371,91 @@ func write(w http.ResponseWriter, data any, statusCode int) { encoder := json.NewEncoder(w) encoder.Encode(data) } + +func TestListEventDevices(t *testing.T) { + fakeRes := &hoapi.ListInputDevicesResponse{ + InputDevices: []string{"keyboard", "mouse", "touchscreen"}, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch ep := r.Method + " " + r.URL.Path; ep { + case "GET /cvds/mygroup/myinstance/event_devices": + writeOK(w, fakeRes) + default: + t.Fatal("unexpected endpoint: " + ep) + } + })) + defer ts.Close() + srv := NewHostOrchestratorClient(ts.URL) + + devices, err := srv.ListEventDevices("mygroup", "myinstance") + if err != nil { + t.Fatal(err) + } + want := []string{"keyboard", "mouse", "touchscreen"} + if diff := cmp.Diff(want, devices); diff != "" { + t.Fatalf("response mismatch (-want +got):\n%s", diff) + } +} + +func TestListEventDevicesServerError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusInternalServerError) + })) + defer ts.Close() + srv := NewHostOrchestratorClient(ts.URL) + + _, err := srv.ListEventDevices("mygroup", "myinstance") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestInjectInputEvents(t *testing.T) { + expectedData := []byte("binary event payload") + var receivedData []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch ep := r.Method + " " + r.URL.Path; ep { + case "POST /cvds/mygroup/myinstance/event_devices/mouse:inject": + if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + t.Fatalf("unexpected content type: %s", r.Header.Get("Content-Type")) + } + f, _, err := r.FormFile("file") + if err != nil { + t.Fatalf("failed to read form file: %v", err) + } + defer f.Close() + receivedData, err = io.ReadAll(f) + if err != nil { + t.Fatalf("failed to read file content: %v", err) + } + writeOK(w, hoapi.Operation{Name: "inject-op"}) + case "POST /operations/inject-op/:wait": + writeOK(w, &hoapi.EmptyResponse{}) + default: + t.Fatal("unexpected endpoint: " + ep) + } + })) + defer ts.Close() + srv := NewHostOrchestratorClient(ts.URL) + + err := srv.InjectInputEvents("mygroup", "myinstance", "mouse", bytes.NewReader(expectedData)) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedData, receivedData); diff != "" { + t.Fatalf("data mismatch (-want +got):\n%s", diff) + } +} + +func TestInjectInputEventsServerError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusBadRequest) + })) + defer ts.Close() + srv := NewHostOrchestratorClient(ts.URL) + + err := srv.InjectInputEvents("mygroup", "myinstance", "mouse", bytes.NewReader([]byte("data"))) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/frontend/src/libhoclient/httputils.go b/frontend/src/libhoclient/httputils.go index 40ce1182db2..d0e17fc114d 100644 --- a/frontend/src/libhoclient/httputils.go +++ b/frontend/src/libhoclient/httputils.go @@ -81,6 +81,33 @@ func (h *HTTPHelper) NewUploadFileRequest(ctx context.Context, path string, body } } +func (h *HTTPHelper) NewPostFormFileRequest(path, fieldName, filename string, r io.Reader) *HTTPRequestBuilder { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + fw, err := writer.CreateFormFile(fieldName, filename) + if err != nil { + return &HTTPRequestBuilder{helper: h, request: nil, err: err} + } + if r != nil { + if _, err := io.Copy(fw, r); err != nil { + return &HTTPRequestBuilder{helper: h, request: nil, err: err} + } + } + if err := writer.Close(); err != nil { + return &HTTPRequestBuilder{helper: h, request: nil, err: err} + } + req, err := http.NewRequest(http.MethodPost, h.RootEndpoint+path, body) + if err != nil { + return &HTTPRequestBuilder{helper: h, request: nil, err: err} + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + return &HTTPRequestBuilder{ + helper: h, + request: req, + err: nil, + } +} + func (h *HTTPHelper) newRequestWithJson(method, path string, jsonBody any) *HTTPRequestBuilder { body := []byte{} var err error