From 6455b1dade2c2ef7d0e593da5946181de737022f Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:12:53 -0400 Subject: [PATCH] feat: the built-in pad's controller type switches from the session menu while Steam runs (#558) --- app/src/main/cpp/fakeinput_steam.cpp | 231 +++++++++++++++--- .../com/droiddeck/launcher/SessionActivity.kt | 14 +- .../launcher/input/FakeInputWriter.java | 47 ++++ .../launcher/input/SteamControllerBle.kt | 2 + .../launcher/session/SessionPrefs.kt | 6 + .../launcher/session/SessionService.kt | 20 +- .../launcher/session/SteamDeckPad.kt | 40 +-- .../droiddeck/launcher/ui/SessionOverlay.kt | 10 + 8 files changed, 304 insertions(+), 66 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index f435a4369..c4f63e3fb 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -805,17 +805,24 @@ __attribute__((visibility("hidden"))) static bool is_fake_input_fd(int fd) { return controller_map().find(fd) != controller_map().end(); } +static bool fake_pad_withdrawn(const FakeController &fake); + +// A node whose pad is gone: the app republished its ring, or the pad was switched to a type that +// is not this node (it became the Deck or the Steam Controller again, fake_pad_withdrawn). +static bool controller_gone(const FakeController &fake) { + return ring_generation(fake.ring) != fake.generation || fake_pad_withdrawn(fake); +} + __attribute__((visibility("hidden"))) static bool fake_fd_is_stale(int fd) { if (!watched(fd)) return false; std::lock_guard guard(controller_mutex()); auto controller = controller_map().find(fd); - return controller != controller_map().end() && - ring_generation(controller->second->ring) != controller->second->generation; + return controller != controller_map().end() && controller_gone(*controller->second); } // Caller holds controller_mutex(). static bool fake_has_unread_data(const FakeController &fake) { - if (ring_generation(fake.ring) != fake.generation) + if (controller_gone(fake)) return false; // Readiness never consumes resync requests or changes the read cursor. // Do not spin a nonblocking guest on a producer's unfinished publication. @@ -838,7 +845,7 @@ static bool fake_fd_has_unread_data(int fd) { static short fake_poll_revents(const std::shared_ptr &fake, short events) { std::lock_guard guard(controller_mutex()); if (fake->closed) return POLLNVAL; - if (ring_generation(fake->ring) != fake->generation) return POLLHUP; + if (controller_gone(*fake)) return POLLHUP; short ready = events & (POLLOUT | POLLWRNORM); if ((events & (POLLIN | POLLRDNORM)) && fake_has_unread_data(*fake)) ready |= events & (POLLIN | POLLRDNORM); @@ -1284,7 +1291,9 @@ struct DeckImu { int16_t pads[4]; // left X, Y, right X, Y: -32768..32767, Y up uint16_t pressure[2]; // left, right: 0..32767 uint32_t controls; // DECK_EXTRA_* bits + uint32_t pad_type; // PAD_TYPE_* | 0x80000000, written by the app; 0 until it does }; +static_assert(offsetof(DeckImu, pad_type) == 44, "DeckImu::pad_type matches FakeInputWriter"); // DeckImu::controls (DeckControls.kt). static constexpr uint32_t DECK_EXTRA_L4 = 1, DECK_EXTRA_R4 = 2, DECK_EXTRA_L5 = 4, DECK_EXTRA_R5 = 8, DECK_EXTRA_LPAD_TOUCH = 16, @@ -1308,7 +1317,7 @@ static_assert(sizeof(DeckImu) <= DECK_IMU_BLOCK_SIZE, "the IMU block is 64 bytes // report characteristic and answers with get_reply set to the same number. struct TritonSharedData { uint32_t magic; - uint32_t reserved; + uint32_t connected; // written by the app: the paired controller is connected and streaming uint64_t input_seq; uint32_t report_id; uint32_t input_length; @@ -1326,6 +1335,7 @@ struct TritonSharedData { static constexpr uint32_t TRITON_SHARED_MAGIC = 0x4e525454; static constexpr size_t TRITON_SHARED_BLOCK_SIZE = 256; static constexpr size_t TRITON_SHARED_OFFSET = FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE; +static_assert(offsetof(TritonSharedData, connected) == 4, "Triton connected offset"); static_assert(offsetof(TritonSharedData, input_seq) == 8, "Triton input sequence offset"); static_assert(offsetof(TritonSharedData, report_id) == 16, "Triton report ID offset"); static_assert(offsetof(TritonSharedData, input) == 24, "Triton input offset"); @@ -1342,6 +1352,9 @@ static_assert(sizeof(TritonSharedData) <= TRITON_SHARED_BLOCK_SIZE, "Triton ring static constexpr const char *TRITON_HIDRAW_PATH = "/dev/hidraw18"; static constexpr unsigned int TRITON_HIDRAW_MAJOR = 240; static constexpr unsigned int TRITON_HIDRAW_MINOR = 18; +// The app's own pad as the wired Steam Controller: a node of its own, so a paired one keeps 18. +static constexpr const char *TRITON_PAD_HIDRAW_PATH = "/dev/hidraw19"; +static constexpr unsigned int TRITON_PAD_HIDRAW_MINOR = 19; static constexpr int TRITON_REPORT_ID_BYTES = 1; static constexpr int TRITON_STATE_BYTES = 45; static constexpr int TRITON_REPORT_INTERVAL_US = 4000; @@ -1393,12 +1406,66 @@ static bool fake_deck_enabled() { return enabled == 1; } -// The app's own pad as the Steam Controller, in the Deck's place: hidraw18 then streams reports -// built from ring slot 0 as the Deck's are, and answers its feature reports itself. -static bool fake_triton_pad() { - static int enabled = -1; - if (enabled < 0) enabled = getenv("FAKE_EVDEV_TRITON_PAD") && atoi(getenv("FAKE_EVDEV_TRITON_PAD")) ? 1 : 0; - return enabled == 1; +// What the app's own pad is to the client, switchable while the session runs (the drawer's +// Controller page): the Deck (/dev/hidraw16), the wired Steam Controller (/dev/hidraw19, built from +// ring slot 0 as the Deck's reports are), or an Xbox 360 pad (its own evdev node, no HID device). +// The app writes the choice into the slot's Deck block (DeckImu::pad_type); until it has, +// FAKE_EVDEV_PAD_TYPE says what the session started with. Only the chosen device can be opened; +// the others end their streams, and the client is told through udev (pad_type_thread). +static constexpr uint32_t PAD_TYPE_DECK = 0, PAD_TYPE_STEAM = 1, PAD_TYPE_XBOX = 2; +static constexpr uint32_t PAD_TYPE_SET = 0x80000000u; + +__attribute__((visibility("hidden"))) static uint32_t session_pad_type() { + static int type = -1; + if (type < 0) { + const char *env = getenv("FAKE_EVDEV_PAD_TYPE"); + type = env && !strcmp(env, "steamcontroller") ? PAD_TYPE_STEAM + : env && !strcmp(env, "xbox360") ? PAD_TYPE_XBOX : PAD_TYPE_DECK; + } + return static_cast(type); +} + +// Slot 0's Deck and Triton blocks, mapped once, read-only; null until the ring file has them. +__attribute__((visibility("hidden"))) static const uint8_t *ring_blocks() { + static std::atomic block{nullptr}; + static std::atomic tries{0}; + const uint8_t *mapped = block.load(std::memory_order_acquire); + if (mapped || tries.load(std::memory_order_relaxed) > 200) return mapped; + static std::mutex lock; + std::lock_guard guard(lock); + if ((mapped = block.load(std::memory_order_acquire))) return mapped; + tries.fetch_add(1, std::memory_order_relaxed); + static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + std::string path = get_ring_path_for_slot(0); + int fd = path.empty() ? -1 : my_open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) return nullptr; + struct stat st; + void *mapping = MAP_FAILED; + const size_t size = TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE; + if (fstat(fd, &st) == 0 && static_cast(st.st_size) >= size) + mapping = mmap(nullptr, size, PROT_READ, MAP_SHARED, fd, 0); + syscall(SYS_close, fd); + if (mapping == MAP_FAILED) return nullptr; + mapped = static_cast(mapping); + block.store(mapped, std::memory_order_release); + return mapped; +} + +// The paired Bluetooth controller is offered only while the app has it connected: Steam asks a +// device it is offered for its info, and one that never answers holds up every other controller. +__attribute__((visibility("hidden"))) static bool triton_connected() { + const uint8_t *blocks = ring_blocks(); + if (!blocks) return false; + const auto *shared = reinterpret_cast(blocks + TRITON_SHARED_OFFSET); + return __atomic_load_n(&shared->magic, __ATOMIC_ACQUIRE) == TRITON_SHARED_MAGIC && + __atomic_load_n(&shared->connected, __ATOMIC_ACQUIRE) != 0; +} + +__attribute__((visibility("hidden"))) static uint32_t live_pad_type() { + const uint8_t *blocks = ring_blocks(); + const DeckImu *imu = blocks ? reinterpret_cast(blocks + FAKE_INPUT_RING_SIZE) : nullptr; + uint32_t value = imu ? __atomic_load_n(&imu->pad_type, __ATOMIC_ACQUIRE) : 0; + return value & PAD_TYPE_SET ? value & 0xff : session_pad_type(); } static bool fake_triton_enabled() { @@ -1435,17 +1502,24 @@ __attribute__((constructor)) static void log_client_start() { } __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) { - return pathname && fake_deck_enabled() && !fake_triton_pad() && !strcmp(pathname, DECK_HIDRAW_PATH); + return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH) && live_pad_type() == PAD_TYPE_DECK; +} + +__attribute__((visibility("hidden"))) static bool is_triton_pad_path(const char *pathname) { + return pathname && fake_deck_enabled() && !strcmp(pathname, TRITON_PAD_HIDRAW_PATH) && + live_pad_type() == PAD_TYPE_STEAM; } __attribute__((visibility("hidden"))) static bool is_triton_hidraw_path(const char *pathname) { - return pathname && fake_triton_enabled() && !strcmp(pathname, TRITON_HIDRAW_PATH); + return pathname && fake_triton_enabled() && !strcmp(pathname, TRITON_HIDRAW_PATH) && triton_connected(); } // The app's first pad is the Deck now: its evdev node is withdrawn from everyone. Further players' // pads are left to the client alone, which hands them to games through Steam Input. __attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const char *pathname) { if (!fake_deck_enabled() || !pathname || strncmp(pathname, "/dev/input/", 11)) return false; + // As an Xbox 360 pad it is its own evdev node again, for the client and games alike. + if (live_pad_type() == PAD_TYPE_XBOX) return false; const char *event = strrchr(pathname, '/') + 1; if (strncmp(event, "event", 5) && strncmp(event, "js", 2)) return false; int number = get_event_number(event); @@ -1453,6 +1527,13 @@ __attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const ch return number == 0 || !process_is_steam_client(); } +static bool fake_pad_withdrawn(const FakeController &fake) { + if (fake.uinput || !fake.event) return false; + char path[64]; + snprintf(path, sizeof(path), "/dev/input/%s", fake.event); + return is_withdrawn_pad_path(path); +} + static inline void put16(uint8_t *at, int value) { at[0] = static_cast(value & 0xff); at[1] = static_cast((value >> 8) & 0xff); @@ -1524,7 +1605,8 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg) uint32_t buttons = 0; int32_t axes[8] = {}; DeckExtras extras; - for (;;) { + // Switched to another type, the stream ends: the client's read fails and it lets the Deck go. + while (live_pad_type() == PAD_TYPE_DECK) { if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) { buttons = snap.buttons; memcpy(axes, snap.axes, sizeof(axes)); @@ -1645,7 +1727,8 @@ __attribute__((visibility("hidden"))) static void *triton_report_thread(void *ar uint8_t report[TRITON_REPORT_ID_BYTES + 64] = {}; uint8_t report_id = 0x45; size_t length = TRITON_STATE_BYTES; - for (;;) { + // A disconnect ends the stream, as unplugging the controller would. + while (triton_connected()) { if (read_triton_state(*self, report_id, report + 1, length)) { report[0] = report_id; if (send(self->peer, report, length + 1, MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && @@ -1745,7 +1828,7 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg) uint32_t buttons = 0; int32_t axes[8] = {}; DeckExtras extras; - for (;;) { + while (live_pad_type() == PAD_TYPE_STEAM) { if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) { buttons = snap.buttons; memcpy(axes, snap.axes, sizeof(axes)); @@ -1765,7 +1848,7 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg) return nullptr; } -__attribute__((visibility("hidden"))) static int open_triton_hidraw(int flags) { +__attribute__((visibility("hidden"))) static int open_triton_hidraw(int flags, bool emulated) { static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); if (!process_is_steam_client()) { errno = ENOENT; @@ -1808,7 +1891,7 @@ __attribute__((visibility("hidden"))) static int open_triton_hidraw(int flags) { triton->ring = static_cast(mapping); triton->mapping_size = mapping_size; triton->triton = true; - triton->emulated = fake_triton_pad(); + triton->emulated = emulated; { std::lock_guard guard(controller_mutex()); deck_map()[pair[0]] = triton; @@ -1828,8 +1911,8 @@ __attribute__((visibility("hidden"))) static int open_triton_hidraw(int flags) { return -1; } pthread_detach(thread); - Logger::log("triton: %s opened as fd %d (%s)\n", TRITON_HIDRAW_PATH, pair[0], - triton->emulated ? "28de:1302, the app's pad" : "28de:1303"); + Logger::log("triton: %s opened as fd %d (%s)\n", emulated ? TRITON_PAD_HIDRAW_PATH : TRITON_HIDRAW_PATH, pair[0], + emulated ? "28de:1302, the app's pad" : "28de:1303"); return pair[0]; } @@ -2122,13 +2205,86 @@ __attribute__((visibility("hidden"))) static int ioctl_touch(DeckHidraw &touch, // Plugging in and out: the app flips `plugged`; this tells the client's HID discovery, which // otherwise never looks again (the sandbox gives it no kernel uevents). -__attribute__((visibility("hidden"))) static void touch_uevent(const char *action) { +__attribute__((visibility("hidden"))) static int inject_uevent(const char *action, const char *devpath, + const char *subsystem, const char *devname, + unsigned major, unsigned minor) { using inject_fn = int (*)(const char *, const char *, const char *, const char *, unsigned, unsigned); static auto inject = reinterpret_cast(dlsym(RTLD_DEFAULT, "bl_udevmon_inject")); - int sent = inject ? inject(action, TOUCH_DEVPATH, "hidraw", TOUCH_HIDRAW_PATH, DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR) : -1; + return inject ? inject(action, devpath, subsystem, devname, major, minor) : -1; +} + +__attribute__((visibility("hidden"))) static void touch_uevent(const char *action) { + int sent = inject_uevent(action, TOUCH_DEVPATH, "hidraw", TOUCH_HIDRAW_PATH, DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR); Logger::log("touch: %s (udev event to %d monitor%s)\n", action, sent, sent == 1 ? "" : "s"); } +// The devices each pad type is, as SteamDeckPad.kt describes them and inputudev.c the evdev node. +static constexpr const char *DECK_DEVPATH = "/devices/droiddeck/usb1/1-1:1.2/0003:28DE:1205.0001/hidraw/hidraw16"; +static constexpr const char *TRITON_PAD_DEVPATH = "/devices/droiddeck/usb3/3-1:1.0/0003:28DE:1302.0004/hidraw/hidraw19"; +static constexpr const char *PAD_EVDEV_DEVPATH = "/devices/virtual/input/droiddeck0/event0"; +static constexpr const char *TRITON_DEVPATH = "/devices/droiddeck/bluetooth/0005:28DE:1303.0003/hidraw/hidraw18"; + +__attribute__((visibility("hidden"))) static int pad_type_uevent(uint32_t type, const char *action) { + if (type == PAD_TYPE_DECK) + return inject_uevent(action, DECK_DEVPATH, "hidraw", DECK_HIDRAW_PATH, DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR); + if (type == PAD_TYPE_STEAM) + return inject_uevent(action, TRITON_PAD_DEVPATH, "hidraw", TRITON_PAD_HIDRAW_PATH, TRITON_HIDRAW_MAJOR, + TRITON_PAD_HIDRAW_MINOR); + dev_t rdev = get_fake_input_rdev("event0"); + return inject_uevent(action, PAD_EVDEV_DEVPATH, "input", "/dev/input/event0", major(rdev), minor(rdev)); +} + +// A switch, as the client sees one: the old device unplugged, then the new one plugged in. +__attribute__((visibility("hidden"))) static void *pad_type_thread(void *) { + static const char *kNames[] = {"Steam Deck controller", "Steam Controller", "Xbox 360 controller"}; + uint32_t was = live_pad_type(); + bool paired = fake_triton_enabled() && triton_connected(); + Logger::log("pad: a %s at start%s\n", kNames[was % 3], paired ? ", the paired Steam Controller connected" : ""); + for (;;) { + struct timespec interval = {0, 50 * 1000 * 1000}; + nanosleep(&interval, nullptr); + bool connected = fake_triton_enabled() && triton_connected(); + if (connected != paired) { + paired = connected; + int sent = inject_uevent(connected ? "add" : "remove", TRITON_DEVPATH, "hidraw", TRITON_HIDRAW_PATH, + TRITON_HIDRAW_MAJOR, TRITON_HIDRAW_MINOR); + Logger::log("pad: the paired Steam Controller %s (udev event to %d monitors)\n", + connected ? "connected" : "disconnected", sent); + } + uint32_t now = live_pad_type(); + if (now == was || now > PAD_TYPE_XBOX) continue; + int removed = pad_type_uevent(was, "remove"); + // Time for the client to let the old device go before the new one appears. + struct timespec settle = {0, 300 * 1000 * 1000}; + nanosleep(&settle, nullptr); + int added = pad_type_uevent(now, "add"); + // The pad's evdev node comes or goes with the Xbox 360 type. Its hotplug is watched with + // inotify on /dev/input (SDL's, with udev off), so it is moved out and back in: everyone + // watching sees it removed and added, and opens it again only if it is the pad now. + if (was == PAD_TYPE_XBOX || now == PAD_TYPE_XBOX) { + std::string node = std::string(fake_hook_dir()) + "/event0"; + std::string aside = std::string(fake_hook_dir()) + "/.event0-switching"; + if (rename(node.c_str(), aside.c_str()) == 0) { + nanosleep(&settle, nullptr); + if (rename(aside.c_str(), node.c_str()) != 0) + Logger::log("pad: event0 not put back (%s)\n", strerror(errno)); + } else { + Logger::log("pad: event0 not moved (%s)\n", strerror(errno)); + } + } + Logger::log("pad: now a %s (udev events to %d/%d monitors)\n", kNames[now], removed, added); + was = now; + } + return nullptr; +} + +__attribute__((constructor)) static void start_pad_type_watch() { + config(); + if (!process_is_steam_client() || !fake_deck_enabled()) return; + pthread_t thread; + if (pthread_create(&thread, nullptr, pad_type_thread, nullptr) == 0) pthread_detach(thread); +} + __attribute__((visibility("hidden"))) static void *touch_plug_thread(void *) { bool was = touch_plugged(); Logger::log("touch: %s at start\n", was ? "plugged in" : "unplugged"); @@ -2727,7 +2883,8 @@ EXPORT int open(const char *pathname, int flags, ...) { if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags); if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags); - if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags); + if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags, false); + if (is_triton_pad_path(pathname)) return open_triton_hidraw(flags, true); if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags); if (is_withdrawn_pad_path(pathname)) { errno = ENOENT; @@ -2803,7 +2960,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags); if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags); - if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags); + if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags, false); + if (is_triton_pad_path(pathname)) return open_triton_hidraw(flags, true); if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags); if (is_withdrawn_pad_path(pathname)) { errno = ENOENT; @@ -2862,7 +3020,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { template static int fake_stat_path(const char *pathname, S *statbuf, Real real) { if (is_withdrawn_pad_path(pathname) || - ((is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname)) && !process_is_steam_client())) { + ((is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname)) && + !process_is_steam_client())) { errno = ENOENT; return -1; } @@ -2878,10 +3037,10 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) { statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR); return 0; } - if (is_triton_hidraw_path(pathname)) { + if (is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname)) { memset(statbuf, 0, sizeof(*statbuf)); statbuf->st_mode = S_IFCHR | 0666; - statbuf->st_rdev = makedev(TRITON_HIDRAW_MAJOR, TRITON_HIDRAW_MINOR); + statbuf->st_rdev = makedev(TRITON_HIDRAW_MAJOR, is_triton_pad_path(pathname) ? TRITON_PAD_HIDRAW_MINOR : TRITON_HIDRAW_MINOR); return 0; } @@ -2931,7 +3090,9 @@ static int fake_stat_fd(int fd, S *buf, Real real) { if (ret == 0 && deck_map().count(fd)) { buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR; const auto &device = *deck_map()[fd]; - unsigned int minor = device.touch ? TOUCH_HIDRAW_MINOR : (device.triton ? TRITON_HIDRAW_MINOR : DECK_HIDRAW_MINOR); + unsigned int minor = device.touch ? TOUCH_HIDRAW_MINOR + : device.emulated ? TRITON_PAD_HIDRAW_MINOR + : device.triton ? TRITON_HIDRAW_MINOR : DECK_HIDRAW_MINOR; buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, minor); return ret; } @@ -2978,9 +3139,10 @@ EXPORT int access(const char *pathname, int mode) { static auto my_access = reinterpret_cast(dlsym(RTLD_NEXT, "access")); if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0; if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0; - if (is_triton_hidraw_path(pathname) && process_is_steam_client()) return 0; + if ((is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname)) && process_is_steam_client()) return 0; if (is_touch_hidraw_path(pathname)) return 0; - if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname)) { + if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname) || + is_triton_pad_path(pathname)) { errno = ENOENT; return -1; } @@ -3016,9 +3178,10 @@ EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) { static auto my_faccessat = reinterpret_cast(dlsym(RTLD_NEXT, "faccessat")); if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0; if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0; - if (is_triton_hidraw_path(pathname) && process_is_steam_client()) return 0; + if ((is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname)) && process_is_steam_client()) return 0; if (is_touch_hidraw_path(pathname)) return 0; - if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname)) { + if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname) || + is_triton_pad_path(pathname)) { errno = ENOENT; return -1; } @@ -3105,7 +3268,7 @@ static bool wait_snapshot(std::shared_ptr fake, errno = EBADF; return false; } - if (ring_generation(fake->ring) != fake->generation) { + if (controller_gone(*fake)) { errno = ENODEV; return false; } @@ -3399,7 +3562,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { errno = EBADF; return -1; } - if (ring_generation(fake.ring) != fake.generation) { + if (controller_gone(fake)) { errno = ENODEV; return -1; } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 5729e8950..0a547011c 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -250,6 +250,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER) private var drawerControllerActive by mutableStateOf(false) private var backActionsInverted by mutableStateOf(false) + /** What the pad is to the Steam client (SessionPrefs.CONTROLLER_*), switched from the drawer. */ + private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK) /** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */ private var pcKeyboardOpen by mutableStateOf(false) private var hudOn by mutableStateOf(true) @@ -395,9 +397,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionState.padBridge = bridge // A paired Steam Controller is read over Bluetooth LE, out of lizard mode, as Steam Link reads it. SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } } - // Not when the pad itself is the Steam Controller: that device is then the pad's. - if (SessionPrefs.steamController(this) != SessionPrefs.CONTROLLER_STEAM) - steamController = SteamControllerBle(this).also { it.start() } + steamController = SteamControllerBle(this).also { it.start() } padMotion = com.droiddeck.launcher.input.PadMotion(this) { @Suppress("DEPRECATION") (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0 @@ -589,6 +589,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } }, onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null, + // Only where the session set the pad up to be switched (SessionService: deckPad). + padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null, + onPadType = { id -> + SessionPrefs.setSteamController(this@SessionActivity, id) + com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id)) + padType = id + }, backActionsInverted = backActionsInverted, onBackActionsInverted = { inverted -> SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted) @@ -849,6 +856,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) oscMode = SessionPrefs.oscMode(this) backActionsInverted = SessionPrefs.backActionsInverted(this) + padType = SessionPrefs.steamController(this) } // ── Compositor ────────────────────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index f80c592e3..27abf42c3 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -107,8 +107,20 @@ public class FakeInputWriter { private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits + // Slot 0 only: what the pad is to the Steam client (libfakeinput's PAD_TYPE_*), | PAD_TYPE_SET. + private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44; + private static final int PAD_TYPE_SET = 0x80000000; + public static final int PAD_TYPE_DECK = 0; + public static final int PAD_TYPE_STEAM = 1; + public static final int PAD_TYPE_XBOX = 2; + /** The type chosen this process, written into a ring made after the choice too; -1 for none. */ + private static volatile int padType = -1; + /** The paired controller's state, likewise written into a ring made after it connected. */ + private static volatile boolean tritonConnected = false; private static final int TRITON_OFFSET = RING_SIZE + 64; private static final int TRITON_MAGIC = 0x4e525454; // TTRN + // Whether the paired controller is connected: libfakeinput offers its device only then. + private static final int TRITON_CONNECTED_OFFSET = TRITON_OFFSET + 4; private static final int TRITON_INPUT_SEQ_OFFSET = TRITON_OFFSET + 8; private static final int TRITON_REPORT_ID_OFFSET = TRITON_OFFSET + 16; private static final int TRITON_REPORT_LENGTH_OFFSET = TRITON_OFFSET + 20; @@ -241,7 +253,10 @@ public class FakeInputWriter { data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0); } data.putInt(DECK_CONTROLS_OFFSET, 0); + int type = padType; + data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0); data.putInt(TRITON_OFFSET, TRITON_MAGIC); + data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0); data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L); data.putInt(TRITON_REPORT_ID_OFFSET, 0); data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0); @@ -549,6 +564,38 @@ public class FakeInputWriter { } } + /** + * Switches what the pad is to the Steam client while the session runs ([PAD_TYPE_DECK], + * [PAD_TYPE_STEAM] or [PAD_TYPE_XBOX]); libfakeinput unplugs the old device and plugs in the new. + */ + public static void setPadType(int type) { + padType = type; + RingSlot ringSlot; + synchronized (RING_LOCK) { + ringSlot = RING_SLOTS[0]; + } + if (ringSlot == null) return; + synchronized (ringSlot) { + ByteBuffer ring = ringSlot.data; + if (ring == null) return; + nativeStoreFence(); + ring.putInt(PAD_TYPE_OFFSET, PAD_TYPE_SET | type); + } + } + + /** The paired Steam Controller came or went (SteamControllerBle): its device is plugged in or out. */ + public static void setTritonConnected(int slot, boolean connected) { + tritonConnected = connected; + RingSlot ringSlot = tritonSlot(slot); + if (ringSlot == null) return; + synchronized (ringSlot) { + ByteBuffer ring = ringSlot.data; + if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return; + nativeStoreFence(); + ring.putInt(TRITON_CONNECTED_OFFSET, connected ? 1 : 0); + } + } + /** Publishes a native Triton input report (without the HID report ID byte). */ public static void writeTritonState(int slot, int reportId, byte[] report) { if (report == null || report.length == 0 || report.length > TRITON_REPORT_MAX) return; diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt index a7bea1a1c..34357e48a 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt @@ -92,6 +92,7 @@ class SteamControllerBle(private val context: Context) { queuedWrites.clear() outputReports.clear() connected = false + FakeInputWriter.setTritonConnected(SLOT, false) g.disconnect() g.close() release() @@ -129,6 +130,7 @@ class SteamControllerBle(private val context: Context) { if (g !== gatt || descriptor.characteristic.uuid != input) return Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status)) connected = status == BluetoothGatt.GATT_SUCCESS + FakeInputWriter.setTritonConnected(SLOT, connected) listener?.invoke() if (connected) { refreshLizardMode() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 017a9aa56..b899c42bf 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -457,6 +457,12 @@ object SessionPrefs { fun steamController(context: Context): String = prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() } + /** [steamController]'s choice as libfakeinput's pad type (FakeInputWriter.PAD_TYPE_*). */ + fun padType(id: String): Int = when (id) { + CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM + CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX + else -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK + } /** * Deck mode's performance overlay (mangoapp, beside gamescope): the QAM's Overlay Level draws diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 4338eaeb3..48e194693 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -742,20 +742,17 @@ class SessionService : Service() { // hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read. // Decided only once its sysfs is in place: without it the client would find no Deck, and // with the Deck asked for the pad's own nodes are withdrawn - no controller at all. + // Set up whatever the pad is to be, so the drawer can switch it between a Deck, a Steam + // Controller and an Xbox 360 pad while the session runs (FakeInputWriter.setPadType). val wantsDeck = uinput && SessionState.mode == MODE_STEAM && SessionState.steamUi != "desktop" && - SessionPrefs.steamController(this) != SessionPrefs.CONTROLLER_XBOX360 && !File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists() - // The pad itself as a Steam Controller: the same machinery as the Deck (sysfs, withdrawn - // evdev nodes, Steam Input's virtual pad for games), with the Triton node in the Deck's place. - val padAsTriton = wantsDeck && SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_STEAM // Steam's touch controller rides on the Deck's sysfs and is offered only beside it. val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists() // A paired Triton is exposed as a second native Steam Controller HID, with its original - // report IDs and output path. The handheld's own pad remains the Deck target. - // With the pad as the Steam Controller, that node is the pad's and a paired one is not bridged. - val wantsTriton = padAsTriton || (wantsDeck && SteamControllerBle.hasPairedController(this)) - deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton, deck = !padAsTriton) else emptyList() + // report IDs and output path, beside whatever the handheld's own pad is. + val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this) + deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton) else emptyList() SessionState.deckPad = deckBinds.isNotEmpty() if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller") logControllersAtStart() @@ -766,7 +763,10 @@ class SessionService : Service() { if (SessionState.deckPad) { guest.add("FAKE_EVDEV_DECK=1") if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1") - if (padAsTriton) guest.add("FAKE_EVDEV_TRITON_PAD=1") + // What the pad starts as; the drawer's changes reach libfakeinput through the ring. + val padType = SessionPrefs.steamController(this) + guest.add("FAKE_EVDEV_PAD_TYPE=$padType") + com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType)) // Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share. if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let { guest.add("FAKE_TOUCHCTL_RING=" + it.path) @@ -1781,7 +1781,7 @@ class SessionService : Service() { pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId)) } Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) + - "; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller") + "; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller") } companion object { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt index f0d929b29..c8cd4ccb0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -47,14 +47,15 @@ object SteamDeckPad { // The paired Bluetooth Steam Controller is a separate Triton HID device. It must not inherit // the Deck's identity or report format. Must match fakeinput_steam.cpp (TRITON_HIDRAW_*). private const val TRITON_MINOR = 18 - private const val TRITON_NODE = "hidraw$TRITON_MINOR" private const val TRITON_HID = "$GUEST_DEVICES/bluetooth/0005:28DE:1303.0003" private const val TRITON_SERIAL = "DROIDDECKSC01" - // The handheld's own pad as the Steam Controller (SessionPrefs.CONTROLLER_STEAM): the same node, - // as the wired controller (28de:1302) on a USB device of its own, since it is built in. + // The handheld's own pad as the Steam Controller (SessionPrefs.CONTROLLER_STEAM): the wired + // controller (28de:1302) on a USB device of its own, since it is built in. private const val TRITON_USB = "$GUEST_DEVICES/usb3" private const val TRITON_INTERFACE = "$TRITON_USB/3-1:1.0" private const val TRITON_WIRED_HID = "$TRITON_INTERFACE/0003:28DE:1302.0004" + /** Must match libfakeinput (TRITON_PAD_HIDRAW_*): its own node, so a paired controller keeps 18. */ + private const val TRITON_PAD_MINOR = 19 // Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb, // "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*). @@ -104,7 +105,7 @@ object SteamDeckPad { fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing") /** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */ - fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false, deck: Boolean = true): List { + fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false): List { val base = File(sessionRoot, "sys/deck") val devices = File(base, "devices") val hidrawClass = File(base, "class-hidraw") @@ -144,8 +145,7 @@ object SteamDeckPad { link(File(dir(HIDRAW), "device"), HID) hidrawClass.mkdirs() - // Without the Deck (the pad is the Steam Controller), its device is never listed. - if (deck) link(File(hidrawClass, NODE), HIDRAW) + link(File(hidrawClass, NODE), HIDRAW) // Only the names matter: libudev reads a listing for the subsystems to descend into and // then opens /sys/class/hidraw itself, which is the bind above. @@ -155,9 +155,7 @@ object SteamDeckPad { link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW) val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() } - File(udevData, "c$MAJOR:$MINOR").apply { - if (deck) writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") else delete() - } + File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") if (touch) { write(TOUCH_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=002\nDEVNUM=002\n") @@ -187,12 +185,14 @@ object SteamDeckPad { Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)") } - if (triton) { - // Without the Deck the node is the pad's own, so the wired controller; else the paired one. - val wired = !deck + // A Steam Controller's HID device: the handheld's own pad as the wired one, or the paired + // one on Bluetooth. Both are always described; libfakeinput offers whichever is in use. + fun describeTriton(wired: Boolean) { val hid = if (wired) TRITON_WIRED_HID else TRITON_HID val product = if (wired) "1302" else "1303" val bus = if (wired) "0003" else "0005" + val minor = if (wired) TRITON_PAD_MINOR else TRITON_MINOR + val node = "hidraw$minor" if (wired) { write(TRITON_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1302/100\nTYPE=0/0/0\nBUSNUM=003\nDEVNUM=002\n") write(TRITON_USB, "idVendor", "28de\n") @@ -213,16 +213,18 @@ object SteamDeckPad { "HID_UNIQ=$TRITON_SERIAL\nMODALIAS=hid:b${bus}g0001v000028DEp0000$product\n") File(dir(hid), "report_descriptor").writeBytes(if (wired) TRITON_WIRED_REPORT_DESCRIPTOR else TRITON_REPORT_DESCRIPTOR) link(File(dir(hid), "subsystem"), "/sys/bus/hid") - val hidraw = "$hid/hidraw/$TRITON_NODE" - write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$TRITON_MINOR\nDEVNAME=$TRITON_NODE\n") - write(hidraw, "dev", "$MAJOR:$TRITON_MINOR\n") + val hidraw = "$hid/hidraw/$node" + write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n") + write(hidraw, "dev", "$MAJOR:$minor\n") link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw") link(File(dir(hidraw), "device"), hid) - link(File(hidrawClass, TRITON_NODE), hidraw) - link(File(context.cacheDir, "drm/sys/$MAJOR:$TRITON_MINOR"), hidraw) - File(udevData, "c$MAJOR:$TRITON_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") - Log.i(TAG, "steam controller: /dev/$TRITON_NODE described as Triton (28de:$product)") + link(File(hidrawClass, node), hidraw) + link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw) + File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") + Log.i(TAG, "steam controller: /dev/$node described as Triton (28de:$product)") } + describeTriton(wired = true) + if (triton) describeTriton(wired = false) Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)") listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 03c21fd34..3e14168ce 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -194,6 +194,9 @@ class DrawerActions( val suspendPolicy: String, val steamDownloadsInBackground: Boolean = false, val backActionsInverted: Boolean, + /** What the pad is to Steam (SessionPrefs.CONTROLLER_*), switched live; null hides the row. */ + val padType: String? = null, + val onPadType: (String) -> Unit = {}, val touchMode: String, val touchAuto: String, val fexPreset: String, @@ -419,6 +422,13 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } SessionDrawerPage.CONTROLLER -> { SettingsGroup(stringResource(R.string.drawer_page_controller)) { + a.padType?.let { type -> + ChoiceRow(host, "pad-type", stringResource(R.string.mode_controller), null, + listOf(SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck), + SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam), + SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360)), + type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType) + } ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null, listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)