diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29718e087..bca1b149d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,10 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/licenses/sdl3/LICENSE.txt b/app/src/main/assets/licenses/sdl3/LICENSE.txt new file mode 100644 index 000000000..e9adee448 --- /dev/null +++ b/app/src/main/assets/licenses/sdl3/LICENSE.txt @@ -0,0 +1,18 @@ +Copyright (C) 1997-2026 Sam Lantinga + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + diff --git a/app/src/main/assets/sdl3bridge-build.txt b/app/src/main/assets/sdl3bridge-build.txt new file mode 100644 index 000000000..170b501e9 --- /dev/null +++ b/app/src/main/assets/sdl3bridge-build.txt @@ -0,0 +1,3 @@ +repo=embedded +tag=SdlBridge 63feeac +sdl=3.4.18 diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index ab06f6a56..d06b353b9 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -63,3 +63,6 @@ if(DROIDDECK_UVC_DIR) target_link_libraries(droiddeck_uvc PRIVATE rgb_uvc rgb_usb log) target_link_options(droiddeck_uvc PRIVATE -Wl,-z,max-page-size=16384) endif() + +# sdl3droiddeck: SDL3 and the SDL3 bridge (patch-droiddeck.sh step 30) +add_subdirectory(sdl3bridge) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index c446170fe..4f853326e 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -18,6 +18,9 @@ #include #include #include +#include +#include +#include #include #include @@ -354,8 +357,11 @@ __attribute__((constructor)) static void library_init() { controller_map(); } +static void hookup_ff_from_game(uint16_t slot, int strong, int weak, uint16_t length); + __attribute__((visibility("hidden"))) static void send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) { + hookup_ff_from_game(slot, strong, weak, duration_ms); if (!fake_vibration_enabled()) return; @@ -804,6 +810,12 @@ struct UinputWriter { std::vector pending; uint32_t buttons = 0; int16_t axes[8] = {}; + std::deque ff_queue; // sdl3bridge: game rumble handed to Steam + uint64_t ff_seen = 0; + uint32_t ff_request = 0; + struct ff_effect ff_effect = {}; + uint64_t paced_us = 0; // sdl3bridge: when the game last got a frame (virtual pad pacing) + bool held = false, flush_now = false; }; static std::unordered_map> &uinput_map() { @@ -934,7 +946,12 @@ __attribute__((visibility("hidden"))) static int publish_uinput(UinputWriter &w) return 0; } +static void hookup_meter_virtual_pad(const std::vector &events); +static bool virtual_pad_hold(UinputWriter &w); + __attribute__((visibility("hidden"))) static void flush_uinput(UinputWriter &w) { + if (w.published && !w.flush_now) hookup_meter_virtual_pad(w.pending); + if (w.published && virtual_pad_hold(w)) return; // sdl3bridge: stick / trigger moves paced for the game if (!w.published || w.pending.empty()) { w.pending.clear(); return; @@ -1080,12 +1097,25 @@ ioctl_uinput(UinputWriter &w, ioctl_request_t op, void *argp) { return -1; } return 0; - case _IOC_NR(UI_BEGIN_FF_UPLOAD): + case _IOC_NR(UI_BEGIN_FF_UPLOAD): { + auto *upload = static_cast(argp); + if (!upload || upload->request_id != w.ff_request) { + errno = EINVAL; + return -1; + } + upload->retval = 0; + upload->effect = w.ff_effect; + memset(&upload->old, 0, sizeof(upload->old)); + return 0; + } case _IOC_NR(UI_END_FF_UPLOAD): - case _IOC_NR(UI_BEGIN_FF_ERASE): case _IOC_NR(UI_END_FF_ERASE): - errno = EINVAL; - return -1; + return 0; + case _IOC_NR(UI_BEGIN_FF_ERASE): { + auto *erase = static_cast(argp); + if (erase) erase->retval = 0; + return 0; + } default: Logger::log("uinput: unhandled ioctl %d\n", number); errno = EINVAL; @@ -1142,6 +1172,10 @@ struct DeckHidraw { bool triton = false; bool emulated = false; uint8_t sony = 0; + bool sc1 = false; // sdl3bridge: Steam Controller (2015) + int8_t hookup = -1; // sdl3bridge: Steam Native Hook-up slot + uint32_t hookup_generation = 0; + std::shared_ptr hookup_in; // sdl3bridge: reports waiting for Steam (mailbox) }; struct DeckImu { @@ -1271,7 +1305,9 @@ static bool fake_deck_enabled() { } static constexpr uint32_t PAD_TYPE_DECK = 0, PAD_TYPE_STEAM = 1, PAD_TYPE_XBOX = 2, PAD_TYPE_DS4 = 3, - PAD_TYPE_DUALSENSE = 4, PAD_TYPE_LAST = PAD_TYPE_DUALSENSE; + PAD_TYPE_DUALSENSE = 4, PAD_TYPE_DUALSENSE_EDGE = 5, + PAD_TYPE_STEAM_2015 = 6, + PAD_TYPE_NONE = 7, PAD_TYPE_LAST = PAD_TYPE_NONE; static constexpr uint32_t PAD_TYPE_SET = 0x80000000u; __attribute__((visibility("hidden"))) static uint32_t session_pad_type() { @@ -1383,12 +1419,23 @@ __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char static constexpr const char *DS4_HIDRAW_PATH = "/dev/hidraw20"; static constexpr const char *DUALSENSE_HIDRAW_PATH = "/dev/hidraw21"; -static constexpr unsigned int DS4_HIDRAW_MINOR = 20, DUALSENSE_HIDRAW_MINOR = 21; +static constexpr unsigned int DS4_HIDRAW_MINOR = 20, DUALSENSE_HIDRAW_MINOR = 21, DUALSENSE_EDGE_HIDRAW_MINOR = 22; +static constexpr const char *DUALSENSE_EDGE_HIDRAW_PATH = "/dev/hidraw22"; +static constexpr const char *SC1_HIDRAW_PATH = "/dev/hidraw23"; +static constexpr unsigned int SC1_HIDRAW_MINOR = 23; +static constexpr const char *SC1_NAME = "Valve Software Steam Controller"; +static constexpr const char *SC1_SERIAL = "DROIDDECKSC15"; + +__attribute__((visibility("hidden"))) static bool is_sc1_hidraw_path(const char *pathname) { + return pathname && fake_deck_enabled() && !strcmp(pathname, SC1_HIDRAW_PATH) && + live_pad_type() == PAD_TYPE_STEAM_2015; +} __attribute__((visibility("hidden"))) static int sony_pad_path(const char *pathname) { if (!pathname || !fake_deck_enabled() || strncmp(pathname, "/dev/hidraw2", 12)) return 0; if (!strcmp(pathname, DS4_HIDRAW_PATH)) return live_pad_type() == PAD_TYPE_DS4 ? 4 : 0; if (!strcmp(pathname, DUALSENSE_HIDRAW_PATH)) return live_pad_type() == PAD_TYPE_DUALSENSE ? 5 : 0; + if (!strcmp(pathname, DUALSENSE_EDGE_HIDRAW_PATH)) return live_pad_type() == PAD_TYPE_DUALSENSE_EDGE ? 6 : 0; return 0; } @@ -1401,6 +1448,88 @@ __attribute__((visibility("hidden"))) static bool is_triton_hidraw_path(const ch return pathname && fake_triton_enabled() && !strcmp(pathname, TRITON_HIDRAW_PATH) && triton_connected(); } +// ===================== sdl3bridge: DroidDeck's desktop session ===================== +// On the desktop (FAKE_EVDEV_DESKTOP=1: the deck pad is on there for the SDL3 bridge) Steam is one program among +// others, like on a PC: +// - the Steam client gets the bridge's controller as its own node (the "present as" type, or the real controller +// with Steam Native Hook-up), so DroidDeck's first pad (the same controller) is not shown to it; the other pads +// are, as in the Steam session; +// - games Steam starts get Steam's virtual pads, as in the Steam session (none of DroidDeck's own pads); +// - every other program keeps DroidDeck's pads (Xbox 360 controllers) and does not see the hidraw nodes meant for +// Steam, so no controller shows up twice. +__attribute__((visibility("hidden"))) static bool desktop_session() { + static int on = -1; + if (on < 0) on = getenv("FAKE_EVDEV_DESKTOP") && atoi(getenv("FAKE_EVDEV_DESKTOP")) ? 1 : 0; + return on == 1; +} + +__attribute__((visibility("hidden"))) static bool desktop_word_is_steam(const char *word, size_t length) { + const char *name = word; + for (size_t i = 0; i < length && word[i]; i++) + if (word[i] == '/') name = word + i + 1; + return name + 5 == word + length && !strncmp(name, "steam", 5); +} + +/** Is process [pid] the Steam client? Its name, or -- FEX running the x86-64 client -- the program it runs. */ +__attribute__((visibility("hidden"))) static bool desktop_pid_is_steam(pid_t pid, pid_t *parent) { + char path[64], buf[1024]; + *parent = 0; + snprintf(path, sizeof(path), "/proc/%d/stat", static_cast(pid)); + int fd = static_cast(syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC)); + if (fd < 0) return false; + ssize_t n = syscall(SYS_read, fd, buf, sizeof(buf) - 1); + syscall(SYS_close, fd); + if (n <= 0) return false; + buf[n] = 0; + char *open_paren = strchr(buf, '('), *close_paren = strrchr(buf, ')'); + if (!open_paren || !close_paren || close_paren < open_paren) return false; + int ppid = 0; + if (sscanf(close_paren + 1, " %*c %d", &ppid) == 1) *parent = static_cast(ppid); + if (desktop_word_is_steam(open_paren + 1, static_cast(close_paren - open_paren - 1))) return true; + snprintf(path, sizeof(path), "/proc/%d/cmdline", static_cast(pid)); + fd = static_cast(syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC)); + if (fd < 0) return false; + n = syscall(SYS_read, fd, buf, sizeof(buf) - 1); + syscall(SYS_close, fd); + if (n <= 0) return false; + buf[n] = 0; + const char *word = buf, *end = buf + n; + for (int i = 0; i < 2 && word < end; i++) { // argv[0], and argv[1] for FEX ("FEX /.../steamrt64/steam") + size_t length = strnlen(word, static_cast(end - word)); + if (desktop_word_is_steam(word, length)) return true; + word += length + 1; + } + return false; +} + +/** Started by the Steam client (a game, or anything it runs): looked up once, through the parent processes. */ +__attribute__((visibility("hidden"))) static bool started_by_steam() { + static int known = 0; + if (known) return known > 0; + bool found = false; + pid_t pid = getppid(); + for (int depth = 0; pid > 1 && depth < 64 && !found; depth++) { + pid_t parent = 0; + found = desktop_pid_is_steam(pid, &parent); + pid = parent; + } + known = found ? 1 : -1; + return found; +} + +/** A program started on the desktop: neither the Steam client nor something Steam started. */ +__attribute__((visibility("hidden"))) static bool desktop_program() { + return desktop_session() && !process_is_steam_client() && !started_by_steam(); +} + +/** /dev/hidraw16..27 (DroidDeck's nodes for Steam) are not there for a desktop program. */ +__attribute__((visibility("hidden"))) static bool desktop_hidden_hidraw(const char *pathname) { + if (!pathname || strncmp(pathname, "/dev/hidraw", 11) || !desktop_session()) return false; + char *end = nullptr; + long minor = strtol(pathname + 11, &end, 10); + return end != pathname + 11 && !*end && minor >= 16 && minor <= 27 && desktop_program(); +} + __attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const char *pathname) { if (!fake_deck_enabled() || !pathname || strncmp(pathname, "/dev/input/", 11)) return false; if (live_pad_type() == PAD_TYPE_XBOX) return false; @@ -1408,6 +1537,7 @@ __attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const ch if (strncmp(event, "event", 5) && strncmp(event, "js", 2)) return false; int number = get_event_number(event); if (number < 0 || number >= UINPUT_EVENT_BASE) return false; + if (desktop_session()) return process_is_steam_client() ? number == 0 : started_by_steam(); // sdl3bridge return number == 0 || !process_is_steam_client(); } @@ -1477,6 +1607,10 @@ build_deck_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int3 put16(report + 54, stick(-axes[3])); } +// sdl3bridge: Steam reads the newest reports of every controller (mailbox, see HookupQueue) +static void pad_mailbox_attach(DeckHidraw &pad); +static int pad_mailbox_offer(DeckHidraw &pad, const uint8_t *data, uint32_t length); + __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg) { auto *deck = static_cast *>(arg); std::shared_ptr self = *deck; @@ -1494,9 +1628,7 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg) read_deck_extras(*self, extras); uint8_t report[DECK_REPORT_BYTES]; build_deck_report(report, ++packet, buttons, axes, extras); - if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && - errno != EAGAIN && errno != EWOULDBLOCK) - break; + if (pad_mailbox_offer(*self, report, sizeof(report)) < 0) break; sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US)); } syscall(SYS_close, self->peer); @@ -1519,8 +1651,7 @@ __attribute__((visibility("hidden"))) static void put_sony_touch(uint8_t *at, So bool down, int16_t x, int16_t y, int height) { if (down && !touch.down[side]) touch.id[side] = touch.next_id++ & 0x7f; touch.down[side] = down; - int half = SONY_TOUCH_WIDTH / 2; - int px = side * half + (static_cast(x) + 32768) * (half - 1) / 65535; + int px = (static_cast(x) + 32768) * (SONY_TOUCH_WIDTH - 1) / 65535; int py = (32767 - static_cast(y)) * (height - 1) / 65535; at[0] = static_cast(touch.id[side] | (down ? 0 : 0x80)); at[1] = static_cast(px & 0xff); @@ -1548,6 +1679,19 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint64_t elap (buttons & 256 ? 0x40 : 0) | (buttons & 512 ? 0x80 : 0); bool click = extras.controls & (DECK_EXTRA_LPAD_CLICK | DECK_EXTRA_RPAD_CLICK); uint8_t system = (buttons & 1024 ? 0x01 : 0) | (click ? 0x02 : 0); + // DualSense / Edge mute (microphone) button -> bit 0x04 of this byte. The bridge maps the mute + // button to the Deck QAM bit; DualShock 4 (model 4) has no mute and reuses 0x04 for its counter. + if (model != 4 && (buttons & SNAPSHOT_QAM_BIT)) system |= 0x04; + // DualSense Edge extra buttons: SDL/Steam read all four from this byte on PID 0x0df2 -- + // 0x10 left Fn, 0x20 right Fn, 0x40 left back paddle, 0x80 right back paddle. + // model 6 == the Edge node; plain DualSense (5) and DS4 (4) are left untouched. Deck grips map 1:1: + // L4 -> left paddle, R4 -> right paddle, L5 -> left Fn, R5 -> right Fn. + if (model == 6) { + if (extras.controls & DECK_EXTRA_L4) system |= 0x40; + if (extras.controls & DECK_EXTRA_R4) system |= 0x80; + if (extras.controls & DECK_EXTRA_L5) system |= 0x10; + if (extras.controls & DECK_EXTRA_R5) system |= 0x20; + } uint8_t left_trigger = static_cast(std::max(0, std::min(255, axes[5]))); uint8_t right_trigger = static_cast(std::max(0, std::min(255, axes[4]))); int16_t gyro[3] = {extras.gyro[0], extras.gyro[2], static_cast(-extras.gyro[1])}; @@ -1594,7 +1738,7 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg) auto *holder = static_cast *>(arg); std::shared_ptr self = *holder; delete holder; - uint32_t type = self->sony == 4 ? PAD_TYPE_DS4 : PAD_TYPE_DUALSENSE; + uint32_t type = self->sony == 4 ? PAD_TYPE_DS4 : self->sony == 6 ? PAD_TYPE_DUALSENSE_EDGE : PAD_TYPE_DUALSENSE; uint32_t packet = 0; SnapshotState snap; uint32_t buttons = 0; @@ -1610,9 +1754,7 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg) read_deck_extras(*self, extras); uint8_t report[SONY_REPORT_BYTES]; build_sony_report(report, self->sony, ++packet, monotonic_us() - start_us, buttons, axes, extras, touch); - if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && - errno != EAGAIN && errno != EWOULDBLOCK) - break; + if (pad_mailbox_offer(*self, report, sizeof(report)) < 0) break; sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US)); } syscall(SYS_close, self->peer); @@ -1620,8 +1762,119 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg) return nullptr; } -__attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uint8_t sony = 0) { - const char *path = sony == 4 ? DS4_HIDRAW_PATH : sony == 5 ? DUALSENSE_HIDRAW_PATH : DECK_HIDRAW_PATH; +// sdl3bridge: Steam Controller (2015) state packet -- ValveInReport_t type 1 (ID_CONTROLLER_STATE), +// laid out as SDL's ValveControllerStatePacket_t: buttons in bytes 8..10, analog triggers in bytes 11/12, +// left axes 16/18 (stick OR left pad, see SC1_FINGER_LEFT), right pad 20/22, triggers 24/26, accel 28.., +// gyro 34.., orientation quaternion 40... +static constexpr uint32_t SC1_RT = 0x000001, SC1_LT = 0x000002, SC1_RB = 0x000004, SC1_LB = 0x000008, + SC1_Y = 0x000010, SC1_B = 0x000020, SC1_X = 0x000040, SC1_A = 0x000080, + SC1_UP = 0x000100, SC1_RIGHT = 0x000200, SC1_LEFT = 0x000400, SC1_DOWN = 0x000800, + SC1_BACK = 0x001000, SC1_STEAM = 0x002000, SC1_START = 0x004000, + SC1_LGRIP = 0x008000, SC1_RGRIP = 0x010000, SC1_LPAD_CLICK = 0x020000, + SC1_RPAD_CLICK = 0x040000, SC1_FINGER_LEFT = 0x080000, SC1_FINGER_RIGHT = 0x100000, + SC1_STICK_CLICK = 0x400000, SC1_PAD_AND_STICK = 0x800000; +static constexpr int32_t SC1_STICK_ACTIVE = 2000; +static constexpr int16_t SC1_DPAD_EDGE = 24000; + +__attribute__((visibility("hidden"))) static void +build_sc1_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int32_t *axes, const DeckExtras &extras) { + memset(report, 0, DECK_REPORT_BYTES); + report[0] = 0x01; // report version 1 + report[2] = 0x01; // ID_CONTROLLER_STATE + report[3] = 0x3c; + put32(report + 4, packet); + // Snapshot order: A B X Y L1 R1 View Menu L3 R3 Steam. No right stick on this controller, so no R3. + static const uint32_t kButtonBits[11] = {SC1_A, SC1_B, SC1_X, SC1_Y, SC1_LB, SC1_RB, + SC1_BACK, SC1_START, SC1_STICK_CLICK, 0, SC1_STEAM}; + uint32_t bits = 0; + for (int i = 0; i < 11; i++) + if (buttons & (1u << i)) bits |= kButtonBits[i]; + int32_t left_trigger = std::max(0, std::min(255, axes[5])), right_trigger = std::max(0, std::min(255, axes[4])); + if (left_trigger >= 250) bits |= SC1_LT; + if (right_trigger >= 250) bits |= SC1_RT; + uint32_t controls = extras.controls; + if (controls & (DECK_EXTRA_L4 | DECK_EXTRA_L5)) bits |= SC1_LGRIP; + if (controls & (DECK_EXTRA_R4 | DECK_EXTRA_R5)) bits |= SC1_RGRIP; + bool left_pad = controls & DECK_EXTRA_LPAD_TOUCH, right_pad = controls & DECK_EXTRA_RPAD_TOUCH; + if (controls & DECK_EXTRA_LPAD_CLICK) bits |= SC1_LPAD_CLICK; + if (controls & DECK_EXTRA_RPAD_CLICK) bits |= SC1_RPAD_CLICK; + int32_t left_x = extras.pads[0], left_y = extras.pads[1]; + // The real controller has no D-pad: its D-pad is a click on the left trackpad's edge. + int32_t hat_x = axes[6], hat_y = axes[7]; + if (hat_x || hat_y) { + if (hat_y < 0) bits |= SC1_UP; + if (hat_y > 0) bits |= SC1_DOWN; + if (hat_x < 0) bits |= SC1_LEFT; + if (hat_x > 0) bits |= SC1_RIGHT; + left_pad = true; + left_x = hat_x < 0 ? -SC1_DPAD_EDGE : hat_x > 0 ? SC1_DPAD_EDGE : 0; + left_y = hat_y < 0 ? SC1_DPAD_EDGE : hat_y > 0 ? -SC1_DPAD_EDGE : 0; + bits |= SC1_LPAD_CLICK; + } + auto clamp16 = [](int32_t value) { return std::max(-32767, std::min(32767, value)); }; + int32_t stick_x = clamp16(axes[0]), stick_y = clamp16(-axes[1]); + bool stick = abs(stick_x) > SC1_STICK_ACTIVE || abs(stick_y) > SC1_STICK_ACTIVE; + // The stick and the left pad share one axis pair; SC1_FINGER_LEFT says which one this packet carries, + // and SC1_PAD_AND_STICK says both are in use (the firmware then alternates them packet by packet). + int32_t axis_x = stick_x, axis_y = stick_y; + if (left_pad && stick) { + bits |= SC1_PAD_AND_STICK; + if (packet & 1) { + bits |= SC1_FINGER_LEFT; + axis_x = left_x; + axis_y = left_y; + } + } else if (left_pad) { + bits |= SC1_FINGER_LEFT; + axis_x = left_x; + axis_y = left_y; + } + if (right_pad) bits |= SC1_FINGER_RIGHT; + report[8] = static_cast(bits); + report[9] = static_cast(bits >> 8); + report[10] = static_cast(bits >> 16); + report[11] = static_cast(left_trigger); + report[12] = static_cast(right_trigger); + put16(report + 16, clamp16(axis_x)); + put16(report + 18, clamp16(axis_y)); + put16(report + 20, right_pad ? extras.pads[2] : 0); + put16(report + 22, right_pad ? extras.pads[3] : 0); + put16(report + 24, left_trigger * 32767 / 255); + put16(report + 26, right_trigger * 32767 / 255); + for (int i = 0; i < 3; i++) { + put16(report + 28 + i * 2, extras.accel[i]); + put16(report + 34 + i * 2, extras.gyro[i]); + } + put16(report + 40, 32767); // orientation quaternion: identity (Steam uses the raw gyro) +} + +__attribute__((visibility("hidden"))) static void *sc1_report_thread(void *arg) { + auto *holder = static_cast *>(arg); + std::shared_ptr self = *holder; + delete holder; + uint32_t packet = 0; + SnapshotState snap; + uint32_t buttons = 0; + int32_t axes[8] = {}; + DeckExtras extras; + while (live_pad_type() == PAD_TYPE_STEAM_2015) { + if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) { + buttons = snap.buttons; + memcpy(axes, snap.axes, sizeof(axes)); + } + read_deck_extras(*self, extras); + uint8_t report[DECK_REPORT_BYTES]; + build_sc1_report(report, ++packet, buttons, axes, extras); + if (pad_mailbox_offer(*self, report, sizeof(report)) < 0) break; + sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US)); + } + syscall(SYS_close, self->peer); + munmap(self->ring, self->mapping_size); + return nullptr; +} + +__attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uint8_t sony = 0, bool sc1 = false) { + const char *path = sc1 ? SC1_HIDRAW_PATH : sony == 4 ? DS4_HIDRAW_PATH : sony == 5 ? DUALSENSE_HIDRAW_PATH : sony == 6 ? DUALSENSE_EDGE_HIDRAW_PATH : DECK_HIDRAW_PATH; static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); if (!process_is_steam_client()) { Logger::log("deck: %s refused: not the Steam client\n", path); @@ -1665,6 +1918,8 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uin deck->ring = static_cast(mapping); deck->mapping_size = mapping_size; deck->sony = sony; + deck->sc1 = sc1; + pad_mailbox_attach(*deck); { std::lock_guard guard(controller_mutex()); deck_map()[pair[0]] = deck; @@ -1672,7 +1927,7 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uin } pthread_t thread; auto *arg = new std::shared_ptr(deck); - if (pthread_create(&thread, nullptr, sony ? sony_report_thread : deck_report_thread, arg) != 0) { + if (pthread_create(&thread, nullptr, sony ? sony_report_thread : sc1 ? sc1_report_thread : deck_report_thread, arg) != 0) { delete arg; std::lock_guard guard(controller_mutex()); deck_map().erase(pair[0]); @@ -1685,7 +1940,7 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uin } pthread_detach(thread); Logger::log("deck: %s opened as fd %d%s\n", path, pair[0], - sony == 4 ? " (a DualShock 4)" : sony == 5 ? " (a DualSense)" : ""); + sony == 4 ? " (a DualShock 4)" : sony == 5 ? " (a DualSense)" : sony == 6 ? " (a DualSense Edge)" : sc1 ? " (a Steam Controller 2015)" : ""); return pair[0]; } @@ -1723,9 +1978,7 @@ __attribute__((visibility("hidden"))) static void *triton_report_thread(void *ar 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 && - errno != EAGAIN && errno != EWOULDBLOCK) - break; + if (pad_mailbox_offer(*self, report, static_cast(length + 1)) < 0) break; } sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US)); } @@ -1820,9 +2073,7 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg) uint8_t report[TRITON_REPORT_ID_BYTES + TRITON_STATE_BYTES]; report[0] = TRITON_STATE_WIRED; build_triton_report(report + 1, ++packet, monotonic_us() - start_us, buttons, axes, extras); - if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && - errno != EAGAIN && errno != EWOULDBLOCK) - break; + if (pad_mailbox_offer(*self, report, sizeof(report)) < 0) break; sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US)); } syscall(SYS_close, self->peer); @@ -1874,6 +2125,7 @@ __attribute__((visibility("hidden"))) static int open_triton_hidraw(int flags, b triton->mapping_size = mapping_size; triton->triton = true; triton->emulated = emulated; + pad_mailbox_attach(*triton); { std::lock_guard guard(controller_mutex()); deck_map()[pair[0]] = triton; @@ -2173,12 +2425,15 @@ __attribute__((visibility("hidden"))) static void touch_uevent(const char *actio 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 *SC1_DEVPATH = "/devices/droiddeck/usb7/7-1:1.2/0003:28DE:1102.0008/hidraw/hidraw23"; static constexpr const char *PAD_EVDEV_DEVPATH = "/devices/virtual/input/droiddeck0/event0"; static constexpr const char *DS4_DEVPATH = "/devices/droiddeck/usb4/4-1:1.3/0003:054C:09CC.0005/hidraw/hidraw20"; static constexpr const char *DUALSENSE_DEVPATH = "/devices/droiddeck/usb5/5-1:1.3/0003:054C:0CE6.0006/hidraw/hidraw21"; +static constexpr const char *DUALSENSE_EDGE_DEVPATH = "/devices/droiddeck/usb6/6-1:1.3/0003:054C:0DF2.0007/hidraw/hidraw22"; 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_NONE) return 0; // sdl3bridge: no pad (real controllers handed to Steam) 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_DS4) @@ -2186,6 +2441,11 @@ __attribute__((visibility("hidden"))) static int pad_type_uevent(uint32_t type, if (type == PAD_TYPE_DUALSENSE) return inject_uevent(action, DUALSENSE_DEVPATH, "hidraw", DUALSENSE_HIDRAW_PATH, DECK_HIDRAW_MAJOR, DUALSENSE_HIDRAW_MINOR); + if (type == PAD_TYPE_DUALSENSE_EDGE) + return inject_uevent(action, DUALSENSE_EDGE_DEVPATH, "hidraw", DUALSENSE_EDGE_HIDRAW_PATH, DECK_HIDRAW_MAJOR, + DUALSENSE_EDGE_HIDRAW_MINOR); + if (type == PAD_TYPE_STEAM_2015) + return inject_uevent(action, SC1_DEVPATH, "hidraw", SC1_HIDRAW_PATH, DECK_HIDRAW_MAJOR, SC1_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); @@ -2207,15 +2467,18 @@ __attribute__((visibility("hidden"))) static void poke_hidraw_watchers(unsigned __attribute__((visibility("hidden"))) static unsigned pad_type_minor(uint32_t type) { return type == PAD_TYPE_DS4 ? DS4_HIDRAW_MINOR : type == PAD_TYPE_DUALSENSE ? DUALSENSE_HIDRAW_MINOR - : type == PAD_TYPE_STEAM ? TRITON_PAD_HIDRAW_MINOR : DECK_HIDRAW_MINOR; + : type == PAD_TYPE_DUALSENSE_EDGE ? DUALSENSE_EDGE_HIDRAW_MINOR + : type == PAD_TYPE_STEAM ? TRITON_PAD_HIDRAW_MINOR + : type == PAD_TYPE_STEAM_2015 ? SC1_HIDRAW_MINOR : DECK_HIDRAW_MINOR; } __attribute__((visibility("hidden"))) static void *pad_type_thread(void *) { static const char *kNames[] = {"Steam Deck controller", "Steam Controller", "Xbox 360 controller", - "DualShock 4", "DualSense"}; + "DualShock 4", "DualSense", "DualSense Edge", + "Steam Controller (2015)", "no pad"}; uint32_t was = live_pad_type(); bool paired = fake_triton_enabled() && triton_connected(); - Logger::log("pad: a %s at start%s\n", kNames[was % 5], paired ? ", the paired Steam Controller connected" : ""); + Logger::log("pad: a %s at start%s\n", kNames[was % 8], paired ? ", the paired Steam Controller connected" : ""); for (;;) { struct timespec interval = {0, 50 * 1000 * 1000}; nanosleep(&interval, nullptr); @@ -2393,6 +2656,13 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { int left = buf[6] | buf[7] << 8, right = buf[8] | buf[9] << 8; send_vibration(left, right, left || right ? 1000 : 0, 0); } + // Steam rumbles / identifies a 2015 Steam Controller with trackpad haptic pulses (0x8f: side, + // on-us, off-us, repeat). Forward the long ones as rumble; short ones are trackpad ticks. + if (deck.sc1 && buf[1] == 0x8f && size >= 10) { + int on_us = buf[4] | buf[5] << 8, off_us = buf[6] | buf[7] << 8, repeat = buf[8] | buf[9] << 8; + long total_ms = static_cast(on_us + off_us) * std::max(1, repeat) / 1000; + if (total_ms >= 20) send_vibration(0, 40000, static_cast(std::min(1000L, total_ms)), 0); + } if (buf[1] == DECK_SET_SETTINGS_VALUES && size >= 3) { int imu_mode = deck_imu_mode_change(buf, size); if (imu_mode >= 0) publish_imu_mode(deck, imu_mode); @@ -2409,13 +2679,17 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { 0x00, 0x0b, 0xa0, 0x0f, 0x00, 0x00, 0x0d, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x0e}; memcpy(reply, kAttributes, sizeof(kAttributes)); + if (deck.sc1) { // ATTRIB_PRODUCT_ID = 0x1102 + reply[4] = 0x02; + reply[5] = 0x11; + } break; } case DECK_GET_STRING_ATTRIBUTE: reply[1] = DECK_GET_STRING_ATTRIBUTE; reply[2] = 0x14; reply[3] = 0x01; - memcpy(reply + 4, DECK_SERIAL, strlen(DECK_SERIAL)); + memcpy(reply + 4, deck.sc1 ? SC1_SERIAL : DECK_SERIAL, strlen(deck.sc1 ? SC1_SERIAL : DECK_SERIAL)); break; case DECK_GET_CHIP_ID: reply[1] = DECK_GET_CHIP_ID; @@ -2464,11 +2738,11 @@ __attribute__((visibility("hidden"))) static int sony_feature(const DeckHidraw & uint8_t reply[64] = {}; reply[0] = id; size_t length = size; - if ((pad.sony == 4 && id == 0x02) || (pad.sony == 5 && id == 0x05)) { + if ((pad.sony == 4 && id == 0x02) || ((pad.sony == 5 || pad.sony == 6) && id == 0x05)) { put_sony_calibration(reply); - } else if ((pad.sony == 4 && (id == 0x12 || id == 0x81)) || (pad.sony == 5 && id == 0x09)) { + } else if ((pad.sony == 4 && (id == 0x12 || id == 0x81)) || ((pad.sony == 5 || pad.sony == 6) && id == 0x09)) { for (int i = 0; i < 6; i++) reply[1 + i] = SONY_MAC[5 - i]; - } else if (pad.sony == 5 && id == 0x20) { + } else if ((pad.sony == 5 || pad.sony == 6) && id == 0x20) { reply[44] = 0x30; reply[45] = 0x04; } @@ -2480,19 +2754,35 @@ __attribute__((visibility("hidden"))) static int sony_feature(const DeckHidraw & __attribute__((visibility("hidden"))) static void sony_output(const DeckHidraw &pad, const uint8_t *data, size_t size) { if (!data || size < 6) return; - int weak, strong; + // sdl3bridge: sony rumble v2 + static int last_strong = -1, last_weak = -1; + static unsigned logged = 0; + int weak = 0, strong = 0; if (pad.sony == 4 && data[0] == 0x05) { - if (!(data[1] & 0x01)) return; + if (!(data[1] & 0x01)) return; // DS4: motors untouched by this report weak = data[4]; strong = data[5]; - } else if (pad.sony == 5 && data[0] == 0x02) { - if (!(data[1] & 0x03)) return; - weak = data[3]; - strong = data[4]; + } else if ((pad.sony == 5 || pad.sony == 6) && data[0] == 0x02) { + // [1] enable bits1 (0x01 rumble emulation, 0x02 haptics off), [3] right/weak, [4] left/strong, + // [39] enable bits3 (0x04 = improved rumble, fw >= 2.24 / Edge). Full state is re-sent every + // report, and the bits are cleared once the motors stop: no bits = motors off. + bool rumble = (data[1] & 0x03) || (size > 39 && (data[39] & 0x04)); + if (rumble) { + weak = data[3]; + strong = data[4]; + } } else { return; } - send_vibration(strong * 257, weak * 257, strong || weak ? 2000 : 0, 0); + if (strong == last_strong && weak == last_weak) return; // LED / repeat report: nothing changed + if (logged < 40) { + logged++; + Logger::log("sony: out 0x%02x bits %02x/%02x motors %d/%d\n", data[0], data[1], + size > 39 ? data[39] : 0, strong, weak); + } + last_strong = strong; + last_weak = weak; + send_vibration(strong * 257, weak * 257, strong || weak ? RUMBLE_HOLD_MS : 0, 0); } __attribute__((visibility("hidden"))) static int ioctl_sony(DeckHidraw &pad, ioctl_request_t op, void *argp) { @@ -2518,14 +2808,14 @@ __attribute__((visibility("hidden"))) static int ioctl_sony(DeckHidraw &pad, ioc uint32_t bustype; int16_t vendor; int16_t product; - } info = {BUS_USB, static_cast(0x054c), static_cast(pad.sony == 4 ? 0x09cc : 0x0ce6)}; + } info = {BUS_USB, static_cast(0x054c), static_cast(pad.sony == 4 ? 0x09cc : pad.sony == 6 ? 0x0df2 : 0x0ce6)}; memcpy(argp, &info, sizeof(info)); return 0; } case 0x04: return copy_ioctl_string(op, argp, pad.sony == 4 ? "Sony Interactive Entertainment Wireless Controller" - : "Sony Interactive Entertainment DualSense Wireless Controller"); - case 0x05: return copy_ioctl_string(op, argp, pad.sony == 4 ? "usb-droiddeck-4/input3" : "usb-droiddeck-5/input3"); + : pad.sony == 6 ? "Sony Interactive Entertainment DualSense Edge Wireless Controller" : "Sony Interactive Entertainment DualSense Wireless Controller"); + case 0x05: return copy_ioctl_string(op, argp, pad.sony == 4 ? "usb-droiddeck-4/input3" : pad.sony == 6 ? "usb-droiddeck-6/input3" : "usb-droiddeck-5/input3"); case 0x08: return copy_ioctl_string(op, argp, "5e:43:4b:44:44:02"); case 0x06: return static_cast(_IOC_SIZE(op)); @@ -2558,13 +2848,13 @@ ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) { uint32_t bustype; int16_t vendor; int16_t product; - } info = {BUS_USB, static_cast(0x28de), static_cast(0x1205)}; + } info = {BUS_USB, static_cast(0x28de), static_cast(deck.sc1 ? 0x1102 : 0x1205)}; memcpy(argp, &info, sizeof(info)); return 0; } - case 0x04: return copy_ioctl_string(op, argp, DECK_NAME); - case 0x05: return copy_ioctl_string(op, argp, "usb-droiddeck-1/input2"); - case 0x08: return copy_ioctl_string(op, argp, DECK_SERIAL); + case 0x04: return copy_ioctl_string(op, argp, deck.sc1 ? SC1_NAME : DECK_NAME); + case 0x05: return copy_ioctl_string(op, argp, deck.sc1 ? "usb-droiddeck-7/input2" : "usb-droiddeck-1/input2"); + case 0x08: return copy_ioctl_string(op, argp, deck.sc1 ? SC1_SERIAL : DECK_SERIAL); case 0x06: return deck_feature(deck, op, static_cast(argp), true); case 0x07: return deck_feature(deck, op, static_cast(argp), false); default: @@ -2800,6 +3090,1547 @@ __attribute__((visibility("hidden"))) static int ioctl_triton(DeckHidraw &triton } } +// ===================== sdl3bridge: Steam Native Hook-up ===================== +// The SDL3 bridge app passes a USB controller through raw: its real USB identity (vendor/product id, +// name, serial, interface, HID report descriptor) and every HID report, both ways. Here each such +// controller becomes a real-looking /dev/hidraw24..27 node, so Steam's own drivers recognise it exactly +// as on a PC (DualSense / Edge, DualShock 4, Switch / Switch 2, Xbox, Flydigi, 8BitDo, Steam Controller, +// ...) and use all of its features. DroidDeck's app side (HookupBridge.kt) fills the shared file named by +// FAKE_HOOKUP_SHM; this side serves Steam's open/read/write/ioctl calls and announces hot-plug to udev. +// +// Game rumble: games rumble Steam's virtual pad; DroidDeck normally handles that itself. While a hooked-up +// controller is present (ff_to_steam), the effect is handed to Steam instead -- through the virtual pad's +// uinput force-feedback protocol -- so Steam rumbles the real controller with its own driver, like on a PC. +static constexpr uint32_t HOOKUP_MAGIC = 0x4b4f4f48; // "HOOK" +static constexpr int HOOKUP_SLOTS = 4; +static constexpr int HOOKUP_IN_FIFO = 256; +static constexpr int HOOKUP_OUT_FIFO = 64; +static constexpr int HOOKUP_REPORT_MAX = 128; +static constexpr int HOOKUP_DESC_MAX = 4096; +static constexpr int HOOKUP_FF_NODES = UINPUT_EVENT_LIMIT - UINPUT_EVENT_BASE; +static constexpr unsigned HOOKUP_MINOR_BASE = 24; +static constexpr size_t HOOKUP_HEADER_SIZE = 4096; +static constexpr size_t HOOKUP_SLOT_SIZE = 53248; +static constexpr size_t HOOKUP_SIZE = HOOKUP_HEADER_SIZE + HOOKUP_SLOTS * HOOKUP_SLOT_SIZE; +static constexpr int HOOKUP_FEATURE_TIMEOUT_MS = 1000; +static constexpr int HOOKUP_OUTPUT_WAIT_MS = 50; // feature writes (settings): wait this long for room +static constexpr int HOOKUP_REPORT_WAIT_MS = 4; // output reports (rumble, lights): newer ones follow, so drop +static constexpr int HOOKUP_FUTEX_WAIT = 0; // FUTEX_WAIT, shared: the writer is the SDL3 bridge, another process + +struct HookupReport { + uint64_t seq; + uint32_t length; + uint32_t kind; // output fifo: 0 output report, 1 feature write; input fifo: SDL3Bridge's read time (us, 0 = none) + uint8_t data[HOOKUP_REPORT_MAX]; +}; +struct HookupSlot { + uint32_t present; + uint32_t generation; + uint16_t vendor, product, version, bus; + int32_t iface; + uint32_t desc_len; + char name[128]; + char serial[64]; + char manufacturer[64]; + char product_name[64]; + uint64_t in_write; + uint64_t out_write; + uint64_t out_read; + uint64_t get_request; + uint64_t get_reply; + uint32_t get_report_id; // low byte: report id; bit 8: input report (HIDIOCGINPUT) instead of feature + int32_t get_length; + uint8_t get[HOOKUP_REPORT_MAX]; + uint64_t direct_ms; // SDL3 bridge heartbeat (CLOCK_MONOTONIC ms) while it writes this slot itself (fast path) + uint32_t rate_hz; // the bridge's Poll rate: hand Steam at most this many reports a second (0 = every one) + uint8_t reserved[1024 - 532]; + uint8_t desc[HOOKUP_DESC_MAX]; + HookupReport in[HOOKUP_IN_FIFO]; + HookupReport out[HOOKUP_OUT_FIFO]; +}; +struct HookupFF { + uint64_t seq; + int32_t strong; + int32_t weak; + uint32_t length; + uint32_t play; +}; +struct HookupHeader { + uint32_t magic; + uint32_t version; + uint32_t ff_to_steam; + uint32_t reserved0; + uint64_t steam_ms; // the Steam client's heartbeat (CLOCK_MONOTONIC ms): DroidDeck's desktop knows Steam runs + uint32_t reserved[10]; + HookupFF ff[HOOKUP_FF_NODES]; +}; +static_assert(sizeof(HookupReport) == 144, "HookupReport layout"); +static_assert(offsetof(HookupSlot, name) == 24, "hookup name offset"); +static_assert(offsetof(HookupSlot, serial) == 152, "hookup serial offset"); +static_assert(offsetof(HookupSlot, manufacturer) == 216, "hookup manufacturer offset"); +static_assert(offsetof(HookupSlot, product_name) == 280, "hookup product offset"); +static_assert(offsetof(HookupSlot, in_write) == 344, "hookup in_write offset"); +static_assert(offsetof(HookupSlot, get_report_id) == 384, "hookup get_report_id offset"); +static_assert(offsetof(HookupSlot, get) == 392, "hookup get offset"); +static_assert(offsetof(HookupSlot, direct_ms) == 520, "hookup direct_ms offset"); +static_assert(offsetof(HookupSlot, rate_hz) == 528, "hookup rate_hz offset"); +static_assert(offsetof(HookupSlot, desc) == 1024, "hookup desc offset"); +static_assert(offsetof(HookupSlot, in) == 5120, "hookup in offset"); +static_assert(offsetof(HookupSlot, out) == 41984, "hookup out offset"); +static_assert(sizeof(HookupSlot) <= HOOKUP_SLOT_SIZE, "hookup slot size"); +static_assert(offsetof(HookupHeader, steam_ms) == 16, "hookup steam_ms offset"); +static_assert(offsetof(HookupHeader, ff) == 64, "hookup ff offset"); +static_assert(sizeof(HookupFF) == 24, "hookup ff entry"); +static_assert(sizeof(HookupHeader) <= HOOKUP_HEADER_SIZE, "hookup header size"); + +__attribute__((visibility("hidden"))) static uint8_t *hookup_shm() { + static uint8_t *mapped = nullptr; + static std::once_flag once; + std::call_once(once, [] { + const char *path = getenv("FAKE_HOOKUP_SHM"); + if (!path || !*path) return; + static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + int fd = my_open(path, O_RDWR | O_CLOEXEC); + if (fd < 0) { + Logger::log("hookup: %s not openable (%s)\n", path, strerror(errno)); + return; + } + struct stat st; + void *mapping = MAP_FAILED; + if (fstat(fd, &st) == 0 && static_cast(st.st_size) >= HOOKUP_SIZE) + mapping = mmap(nullptr, HOOKUP_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + syscall(SYS_close, fd); + if (mapping == MAP_FAILED || static_cast(mapping)->magic != HOOKUP_MAGIC) { + Logger::log("hookup: %s %s\n", path, mapping == MAP_FAILED ? "could not be mapped" : "has no valid header"); + if (mapping != MAP_FAILED) munmap(mapping, HOOKUP_SIZE); + return; + } + mapped = static_cast(mapping); + }); + return mapped; +} + +__attribute__((visibility("hidden"))) static HookupHeader *hookup_header() { + return reinterpret_cast(hookup_shm()); +} + +__attribute__((visibility("hidden"))) static HookupSlot *hookup_slot(int i) { + uint8_t *base = hookup_shm(); + if (!base || i < 0 || i >= HOOKUP_SLOTS) return nullptr; + return reinterpret_cast(base + HOOKUP_HEADER_SIZE + static_cast(i) * HOOKUP_SLOT_SIZE); +} + +__attribute__((visibility("hidden"))) static bool hookup_present(int i) { + HookupSlot *s = hookup_slot(i); + return s && __atomic_load_n(&s->present, __ATOMIC_ACQUIRE) != 0; +} + +/** /dev/hidraw24..27 -> slot 0..3 when that slot holds a controller, else -1. */ +__attribute__((visibility("hidden"))) static int hookup_path_slot(const char *pathname) { + if (!pathname || !fake_deck_enabled() || strncmp(pathname, "/dev/hidraw2", 12)) return -1; + for (int i = 0; i < HOOKUP_SLOTS; i++) { + char path[32]; + snprintf(path, sizeof(path), "/dev/hidraw%u", HOOKUP_MINOR_BASE + i); + if (!strcmp(pathname, path)) return hookup_present(i) ? i : -1; + } + return -1; +} + +/** Must match HookupBridge.kt's sysfs layout. */ +__attribute__((visibility("hidden"))) static void hookup_devpath(int i, const HookupSlot &s, char *out, size_t size) { + int iface = s.iface < 0 ? 0 : s.iface; + snprintf(out, size, "/devices/droiddeck/usb%d/%d-1:1.%d/0003:%04X:%04X.%04X/hidraw/hidraw%u", 10 + i, 10 + i, iface, + s.vendor, s.product, 0x10 + i, HOOKUP_MINOR_BASE + i); +} + +// ---- latency meter (logged to pad.log every 10 s while it has samples) ---- +// Where a button press of a hooked-up controller spends its time, in three steps: +// 1. waiting in Steam's queue: from the report being handed to Steam until Steam reads it, +// 2. Steam's own processing: from Steam reading the press until Steam writes the mapped button on its virtual pad, +// 3. the game: from Steam's virtual pad until the game reads it. +// Mouse / keyboard output from Steam does not go through the virtual pad, so it is in step 1 only. +struct HookupLatency { + std::mutex lock; + uint32_t n = 0, missed = 0; + uint64_t sum_us = 0, max_us = 0, since_us = 0; + void add(const char *what, uint64_t us, uint64_t now_us, bool hit = true) { + std::lock_guard guard(lock); + if (!since_us) since_us = now_us; + if (hit) { + n++; + sum_us += us; + if (us > max_us) max_us = us; + } else { + missed++; + } + if (now_us - since_us < 10000000ULL) return; + if (n) + Logger::log("latency: %s: %u presses, avg %.1f ms, max %.1f ms%s\n", what, n, sum_us / 1000.0 / n, max_us / 1000.0, + missed ? " (plus presses that never reached the pad: mapped to mouse / keyboard or nothing)" : ""); + n = missed = 0; + sum_us = max_us = 0; + since_us = now_us; + } +}; + +static std::atomic g_hookup_press_us{0}; // Steam process: a press Steam has read, not yet on its pad + +/** + * Steam's controller work runs at a higher priority, like Android's own input threads (nice -8): the threads that + * read controllers (hooked-up ones and DroidDeck's own pads -- every controller), the one that writes Steam's + * virtual pad, and ours that hand reports over. On a phone the game and the emulation keep the CPU busy, and + * Steam's controller loop otherwise waits its turn behind them -- that is when it reads late. Once per thread; if + * Android doesn't allow it, nothing changes (logged once). + */ +__attribute__((visibility("hidden"))) static void hookup_boost_this_thread(const char *what) { + static thread_local bool done; + if (done) return; + done = true; + if (!process_is_steam_client()) return; + pid_t tid = static_cast(syscall(SYS_gettid)); + errno = 0; + int was = getpriority(PRIO_PROCESS, static_cast(tid)); + if (errno == 0 && was <= -8) return; + if (setpriority(PRIO_PROCESS, static_cast(tid), -8) == 0) { + Logger::log("thread %d (%s) now runs at higher priority (nice %d -> -8)\n", tid, what, was); + } else { + static std::atomic told{false}; + if (!told.exchange(true)) + Logger::log("could not raise the priority of thread %d (%s): %s\n", tid, what, strerror(errno)); + } +} +static std::atomic g_hookup_samples{0}; +static std::atomic g_vpad_passed{0}; // frames of Steam's virtual pad handed to the game (pacing below) +static uint64_t virtual_pad_interval_us(); + +/** Steam process, as Steam writes its virtual pad: the end of step 2. */ +__attribute__((visibility("hidden"))) static void hookup_meter_virtual_pad(const std::vector &events) { + hookup_boost_this_thread("writes Steam's virtual controller"); + { // how often Steam updates its virtual pad: every update is passed on to the game, one by one + static std::atomic updates{0}, logged{0}; + static std::atomic since{0}; + uint64_t t = monotonic_us(), was = since.load(std::memory_order_relaxed); + if (!was) since.compare_exchange_strong(was, t); + if (!events.empty()) updates.fetch_add(1, std::memory_order_relaxed); + if (was && t - was >= 10000000ULL && since.compare_exchange_strong(was, t)) { + uint32_t n = updates.exchange(0), passed = g_vpad_passed.exchange(0); + double secs = (t - was) / 1e6; + if (n && (logged.fetch_add(1) < 3 || n > 4000)) + Logger::log("latency: Steam updates its virtual pad %.0f times a second; the game gets %.0f%s\n", n / secs, + passed / secs, virtual_pad_interval_us() ? " (stick / trigger moves paced, buttons at once)" : ""); + } + } + uint64_t press = g_hookup_press_us.load(std::memory_order_acquire); + if (!press) return; + static HookupLatency stat; + uint64_t now = monotonic_us(); + if (now - press > 250000ULL) { // no gamepad button followed: mapped to mouse / keyboard, or nothing + if (g_hookup_press_us.compare_exchange_strong(press, 0)) stat.add("button press -> Steam's virtual pad", 0, now, false); + return; + } + for (const struct input_event &ev : events) { + if (ev.type != EV_KEY || ev.value != 1) continue; + if (!g_hookup_press_us.compare_exchange_strong(press, 0)) return; + stat.add("Steam read a button press -> on Steam's virtual pad (Steam's own processing)", now - press, now); + int sample = g_hookup_samples.fetch_add(1, std::memory_order_relaxed); + if (sample < 12) Logger::log("latency: press %d: on Steam's virtual pad %.1f ms after Steam read it\n", sample + 1, (now - press) / 1000.0); + return; + } +} + +// ---- Steam's virtual pad, paced for the game (every controller, every mode) ---- +// Steam writes its virtual pad each time a controller report changes its state: with a controller that sends 1000 +// reports a second (a DualSense over USB in Steam Native Hook-up) about that often. A Windows game gets each update +// passed along one by one inside Wine, and under x86 emulation that falls behind: the game lags -- unplayable at +// 1000 Hz, fine at 250 Hz, the rate DroidDeck's own pads send. So stick / trigger movement reaches the game at most +// 250 times a second, merged to the newest values; buttons are never held: a frame with a button change goes out +// at once (after the newest axis values). Steam itself still reads the controller at full rate, so gyro-to-mouse +// and mouse / keyboard bindings (not through the virtual pad) keep it. FAKE_VIRTUAL_PAD_HZ sets another rate +// (0 = every update, as before). +__attribute__((visibility("hidden"))) static uint64_t virtual_pad_interval_us() { + static const uint64_t interval = [] { + const char *v = getenv("FAKE_VIRTUAL_PAD_HZ"); + long hz = v && *v ? strtol(v, nullptr, 10) : 250; + return hz > 0 && hz < 1000000 ? static_cast(1000000 / hz) : 0; + }(); + return interval; +} + +/** Only the newest value of each axis, then one SYN_REPORT. */ +__attribute__((visibility("hidden"))) static void virtual_pad_merge(std::vector &events) { + std::vector merged; + merged.reserve(events.size()); + struct input_event syn = {}; + bool has_syn = false; + for (const struct input_event &ev : events) { + if (ev.type == EV_SYN) { + if (ev.code == SYN_REPORT) { + syn = ev; + has_syn = true; + } + continue; + } + bool replaced = false; + for (struct input_event &m : merged) + if (m.type == ev.type && m.code == ev.code) { + m = ev; + replaced = true; + break; + } + if (!replaced) merged.push_back(ev); + } + if (has_syn) merged.push_back(syn); + events.swap(merged); +} + +// Never destroyed (allocated once): at exit the pacer thread is still waiting on them, and destroying a condition +// variable with a waiter blocks forever -- the process would hang on quit. +static std::mutex &g_vpad_lock = *new std::mutex(); +static std::condition_variable &g_vpad_cv = *new std::condition_variable(); +static uint64_t g_vpad_due = 0; +static pid_t g_vpad_thread_pid = 0; + +__attribute__((visibility("hidden"))) static void *virtual_pad_pacer(void *); + +/** Frames are held: hand them to the game at `due` (the pacer thread), unless Steam's next frame comes first. */ +__attribute__((visibility("hidden"))) static void virtual_pad_wake(uint64_t due) { + std::lock_guard guard(g_vpad_lock); + if (!g_vpad_due || due < g_vpad_due) g_vpad_due = due; + if (g_vpad_thread_pid != getpid()) { // once per process (not inherited by a fork) + pthread_t thread; + if (pthread_create(&thread, nullptr, virtual_pad_pacer, nullptr) == 0) { + pthread_detach(thread); + g_vpad_thread_pid = getpid(); + } + } + g_vpad_cv.notify_one(); +} + +__attribute__((visibility("hidden"))) static void *virtual_pad_pacer(void *) { + hookup_boost_this_thread("passes Steam's virtual controller on to games"); + std::unique_lock lock(g_vpad_lock); + for (;;) { + if (!g_vpad_due) { + g_vpad_cv.wait(lock); + continue; + } + uint64_t now = monotonic_us(); + if (now < g_vpad_due) { + g_vpad_cv.wait_for(lock, std::chrono::microseconds(g_vpad_due - now)); + continue; + } + g_vpad_due = 0; + lock.unlock(); + { + std::lock_guard guard(controller_mutex()); + uint64_t interval = virtual_pad_interval_us(); + for (auto &entry : uinput_map()) { + UinputWriter &w = *entry.second; + if (!w.held) continue; + if (monotonic_us() - w.paced_us + 200 >= interval) { // due (within 0.2 ms) + w.flush_now = true; + flush_uinput(w); + w.flush_now = false; + } else { + virtual_pad_wake(w.paced_us + interval); + } + } + } + lock.lock(); + } + return nullptr; +} + +/** flush_uinput: true = hold these events for now (they stay in w.pending, merged); false = write them. */ +__attribute__((visibility("hidden"))) static bool virtual_pad_hold(UinputWriter &w) { + uint64_t interval = virtual_pad_interval_us(); + if (!interval || w.pending.empty()) { + w.held = false; + return false; + } + const struct input_event &last = w.pending.back(); + if (last.type != EV_SYN || last.code != SYN_REPORT) return false; // a partial frame (64 events): as before + bool only_axes = true; + for (const struct input_event &ev : w.pending) + if (ev.type != EV_ABS && ev.type != EV_SYN) { + only_axes = false; + break; + } + uint64_t now = monotonic_us(); + if (!only_axes || w.flush_now || now - w.paced_us >= interval) { + if (only_axes) virtual_pad_merge(w.pending); // held frames and this one: one frame, the newest values + w.paced_us = now; + w.held = false; + g_vpad_passed.fetch_add(1, std::memory_order_relaxed); + return false; + } + virtual_pad_merge(w.pending); + w.held = true; + virtual_pad_wake(w.paced_us + interval); + return true; +} + +/** Game process, as it reads Steam's virtual pad: step 3 (stamped when Steam wrote it). */ +__attribute__((visibility("hidden"))) static void hookup_meter_game_read(const struct input_event *events, size_t count) { + if (process_is_steam_client()) return; + for (size_t i = 0; i < count; i++) { + if (events[i].type != EV_KEY || events[i].value != 1) continue; + struct timeval now = {}; + gettimeofday(&now, nullptr); + int64_t us = (static_cast(now.tv_sec) - events[i].time.tv_sec) * 1000000LL + (now.tv_usec - events[i].time.tv_usec); + if (us < 0 || us > 1000000LL) continue; + static HookupLatency stat; + stat.add("Steam's virtual pad -> game read", static_cast(us), monotonic_us()); + } +} + +/** Steam's output reports / feature writes to a hooked-up controller, logged every 10 s when Steam had to wait. */ +struct HookupOutputStat { + uint32_t reports = 0, features = 0, dropped = 0; + uint64_t waited_us = 0, since_us = 0; +}; + +/** + * Buttons and hat switches the controller's own HID report descriptor declares for input report `id` (0 = the + * controller uses no report IDs), as a bit mask over the report bytes (byte 0 = the report ID when IDs are used). + * Controllers with a standard descriptor get their buttons protected exactly; vendor-defined reports (Sony's extra + * area, Switch, Steam Controller, Xbox) declare none, and rely on what is learned from the reports. + */ +__attribute__((visibility("hidden"))) static void hookup_descriptor_buttons(const HookupSlot &s, uint8_t id, uint8_t *mask, + uint32_t length) { + const uint8_t *d = s.desc; + uint32_t n = std::min(s.desc_len, HOOKUP_DESC_MAX); + struct Globals { uint32_t page = 0, size = 0, count = 0, id = 0; } g, stack[4]; + int depth = 0; + uint32_t usages[64]; + int nusages = 0; + uint32_t umin = 0, umax = 0; + bool range = false, ids = false; + uint32_t bitpos[256] = {}; + for (uint32_t pos = 0; pos < n;) { + uint8_t prefix = d[pos++]; + if (prefix == 0xfe) { // long item + if (pos + 1 >= n) break; + pos += 2 + d[pos]; + continue; + } + uint32_t size = prefix & 3; + if (size == 3) size = 4; + if (pos + size > n) break; + uint32_t v = 0; + for (uint32_t k = 0; k < size; k++) v |= static_cast(d[pos + k]) << (8 * k); + pos += size; + uint32_t type = (prefix >> 2) & 3, tag = prefix >> 4; + if (type == 1) { // global + if (tag == 0) g.page = v; + else if (tag == 7) g.size = v; + else if (tag == 8) { g.id = v & 0xff; ids = true; } + else if (tag == 9) g.count = v; + else if (tag == 10 && depth < 4) stack[depth++] = g; + else if (tag == 11 && depth > 0) g = stack[--depth]; + } else if (type == 2) { // local + if (tag == 0 && nusages < 64) usages[nusages++] = size == 4 ? v : (g.page << 16) | v; + else if (tag == 1) { umin = size == 4 ? v : (g.page << 16) | v; range = true; } + else if (tag == 2) umax = size == 4 ? v : (g.page << 16) | v; + } else if (type == 0) { // main + if (tag == 8) { // Input + uint32_t start = bitpos[g.id]; + uint32_t bits = g.size * g.count; + bitpos[g.id] += bits; + if (!(v & 1) && g.id == id && g.size && g.size <= 32) { + for (uint32_t j = 0; j < g.count; j++) { + uint32_t usage = j < static_cast(nusages) ? usages[j] + : range ? std::min(umin + j, umax ? umax : umin + j) + : nusages ? usages[nusages - 1] : (g.page << 16); + uint32_t page = usage >> 16, u = usage & 0xffff; + if (page != 0x09 && !(page == 0x01 && u == 0x39)) continue; + for (uint32_t b = start + j * g.size; b < start + (j + 1) * g.size; b++) { + uint32_t byte = (ids ? 1 : 0) + b / 8; + if (byte < length) mask[byte] |= 1u << (b % 8); + } + } + } + } + nusages = 0; + range = false; + umin = umax = 0; + } + } + (void)ids; +} + +/** + * Which bits of a controller's reports are "moving", learned from the reports themselves for ANY controller (no + * report layout needed): bytes that change in more than a quarter of the last 256 reports of that kind (sticks in + * use, gyro, accelerometer, touch position, noise) and single bits that do; and counters / timestamps (a byte that + * goes up in nearly every report, and the bytes above it that only change when it wraps around). A button is never + * pressed that fast. Buttons the HID descriptor declares are never "moving". Until the first 256 reports are seen + * (and for kinds that don't stream) nothing counts as moving. + */ +struct HookupReportKind { + uint32_t length = 0; + uint8_t id = 0; // first byte (the report ID on controllers that use them) + bool learned = false; + uint32_t count = 0; + uint8_t prev[HOOKUP_REPORT_MAX] = {}; + bool has_prev = false; + uint16_t changes[HOOKUP_REPORT_MAX * 8] = {}; + uint16_t byte_changes[HOOKUP_REPORT_MAX] = {}; + uint16_t ups[HOOKUP_REPORT_MAX] = {}; // byte went up by 1..127 (mod 256) + uint16_t carried[HOOKUP_REPORT_MAX] = {}; // byte changed while the byte below it wrapped around + uint16_t uncarried[HOOKUP_REPORT_MAX] = {}; // byte changed while the byte below it did not wrap + uint8_t moving[HOOKUP_REPORT_MAX] = {}; // 1 bits = moving (or part of a counter) + bool analog[HOOKUP_REPORT_MAX] = {}; // the whole byte is moving, and it is not a counter + uint8_t buttons[HOOKUP_REPORT_MAX] = {}; // declared buttons / hat switches (HID descriptor) + uint8_t noisy[HOOKUP_REPORT_MAX] = {}; // bits that change in over a quarter of reports, declared or not + uint8_t given[HOOKUP_REPORT_MAX] = {}; // the last report of this kind Steam got + bool has_given = false; + uint64_t window_us = 0; + void learn(const uint8_t *d, uint64_t now_us) { + if (!window_us) window_us = now_us; + if (has_prev) { + for (uint32_t i = 0; i < length; i++) { + uint8_t x = d[i] ^ prev[i]; + if (!x) continue; + byte_changes[i]++; + for (uint32_t b = x; b; b &= b - 1) changes[i * 8 + __builtin_ctz(b)]++; + if (static_cast(d[i] - prev[i]) > 0) ups[i]++; + if (i > 0) { + if (d[i - 1] < prev[i - 1]) carried[i]++; else uncarried[i]++; + } + } + } + memcpy(prev, d, length); + has_prev = true; + if (++count < 256) return; + bool counter[HOOKUP_REPORT_MAX] = {}; + for (uint32_t i = 0; i < length; i++) { + if (ups[i] >= 200) counter[i] = true; // goes up in ~80 % of reports + else if (i > 0 && counter[i - 1] && carried[i] >= 2 && !uncarried[i]) counter[i] = true; // its upper part + analog[i] = !counter[i] && byte_changes[i] > 64 && !buttons[i]; + uint8_t m = counter[i] || byte_changes[i] > 64 ? 0xff : 0; + for (int b = 0; b < 8; b++) + if (changes[i * 8 + b] > 64) m |= 1u << b; + moving[i] = m & ~buttons[i]; + noisy[i] = m; + } + memset(changes, 0, sizeof(changes)); + memset(byte_changes, 0, sizeof(byte_changes)); + memset(ups, 0, sizeof(ups)); + memset(carried, 0, sizeof(carried)); + memset(uncarried, 0, sizeof(uncarried)); + count = 0; + // Only a kind that streams (256 reports within 5 s) is ever replaced: rarer kinds -- replies to Steam's + // commands, status reports -- always reach Steam one by one. + learned = now_us - window_us < 5000000ULL; + window_us = now_us; + } + /** + * Would replacing `older` by `newer` lose a change Steam has not seen -- a button-like bit that changed (compared + * with `base`, what Steam has or will get) and changed back? That is a short press-and-release (or the reverse) + * that must reach Steam. Not counted: moving bits (their next value carries them), and the carry of an analog + * value crossing a boundary: a bit that changed together with lower bits of its byte, or a byte that moved by + * 1..3 right above an analog byte (the upper half of a 16-bit value). A declared button always counts. + */ + bool loses_change(const uint8_t *older, const uint8_t *newer, const uint8_t *base) const { + for (uint32_t i = 0; i < length; i++) { + uint8_t changed = older[i] ^ base[i]; + uint8_t reverted = changed & ~(newer[i] ^ base[i]) & ~noisy[i]; // noisy: moving bits, and declared ones that act so + for (uint32_t r = reverted; r; r &= r - 1) { + int b = __builtin_ctz(r); + if (buttons[i] & (1u << b)) return true; + if (changed & ((1u << b) - 1)) continue; + int delta = static_cast(older[i] - base[i]); + if (i > 0 && analog[i - 1] && delta >= -3 && delta <= 3) continue; + return true; + } + } + return false; + } + /** + * For the latency meter, any controller: a button press since `ref` -- a declared button (HID descriptor) that + * turned on, or, for controllers that declare none, a learned non-moving bit that turned on (not the carry of an + * analog value crossing a boundary, as in loses_change). Bits that change in over a quarter of reports are never + * a press, even when the descriptor calls them buttons (some controllers, e.g. a DualSense Edge, declare bits + * that change constantly): nobody presses a button that fast, and counting them buried the real presses. + */ + bool press(const uint8_t *d, const uint8_t *ref) const { + bool declared = false; + for (uint32_t i = 0; i < length; i++) { + if (!buttons[i]) continue; + declared = true; + if (d[i] & ~ref[i] & buttons[i] & ~noisy[i]) return true; + } + if (declared || !learned) return false; + for (uint32_t i = 0; i < length; i++) { + uint8_t changed = d[i] ^ ref[i]; + uint8_t rises = d[i] & changed & ~moving[i]; + if (!rises) continue; + int delta = static_cast(d[i] - ref[i]); + if (i > 0 && analog[i - 1] && delta >= -3 && delta <= 3) continue; + for (uint32_t r = rises; r; r &= r - 1) + if (!(changed & ((1u << __builtin_ctz(r)) - 1))) return true; + } + return false; + } +}; + +/** + * Controller reports -> Steam, for every controller: the hooked-up ones (/dev/hidraw24-27) and DroidDeck's own pads + * (Steam Deck, DualSense / Edge, DualShock 4, Steam Controller 2015 / 2026). A mailbox read at the moment Steam + * reads: the report thread puts reports here, Steam's read() of the node is answered from here (hookup_read_steam), + * and the node's socket only carries a 1-byte "something to read" token, so poll() works as on a real node. + * + * How the reports are handed over follows how Steam reads, learned while it runs: + * - Steam takes everything waiting each time (like SDL's update loop): it gets every report, in order, the newest + * last -- the newest state is in that same read, and gyro / touch keep all their samples. + * - Steam takes one report and goes away (busy): it gets the newest one. Older ones are skipped, unless skipping + * one would lose a change: a button-like bit that changed and changed back before Steam read it, like a press + * shorter than Steam's read cycle (HookupReportKind::loses_change). Those reach Steam, in order. + * At most 8 reports wait; beyond that the oldest skippable one goes. Reports of kinds that don't stream (replies to + * Steam's commands, status) are never skipped. + * (Before, reports went into a small socket queue: when Steam read slowly it filled up, the NEWEST reports were the + * ones dropped and Steam read old ones -- a press could reach it one or more of its read cycles late.) + */ +struct HookupQueue { + struct Entry { + int kind; // index in kinds, -1 = beyond the 4 kinds tracked (never skipped) + uint32_t length; + uint64_t arrived_us; + uint8_t data[HOOKUP_REPORT_MAX]; + }; + static constexpr int MAX = 8; + std::mutex lock; + Entry entries[MAX + 1]; + int n = 0; + HookupReportKind kinds[4]; + int nkinds = 0; + bool token = false; // the "something to read" byte is in Steam's socket + bool closed = false; // the report thread ended (controller gone) + bool drains = false; // learned: Steam takes everything waiting each time + uint64_t last_read_us = 0; // Steam's last read that got a report + // Paced (Hook-up, the bridge's Poll rate): Steam is told "something to read" at most once per interval and then + // gets the newest report (short presses still reach it in order) -- like DroidDeck's own pads, which make + // reports at their own rate. + uint32_t interval_us = 0; + uint64_t last_token_us = 0; + uint64_t peer_check_us = 0; + uint32_t mode_logs = 0; + char name[64] = {}; + // 10 s summary + uint64_t meter_us = 0; + uint32_t arrived = 0, handed = 0, skipped = 0, lost = 0; + uint64_t handed_ever = 0; // a node Steam keeps open only to send it settings is never read + bool told_unread = false; + uint64_t wait_sum_us = 0, wait_max_us = 0; +}; + +/** Reads this close together are one read cycle of Steam's. */ +static constexpr uint64_t HOOKUP_CYCLE_GAP_US = 1000; + +/** What Steam has, or will have, before entry i of its kind: the previous one waiting, or the last one it got. */ +__attribute__((visibility("hidden"))) static const uint8_t *hookup_base(const HookupQueue &q, int i) { + int kind = q.entries[i].kind; + for (int j = i - 1; j >= 0; j--) + if (q.entries[j].kind == kind) return q.entries[j].data; + return q.kinds[kind].has_given ? q.kinds[kind].given : nullptr; +} + +/** Entry i can be skipped: a newer report of its kind waits behind it, and skipping i loses no change. */ +__attribute__((visibility("hidden"))) static bool hookup_skippable(const HookupQueue &q, int i) { + const HookupQueue::Entry &e = q.entries[i]; + if (e.kind < 0 || !q.kinds[e.kind].learned) return false; + for (int j = i + 1; j < q.n; j++) { + if (q.entries[j].kind != e.kind) continue; + const uint8_t *base = hookup_base(q, i); + return !base || !q.kinds[e.kind].loses_change(e.data, q.entries[j].data, base); + } + return false; +} + +__attribute__((visibility("hidden"))) static void hookup_remove(HookupQueue &q, int i) { + for (int j = i; j < q.n - 1; j++) q.entries[j] = q.entries[j + 1]; + q.n--; +} + +__attribute__((visibility("hidden"))) static void hookup_set_mode(HookupQueue &q, bool drains) { + q.drains = drains; + if (q.mode_logs++ < 4) + Logger::log("%s: %s\n", q.name, + drains ? "Steam takes every report waiting each time: it gets them all, in order, the newest last" + : "Steam reads one report at a time: it gets the newest (older ones are skipped unless that " + "would lose a press)"); +} + +/** + * A new report for Steam, from a report thread. `slot` = the hooked-up controller (its HID report descriptor marks + * the buttons), or null for DroidDeck's own pads. Returns -1 once Steam has closed the node (the thread ends). + */ +__attribute__((visibility("hidden"))) static int hookup_offer(HookupQueue &q, int peer, const HookupSlot *slot, + const uint8_t *data, uint32_t length) { + if (length > HOOKUP_REPORT_MAX) length = HOOKUP_REPORT_MAX; + if (!length) return 0; + uint64_t now = monotonic_us(); + std::lock_guard guard(q.lock); + q.arrived++; + int kind = -1; + for (int i = 0; i < q.nkinds && kind < 0; i++) + if (q.kinds[i].length == length && q.kinds[i].id == data[0]) kind = i; + if (kind < 0 && q.nkinds < 4) { + HookupReportKind &k = q.kinds[q.nkinds]; + k.length = length; + k.id = data[0]; + if (slot) { + hookup_descriptor_buttons(*slot, data[0], k.buttons, length); // report IDs: byte 0 is the ID + uint8_t no_ids[HOOKUP_REPORT_MAX] = {}; + hookup_descriptor_buttons(*slot, 0, no_ids, length); // no IDs: the report starts at once + for (uint32_t i = 0; i < length; i++) k.buttons[i] |= no_ids[i]; + } + kind = q.nkinds++; + } + if (kind >= 0) q.kinds[kind].learn(data, now); + HookupQueue::Entry &e = q.entries[q.n++]; + e.kind = kind; + e.length = length; + e.arrived_us = now; + memcpy(e.data, data, length); + if (q.n > HookupQueue::MAX) { // Steam hasn't read for a while: make room, the oldest skippable report first + int drop = -1; + for (int i = 0; i < q.n - 1 && drop < 0; i++) + if (hookup_skippable(q, i)) drop = i; + if (drop >= 0) { + q.skipped++; + } else { + drop = 0; + if (q.entries[0].kind >= 0 && q.kinds[q.entries[0].kind].learned) q.lost++; + } + hookup_remove(q, drop); + } + if (!q.token && (!q.interval_us || now - q.last_token_us >= q.interval_us)) { // tell Steam's poll() there is something + uint8_t token = 1; + if (send(peer, &token, 1, MSG_DONTWAIT | MSG_NOSIGNAL) == 1) { + q.token = true; + q.last_token_us = now; + } else if (errno != EAGAIN && errno != EWOULDBLOCK) { + return -1; // Steam closed the node + } + } else if (q.token && now - q.peer_check_us > 100000) { // the token is waiting: did Steam close the node meanwhile? + q.peer_check_us = now; + struct pollfd p = {peer, 0, 0}; + struct timespec zero = {0, 0}; + if (syscall(SYS_ppoll, &p, 1, &zero, nullptr, 0) > 0 && (p.revents & (POLLHUP | POLLERR))) return -1; + } + // 10 s summary, logged when Steam was slow to read (skipping reports, or reading them late) + if (!q.meter_us) q.meter_us = now; + if (now - q.meter_us >= 10000000ULL) { + double secs = (now - q.meter_us) / 1e6; + if (!q.handed_ever) { // opened only to send it settings (Steam reads another copy): not slow reading + if (!q.told_unread) + Logger::log("%s: Steam keeps this copy of the node open without reading it (it only sends it settings)\n", q.name); + q.told_unread = true; + } else if ((q.skipped && !q.interval_us) || q.lost || + (q.handed && q.wait_sum_us / q.handed > 2000 + q.interval_us)) + Logger::log("%s: %.0f reports/s, Steam read %.0f/s (%s), avg %.1f ms after they arrived (max %.1f ms); %u older " + "ones skipped%s%s\n", + q.name, q.arrived / secs, q.handed / secs, + q.interval_us ? "paced: the newest" : q.drains ? "all waiting each time" : "the newest each time", + q.handed ? q.wait_sum_us / 1000.0 / q.handed : 0.0, q.wait_max_us / 1000.0, q.skipped, + q.interval_us ? "" : " while Steam was busy", + q.lost ? " -- Steam stopped reading for a while, some short presses were dropped" : ""); + q.arrived = q.handed = q.skipped = q.lost = 0; + q.wait_sum_us = q.wait_max_us = 0; + q.meter_us = now; + } + return 0; +} + +/** + * Paced mailbox: the "something to read" token held back by hookup_offer (interval not over yet) goes out once the + * interval is over. Returns how many microseconds until then (0 = nothing waiting). -1 = Steam closed the node. + */ +__attribute__((visibility("hidden"))) static int64_t hookup_token_due(HookupQueue &q, int peer, uint64_t now) { + std::lock_guard guard(q.lock); + if (q.token || !q.n || !q.interval_us) return 0; + uint64_t since = now - q.last_token_us; + if (since < q.interval_us) return static_cast(q.interval_us - since); + uint8_t token = 1; + if (send(peer, &token, 1, MSG_DONTWAIT | MSG_NOSIGNAL) == 1) { + q.token = true; + q.last_token_us = now; + } else if (errno != EAGAIN && errno != EWOULDBLOCK) { + return -1; + } + return 0; +} + +/** Steam process, as Steam reads a report from the mailbox: step 1 of the latency meter, and the start of step 2. */ +__attribute__((visibility("hidden"))) static void hookup_meter_handed(HookupQueue &q, const HookupQueue::Entry &e, + uint64_t now) { + uint64_t wait = now - e.arrived_us; + q.handed++; + q.handed_ever++; + q.wait_sum_us += wait; + if (wait > q.wait_max_us) q.wait_max_us = wait; + HookupReportKind *k = e.kind >= 0 ? &q.kinds[e.kind] : nullptr; + if (!k || !k->has_given || !k->press(e.data, k->given)) return; + static HookupLatency stat; + stat.add("button press waited for Steam to read it", wait, now); + g_hookup_press_us.store(now, std::memory_order_release); + static std::atomic samples{0}; + int sample = samples.fetch_add(1, std::memory_order_relaxed); + if (sample < 12) Logger::log("latency: press %d: Steam read it %.1f ms after it arrived\n", sample + 1, wait / 1000.0); +} + +/** Steam's read() of a controller node with a mailbox: the next report (see HookupQueue). */ +__attribute__((visibility("hidden"))) static ssize_t hookup_read_steam(int fd, HookupQueue &q, void *buf, size_t count) { + hookup_boost_this_thread("reads a controller"); + for (;;) { + { + std::lock_guard guard(q.lock); + uint64_t now = monotonic_us(); + bool new_cycle = !q.last_read_us || now - q.last_read_us >= HOOKUP_CYCLE_GAP_US; + if (q.n) { + // back after a while, and a report it left waiting last time is still here: it reads one at a time + if (new_cycle && q.drains && q.entries[0].arrived_us < q.last_read_us) hookup_set_mode(q, false); + if (!q.drains || q.interval_us) // the newest, minus what can't be skipped (always when paced) + for (int i = 0; i < q.n - 1;) { + if (hookup_skippable(q, i)) { + hookup_remove(q, i); + q.skipped++; + } else { + i++; + } + } + HookupQueue::Entry e = q.entries[0]; + hookup_remove(q, 0); + if (!q.n && q.token) { // nothing left: take the token back so poll() reports "nothing to read" + uint8_t token; + if (recv(fd, &token, 1, MSG_DONTWAIT) < 0) {} + q.token = false; + } + size_t copied = std::min(count, e.length); + memcpy(buf, e.data, copied); + hookup_meter_handed(q, e, now); + if (e.kind >= 0) { + memcpy(q.kinds[e.kind].given, e.data, e.length); + q.kinds[e.kind].has_given = true; + } + q.last_read_us = now; + return static_cast(copied); + } + if (!new_cycle && !q.drains) hookup_set_mode(q, true); // came straight back for more: it takes all waiting + if (q.closed) return 0; // the controller is gone: end of file, as on a real node + uint8_t peek; + if (recv(fd, &peek, 1, MSG_PEEK | MSG_DONTWAIT) == 0) return 0; // the report thread ended: end of file + } + int flags = fcntl(fd, F_GETFL); + if (flags >= 0 && (flags & O_NONBLOCK)) { + errno = EAGAIN; + return -1; + } + struct pollfd p = {fd, POLLIN, 0}; + if (syscall(SYS_ppoll, &p, 1, nullptr, nullptr, 0) < 0 && errno != EINTR) return -1; // until there is something + if (p.revents & (POLLHUP | POLLERR)) { + std::lock_guard guard(q.lock); + if (!q.n) return 0; + } + } +} + +/** readv() of a node with a mailbox: one report, as read() gives it, spread over the buffers. -2 = not one. */ +__attribute__((visibility("hidden"))) static ssize_t hookup_readv_mailbox(int fd, const struct iovec *iov, int iovcnt) { + if (!watched(fd)) return -2; + std::shared_ptr q; + { + std::lock_guard guard(controller_mutex()); + auto deck = deck_map().find(fd); + if (deck == deck_map().end() || !deck->second->hookup_in) return -2; + q = deck->second->hookup_in; + } + if (iovcnt < 0 || iovcnt > IOV_MAX || (iovcnt && !iov)) { + errno = EINVAL; + return -1; + } + size_t total = 0; + for (int i = 0; i < iovcnt; i++) total += iov[i].iov_len; + uint8_t report[HOOKUP_REPORT_MAX]; + ssize_t got = hookup_read_steam(fd, *q, report, std::min(total, sizeof(report))); + size_t done = 0; + for (int i = 0; i < iovcnt && got > 0 && done < static_cast(got); i++) { + size_t part = std::min(iov[i].iov_len, static_cast(got) - done); + memcpy(iov[i].iov_base, report + done, part); + done += part; + } + return got; +} + +// ---- DroidDeck's own controller nodes (Steam Deck pad, DualSense / Edge, DualShock 4, Steam Controller 2015 and +// 2026): the same mailbox, so Steam reads the newest state of every controller, not only hooked-up ones ---- +__attribute__((visibility("hidden"))) static void pad_mailbox_attach(DeckHidraw &pad) { + pad.hookup_in = std::make_shared(); + const char *what = pad.triton ? (pad.emulated ? "Steam Controller (2026) pad" : "Steam Controller (2026)") + : pad.sc1 ? "Steam Controller (2015) pad" + : pad.sony == 4 ? "DualShock 4 pad" + : pad.sony == 6 ? "DualSense Edge pad" + : pad.sony ? "DualSense pad" + : "Steam Deck pad"; + snprintf(pad.hookup_in->name, sizeof(pad.hookup_in->name), "deck: %s", what); +} + +/** A report of one of DroidDeck's own pads for Steam. -1 = Steam closed the node (the report thread ends). */ +__attribute__((visibility("hidden"))) static int pad_mailbox_offer(DeckHidraw &pad, const uint8_t *data, + uint32_t length) { + hookup_boost_this_thread("hands DroidDeck's controller reports to Steam"); + if (!pad.hookup_in) { // no mailbox: straight into the node's socket, as DroidDeck does + if (send(pad.peer, data, length, MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && errno != EAGAIN && errno != EWOULDBLOCK) + return -1; + return 0; + } + return hookup_offer(*pad.hookup_in, pad.peer, nullptr, data, length); +} + +__attribute__((visibility("hidden"))) static void *hookup_report_thread(void *arg) { + auto *holder = static_cast *>(arg); + std::shared_ptr self = *holder; + delete holder; + HookupSlot *s = hookup_slot(self->hookup); + std::shared_ptr qp = self->hookup_in; + HookupQueue &q = *qp; + hookup_boost_this_thread("hands controller reports to Steam"); + uint64_t next = s ? __atomic_load_n(&s->in_write, __ATOMIC_ACQUIRE) : 0; + bool peer_gone = false; + // the hop before every other meter: SDL3Bridge reading the report from USB -> picked up here (Hook-up only) + uint32_t hop_n = 0, hop_logs = 0; + uint64_t hop_sum = 0, hop_max = 0, hop_since = monotonic_us(); + while (s && __atomic_load_n(&s->present, __ATOMIC_ACQUIRE) && + __atomic_load_n(&s->generation, __ATOMIC_ACQUIRE) == self->hookup_generation) { + uint64_t written = __atomic_load_n(&s->in_write, __ATOMIC_ACQUIRE); + if (written - next > HOOKUP_IN_FIFO) next = written - HOOKUP_IN_FIFO; // fell behind: skip the oldest + while (next < written && !peer_gone) { + const HookupReport &r = s->in[next % HOOKUP_IN_FIFO]; + uint8_t data[HOOKUP_REPORT_MAX]; + uint32_t length = __atomic_load_n(&r.length, __ATOMIC_RELAXED); + if (length > HOOKUP_REPORT_MAX) length = HOOKUP_REPORT_MAX; + memcpy(data, r.data, length); + uint32_t stamp = __atomic_load_n(&r.kind, __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_ACQUIRE); + bool intact = __atomic_load_n(&r.seq, __ATOMIC_ACQUIRE) == next + 1; + next++; + if (!intact || !length) continue; + if (stamp) { // same clock in both processes (CLOCK_MONOTONIC); 32-bit microseconds, wraps every 71 min + uint32_t hop = static_cast(monotonic_us()) - stamp; + if (hop < 1000000u) { + hop_n++; + hop_sum += hop; + if (hop > hop_max) hop_max = hop; + } + } + if (hookup_offer(q, self->peer, s, data, length) < 0) peer_gone = true; + } + if (peer_gone) break; // Steam closed the node + // the bridge's Poll rate paces what Steam is handed (fast path only; 0 / relayed by DroidDeck's app = every report) + { + uint64_t beat_ms = __atomic_load_n(&s->direct_ms, __ATOMIC_ACQUIRE); + uint32_t hz = beat_ms && monotonic_us() / 1000 - beat_ms < 1500 ? __atomic_load_n(&s->rate_hz, __ATOMIC_ACQUIRE) : 0; + uint32_t interval = hz >= 20 && hz < 1000 ? 1000000u / hz : 0; // 1000 Hz and up: every report, as before + std::lock_guard guard(q.lock); + if (interval != q.interval_us) { + if (interval) + Logger::log("hookup: /dev/hidraw%u: Steam gets the newest report %u times a second (the bridge's Poll rate)\n", + HOOKUP_MINOR_BASE + self->hookup, hz); + else + Logger::log("hookup: /dev/hidraw%u: every report goes to Steam (Poll rate 1000 Hz)\n", + HOOKUP_MINOR_BASE + self->hookup); + q.interval_us = interval; + } + } + int64_t due = hookup_token_due(q, self->peer, monotonic_us()); + if (due < 0) break; // Steam closed the node + uint64_t hop_now = monotonic_us(); + if (hop_now - hop_since >= 10000000ULL) { // first one always, then when the hop is slow + if (hop_n && (hop_logs++ == 0 || hop_sum / hop_n > 1000 || hop_max > 8000)) + Logger::log("latency: SDL3Bridge read a report -> it reached Steam's side (/dev/hidraw%u): %u reports, avg %.2f " + "ms, max %.1f ms\n", HOOKUP_MINOR_BASE + self->hookup, hop_n, hop_sum / 1000.0 / hop_n, + hop_max / 1000.0); + hop_n = 0; + hop_sum = hop_max = 0; + hop_since = hop_now; + } + // Sleep until the next report. The SDL3 bridge's fast path wakes this futex the moment it writes one (the + // low half of in_write changes); reports relayed by DroidDeck's app are picked up by the timeout. + uint64_t beat = __atomic_load_n(&s->direct_ms, __ATOMIC_ACQUIRE); + bool fast = beat && monotonic_us() / 1000 - beat < 1500; + long wait_us = fast ? 4000L : 500L; + if (due > 0 && due < wait_us) wait_us = static_cast(due); // a held-back token is due sooner + struct timespec wait = {0, wait_us * 1000L}; + syscall(SYS_futex, reinterpret_cast(&s->in_write), HOOKUP_FUTEX_WAIT, static_cast(written), + &wait, nullptr, 0); + } + { + std::lock_guard guard(q.lock); + q.closed = true; + } + syscall(SYS_close, self->peer); + return nullptr; +} + +__attribute__((visibility("hidden"))) static int open_hookup_hidraw(int flags, int slot) { + HookupSlot *s = hookup_slot(slot); + if (!s || !process_is_steam_client()) { + errno = ENOENT; + return -1; + } + int pair[2]; + if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) return -1; + // The socket only carries the mailbox's 1-byte "something to read" token (see HookupQueue): small is plenty. + int buffer = HOOKUP_REPORT_MAX * 4; + setsockopt(pair[1], SOL_SOCKET, SO_SNDBUF, &buffer, sizeof(buffer)); + if (!(flags & O_CLOEXEC)) fcntl(pair[0], F_SETFD, 0); + if (flags & O_NONBLOCK) fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK); + auto pad = std::make_shared(); + pad->peer = pair[1]; + pad->hookup = static_cast(slot); + pad->hookup_generation = __atomic_load_n(&s->generation, __ATOMIC_ACQUIRE); + pad->hookup_in = std::make_shared(); + snprintf(pad->hookup_in->name, sizeof(pad->hookup_in->name), "hookup: /dev/hidraw%u", HOOKUP_MINOR_BASE + slot); + { + std::lock_guard guard(controller_mutex()); + deck_map()[pair[0]] = pad; + watch_fd(pair[0]); + } + pthread_t thread; + auto *arg = new std::shared_ptr(pad); + if (pthread_create(&thread, nullptr, hookup_report_thread, arg) != 0) { + delete arg; + std::lock_guard guard(controller_mutex()); + deck_map().erase(pair[0]); + unwatch_fd(pair[0]); + syscall(SYS_close, pair[0]); + syscall(SYS_close, pair[1]); + errno = ENOMEM; + return -1; + } + pthread_detach(thread); + Logger::log("hookup: /dev/hidraw%u opened as fd %d (%04x:%04x \"%s\")\n", HOOKUP_MINOR_BASE + slot, pair[0], s->vendor, + s->product, s->name); + return pair[0]; +} + +/** + * Steam -> controller: an output report (kind 0) or a feature-report write (kind 1). Called without the hook's + * controller lock (see the write() patch), so a wait here never holds up Steam's other controller calls -- its + * virtual pad above all. Output reports (rumble, lights) wait at most a few ms for room: when the controller can't + * take them that fast the newest is dropped, as the next one carries the current state again. + */ +__attribute__((visibility("hidden"))) static void hookup_queue(const DeckHidraw &pad, const uint8_t *data, size_t size, + uint32_t kind) { + HookupSlot *s = hookup_slot(pad.hookup); + if (!s || !data || !size || !__atomic_load_n(&s->present, __ATOMIC_ACQUIRE)) return; + if (size > HOOKUP_REPORT_MAX) size = HOOKUP_REPORT_MAX; + static std::mutex one_at_a_time; + std::lock_guard guard(one_at_a_time); + static HookupOutputStat stats[HOOKUP_SLOTS]; + HookupOutputStat &st = stats[pad.hookup]; + uint64_t t0 = monotonic_us(); + uint64_t idx = __atomic_load_n(&s->out_write, __ATOMIC_RELAXED); + int limit = (kind ? HOOKUP_OUTPUT_WAIT_MS : HOOKUP_REPORT_WAIT_MS) * 4; + int waited = 0; + for (; idx - __atomic_load_n(&s->out_read, __ATOMIC_ACQUIRE) >= HOOKUP_OUT_FIFO && waited < limit; waited++) + sleep_us(250); + uint64_t t1 = monotonic_us(); + if (waited) st.waited_us += t1 - t0; + if (!st.since_us) st.since_us = t1; + if (kind) st.features++; else st.reports++; + bool full = idx - __atomic_load_n(&s->out_read, __ATOMIC_ACQUIRE) >= HOOKUP_OUT_FIFO; + if (full && !kind) st.dropped++; + if (t1 - st.since_us >= 10000000ULL) { + if (st.waited_us || st.dropped) + Logger::log("hookup: /dev/hidraw%u: Steam sent %u output reports + %u feature writes in 10 s; the controller " + "fell behind: waited %.1f ms for room, dropped %u output reports\n", + HOOKUP_MINOR_BASE + pad.hookup, st.reports, st.features, st.waited_us / 1000.0, st.dropped); + st = HookupOutputStat{}; + st.since_us = t1; + } + if (full && !kind) return; + HookupReport &r = s->out[idx % HOOKUP_OUT_FIFO]; + __atomic_store_n(&r.seq, 0, __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_RELEASE); + r.length = static_cast(size); + r.kind = kind; + memcpy(r.data, data, size); + __atomic_thread_fence(__ATOMIC_RELEASE); + __atomic_store_n(&r.seq, idx + 1, __ATOMIC_RELEASE); + __atomic_store_n(&s->out_write, idx + 1, __ATOMIC_RELEASE); +} + +/** Feature (or input) report read: asks the controller through the app and waits for the answer. */ +__attribute__((visibility("hidden"))) static int hookup_get(const DeckHidraw &pad, uint8_t *buf, size_t size, bool input) { + HookupSlot *s = hookup_slot(pad.hookup); + if (!s || !buf || size < 1) { + errno = EINVAL; + return -1; + } + static std::mutex one_at_a_time[HOOKUP_SLOTS]; + std::lock_guard guard(one_at_a_time[pad.hookup]); + uint8_t report_id = buf[0]; + __atomic_store_n(&s->get_report_id, report_id | (input ? 0x100u : 0u), __ATOMIC_RELAXED); + __atomic_store_n(&s->get_length, static_cast(std::min(size, HOOKUP_REPORT_MAX)), __ATOMIC_RELAXED); + uint64_t request = __atomic_add_fetch(&s->get_request, 1, __ATOMIC_RELEASE); + uint64_t t0 = monotonic_us(); + for (int waited = 0; waited < HOOKUP_FEATURE_TIMEOUT_MS * 4; waited++) { + if (!__atomic_load_n(&s->present, __ATOMIC_ACQUIRE)) break; + if (__atomic_load_n(&s->get_reply, __ATOMIC_ACQUIRE) == request) { + int32_t length = __atomic_load_n(&s->get_length, __ATOMIC_RELAXED); + if (length <= 0 || length > HOOKUP_REPORT_MAX) break; + size_t copied = std::min(size, static_cast(length)); + memcpy(buf, s->get, copied); + uint64_t took = monotonic_us() - t0; + static std::atomic slow; + if (took > 20000) { // Steam waits for these: a slow one holds up that Steam thread + unsigned n = ++slow; + if ((n & (n - 1)) == 0) + Logger::log("hookup: report 0x%02x read took %.1f ms (slow reads so far: %u)\n", report_id, took / 1000.0, n); + } + return static_cast(copied); + } + sleep_us(250); + } + static std::atomic failures; + unsigned n = ++failures; + if ((n & (n - 1)) == 0) Logger::log("hookup: report 0x%02x read got no answer (x%u)\n", report_id, n); + errno = EIO; + return -1; +} + +__attribute__((visibility("hidden"))) static int ioctl_hookup(DeckHidraw &pad, ioctl_request_t op, void *argp) { + HookupSlot *s = hookup_slot(pad.hookup); + if (_IOC_TYPE(op) != 'H' || !s) { + errno = ENOTTY; + return -1; + } + switch (_IOC_NR(op)) { + case 0x01: + *static_cast(argp) = static_cast(std::min(s->desc_len, HOOKUP_DESC_MAX)); + return 0; + case 0x02: { + auto *out = static_cast(argp); + uint32_t size; + memcpy(&size, out, sizeof(size)); + memcpy(out + 4, s->desc, std::min(size, std::min(s->desc_len, HOOKUP_DESC_MAX))); + return 0; + } + case 0x03: { + struct { + uint32_t bustype; + int16_t vendor; + int16_t product; + } info = {s->bus ? s->bus : static_cast(BUS_USB), static_cast(s->vendor), static_cast(s->product)}; + memcpy(argp, &info, sizeof(info)); + return 0; + } + case 0x04: return copy_ioctl_string(op, argp, s->name); + case 0x05: { + char phys[48]; + snprintf(phys, sizeof(phys), "usb-droiddeck-%d/input%d", 10 + pad.hookup, s->iface < 0 ? 0 : s->iface); + return copy_ioctl_string(op, argp, phys); + } + case 0x08: return copy_ioctl_string(op, argp, s->serial); + case 0x06: // HIDIOCSFEATURE + hookup_queue(pad, static_cast(argp), _IOC_SIZE(op), 1); + return static_cast(_IOC_SIZE(op)); + case 0x07: // HIDIOCGFEATURE + return hookup_get(pad, static_cast(argp), _IOC_SIZE(op), false); + case 0x0A: // HIDIOCGINPUT + return hookup_get(pad, static_cast(argp), _IOC_SIZE(op), true); + case 0x0B: // HIDIOCSOUTPUT + hookup_queue(pad, static_cast(argp), _IOC_SIZE(op), 0); + return static_cast(_IOC_SIZE(op)); + default: + errno = EINVAL; + return -1; + } +} + +// ---- game rumble -> Steam (uinput force feedback) ---- +__attribute__((visibility("hidden"))) static void hookup_ff_from_game(uint16_t slot, int strong, int weak, uint16_t length) { + HookupHeader *h = hookup_header(); + if (!h || slot < UINPUT_EVENT_BASE || slot >= UINPUT_EVENT_LIMIT) return; + if (!__atomic_load_n(&h->ff_to_steam, __ATOMIC_ACQUIRE)) return; + HookupFF &f = h->ff[slot - UINPUT_EVENT_BASE]; + f.strong = strong; + f.weak = weak; + f.length = length; + f.play = (strong || weak) && length ? 1 : 0; + __atomic_add_fetch(&f.seq, 1, __ATOMIC_RELEASE); +} + +/** Runs in the Steam client: turns game rumble into uinput force-feedback requests for Steam's virtual pad. */ +__attribute__((visibility("hidden"))) static void hookup_ff_to_steam() { + HookupHeader *h = hookup_header(); + if (!h) return; + std::lock_guard guard(controller_mutex()); + for (auto &entry : uinput_map()) { + UinputWriter &w = *entry.second; + if (!w.published || w.owner != getpid() || w.node < UINPUT_EVENT_BASE || w.node >= UINPUT_EVENT_LIMIT) continue; + if (!test_bit(w.description.ffbits, sizeof(w.description.ffbits), FF_RUMBLE)) continue; + HookupFF &f = h->ff[w.node - UINPUT_EVENT_BASE]; + uint64_t seq = __atomic_load_n(&f.seq, __ATOMIC_ACQUIRE); + if (seq == w.ff_seen) continue; + w.ff_seen = seq; + struct timeval now = {}; + gettimeofday(&now, nullptr); + auto event = [&](uint16_t type, uint16_t code, int32_t value) { + struct input_event ev = {}; + ev.time = now; + ev.type = type; + ev.code = code; + ev.value = value; + w.ff_queue.push_back(ev); + }; + if (f.play) { + w.ff_request++; + memset(&w.ff_effect, 0, sizeof(w.ff_effect)); + w.ff_effect.type = FF_RUMBLE; + w.ff_effect.id = 0; + w.ff_effect.replay.length = f.length == 0xffff ? 0 : static_cast(f.length); + w.ff_effect.u.rumble.strong_magnitude = static_cast(std::max(0, std::min(65535, f.strong))); + w.ff_effect.u.rumble.weak_magnitude = static_cast(std::max(0, std::min(65535, f.weak))); + event(EV_UINPUT, UI_FF_UPLOAD, static_cast(w.ff_request)); + event(EV_FF, 0, 1); + } else { + event(EV_FF, 0, 0); + } + uint64_t one = 1; + if (syscall(SYS_write, entry.first, &one, sizeof(one)) < 0) {} + } +} + +/** Serves Steam's read() of its uinput fd: the queued force-feedback requests. -2 = not handled. */ +__attribute__((visibility("hidden"))) static ssize_t hookup_read_uinput(int fd, void *buf, size_t count) { + std::unique_lock guard(controller_mutex()); + auto made = uinput_map().find(fd); + if (made == uinput_map().end()) { + // Steam reading a controller node with a mailbox (HookupQueue): answered from it, unlocked + auto deck = deck_map().find(fd); + if (deck == deck_map().end()) return -2; + if (!deck->second->hookup_in) { // a node without one (touch screen, or the pad mailbox not patched in) + if (!deck->second->touch) hookup_boost_this_thread("reads DroidDeck's controller"); + return -2; + } + std::shared_ptr q = deck->second->hookup_in; + guard.unlock(); + return hookup_read_steam(fd, *q, buf, count); + } + std::shared_ptr w = made->second; + if (count < sizeof(struct input_event)) { + errno = EINVAL; + return -1; + } + for (;;) { + if (!w->ff_queue.empty()) { + size_t n = std::min(count / sizeof(struct input_event), w->ff_queue.size()); + for (size_t i = 0; i < n; i++) { + memcpy(static_cast(buf) + i * sizeof(struct input_event), &w->ff_queue.front(), sizeof(struct input_event)); + w->ff_queue.pop_front(); + } + // The eventfd counter mirrors "events pending": drained when the queue empties, kept >0 otherwise, + // so Steam's poll() on the fd stays accurate. + int flags = fcntl(fd, F_GETFL); + if (w->ff_queue.empty()) { + uint64_t counter; + if (flags >= 0 && !(flags & O_NONBLOCK)) fcntl(fd, F_SETFL, flags | O_NONBLOCK); + if (syscall(SYS_read, fd, &counter, sizeof(counter)) < 0) {} + if (flags >= 0 && !(flags & O_NONBLOCK)) fcntl(fd, F_SETFL, flags); + } else { + uint64_t one = 1; + if (syscall(SYS_write, fd, &one, sizeof(one)) < 0) {} + } + return static_cast(n * sizeof(struct input_event)); + } + int flags = fcntl(fd, F_GETFL); + if (flags >= 0 && (flags & O_NONBLOCK)) { + errno = EAGAIN; + return -1; + } + guard.unlock(); + uint64_t counter; + if (syscall(SYS_read, fd, &counter, sizeof(counter)) < 0 && errno != EINTR) return -1; // blocks until queued + guard.lock(); + } +} + +// ---- hot-plug: udev add/remove for the hooked-up nodes, plus the force-feedback pump ---- +__attribute__((visibility("hidden"))) static void *hookup_watch_thread(void *) { + uint32_t seen_gen[HOOKUP_SLOTS] = {}; + bool seen[HOOKUP_SLOTS] = {}; + char devpath[HOOKUP_SLOTS][160] = {}; + for (unsigned tick = 0;; tick++) { + // Steam runs: on DroidDeck's desktop (where Steam is one program among others) the SDL3 bridge hands + // controllers to Steam only while this heartbeat is fresh, and they are ordinary gamepads otherwise + if (tick % 50 == 0) + if (HookupHeader *h = hookup_header()) __atomic_store_n(&h->steam_ms, monotonic_us() / 1000, __ATOMIC_RELEASE); + sleep_us(2000); + hookup_ff_to_steam(); + if (tick % 10) continue; + for (int i = 0; i < HOOKUP_SLOTS; i++) { + HookupSlot *s = hookup_slot(i); + if (!s) continue; + bool now = __atomic_load_n(&s->present, __ATOMIC_ACQUIRE) != 0; + uint32_t gen = __atomic_load_n(&s->generation, __ATOMIC_ACQUIRE); + if (now == seen[i] && (!now || gen == seen_gen[i])) continue; + char path[32]; + snprintf(path, sizeof(path), "/dev/hidraw%u", HOOKUP_MINOR_BASE + i); + if (seen[i]) { + int sent = inject_uevent("remove", devpath[i], "hidraw", path, DECK_HIDRAW_MAJOR, HOOKUP_MINOR_BASE + i); + Logger::log("hookup: %s removed (udev event to %d monitors)\n", path, sent); + } + seen[i] = now; + seen_gen[i] = gen; + if (now) { + hookup_devpath(i, *s, devpath[i], sizeof(devpath[i])); + if (seen_gen[i] != 0) { + struct timespec settle = {0, 200 * 1000 * 1000}; + nanosleep(&settle, nullptr); + } + int sent = inject_uevent("add", devpath[i], "hidraw", path, DECK_HIDRAW_MAJOR, HOOKUP_MINOR_BASE + i); + Logger::log("hookup: %s is %04x:%04x \"%s\" (udev event to %d monitors)\n", path, s->vendor, s->product, s->name, sent); + } + poke_hidraw_watchers(HOOKUP_MINOR_BASE + i); + } + } + return nullptr; +} + +__attribute__((constructor)) static void start_hookup_watch() { + config(); + if (!process_is_steam_client() || !fake_deck_enabled()) return; + const char *path = getenv("FAKE_HOOKUP_SHM"); + if (!path || !*path) { + Logger::log("hookup: off (no FAKE_HOOKUP_SHM: the SDL3 bridge app is not installed, or an older DroidDeck build)\n"); + return; + } + if (!hookup_shm()) return; + Logger::log("hookup: watching %s for controllers handed over by the SDL3 bridge\n", path); + pthread_t thread; + if (pthread_create(&thread, nullptr, hookup_watch_thread, nullptr) == 0) pthread_detach(thread); +} +// ---- Steam Input's mouse / keyboard output, traced (pad.log "mouse: ...") ---- +// A Steam binding to a key, mouse movement or a mouse button reaches a game in three steps: Steam sends an XTest +// event to the game's X server (Xwayland; Steam does send clicks there), Xwayland hands it to gamescope over libei, +// and gamescope (libeis) delivers it to the game's window. Steps 2 and 3 are logged here, in the Xwayland and +// gamescope processes (this library is loaded in all of them), plus the input device each side sets up, and +// Steam's own XTest calls (step 1) -- so a +// click that goes missing shows where. Buttons are logged one by one at first (then every 100th), keys a few times +// to compare. Every call is passed on unchanged. The real function is looked up in the library itself if it is not +// next in line (a program that loaded it privately), so a call is never lost here. +namespace input_trace { +static std::atomic buttons{0}, keys{0}, setup{0}; + +static bool first(std::atomic &n, uint32_t count) { + uint32_t i = n.fetch_add(1, std::memory_order_relaxed); + return i < count || i % 100 == 0; +} + +/** Which X server this is (Xwayland ":0" / ":1" from its command line), or "" in other processes. */ +static const char *display() { + static char name[16] = {0}; + static std::once_flag once; + std::call_once(once, [] { + char args[4096]; + int fd = static_cast(syscall(SYS_openat, AT_FDCWD, "/proc/self/cmdline", O_RDONLY | O_CLOEXEC)); + if (fd < 0) return; + ssize_t n = syscall(SYS_read, fd, args, sizeof(args) - 1); + syscall(SYS_close, fd); + if (n <= 0) return; + args[n] = 0; + for (ssize_t i = 0; i < n; i += static_cast(strlen(args + i)) + 1) + if (args[i] == ':' && args[i + 1] >= '0' && args[i + 1] <= '9') { + snprintf(name, sizeof(name), " %s", args + i); + break; + } + }); + return name; +} + +static void log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); +static void log(const char *fmt, ...) { + config(); + char text[256]; + va_list args; + va_start(args, fmt); + vsnprintf(text, sizeof(text), fmt, args); + va_end(args); + Logger::log("mouse:%s %s\n", display(), text); +} + +/** Each libei / libeis event type once per process (the connection's life: added, resumed, paused, ...). */ +static void event_type_once(const char *side, uint32_t type) { + static std::atomic seen[4]; + uint32_t slot = type % 256; + uint64_t bit = 1ULL << (slot % 64); + if (seen[slot / 64].fetch_or(bit) & bit) return; + log("%s event type %u", side, type); +} + +static const char *x_display(void *display) { + using Fn = char *(*)(void *); + static Fn get = [] { + void *fn = dlsym(RTLD_DEFAULT, "XDisplayString"); + if (!fn) + if (void *x11 = dlopen("libX11.so.6", RTLD_LAZY | RTLD_NOLOAD)) fn = dlsym(x11, "XDisplayString"); + return reinterpret_cast(fn); + }(); + const char *name = display && get ? get(display) : nullptr; + return name ? name : "?"; +} + +template static Fn real(const char *name, const char *library) { + void *fn = dlsym(RTLD_NEXT, name); + if (!fn) { + void *handle = dlopen(library, RTLD_LAZY | RTLD_NOLOAD); + if (!handle) handle = dlopen(library, RTLD_LAZY); + if (handle) fn = dlsym(handle, name); // the library's own definition, not this one + } + if (!fn) log("could not find the real %s in %s: this call is dropped", name, library); + return reinterpret_cast(fn); +} + +static const char *caps(uint32_t c) { + static thread_local char text[64]; + // libei / libeis capability bits: pointer 1, pointer absolute 2, keyboard 4, touch 8, scroll 16, button 32 + snprintf(text, sizeof(text), "%s%s%s%s%s%s", c & 1 ? " pointer" : "", c & 2 ? " absolute" : "", + c & 4 ? " keyboard" : "", c & 8 ? " touch" : "", c & 16 ? " scroll" : "", c & 32 ? " button" : ""); + return text; +} +} // namespace input_trace + +// step 1 (Steam): XTest sent to the game's X server. Steam's client library links libXtst privately, so the real +// function is not "next" from here: it is looked up in libXtst itself (input_trace::real). +EXPORT int XTestFakeButtonEvent(void *display, unsigned int button, int is_press, unsigned long delay) { + static auto real = input_trace::real("XTestFakeButtonEvent", "libXtst.so.6"); + int result = real ? real(display, button, is_press, delay) : 0; + if (input_trace::first(input_trace::buttons, 40)) + input_trace::log("Steam sent XTest mouse button %u %s to X display %s (%s)", button, is_press ? "press" : "release", + input_trace::x_display(display), result ? "sent" : "NOT sent: no XTest on that display"); + return result; +} + +EXPORT int XTestFakeKeyEvent(void *display, unsigned int keycode, int is_press, unsigned long delay) { + static auto real = input_trace::real("XTestFakeKeyEvent", "libXtst.so.6"); + int result = real ? real(display, keycode, is_press, delay) : 0; + if (input_trace::first(input_trace::keys, 40)) + input_trace::log("Steam sent XTest key %u %s to X display %s (%s)", keycode, is_press ? "press" : "release", + input_trace::x_display(display), result ? "sent" : "NOT sent: no XTest on that display"); + return result; +} + +EXPORT int XTestFakeRelativeMotionEvent(void *display, int dx, int dy, unsigned long delay) { + static auto real = input_trace::real("XTestFakeRelativeMotionEvent", "libXtst.so.6"); + return real ? real(display, dx, dy, delay) : 0; +} + +EXPORT int XTestFakeMotionEvent(void *display, int screen, int x, int y, unsigned long delay) { + static auto real = input_trace::real("XTestFakeMotionEvent", "libXtst.so.6"); + static std::atomic n{0}; + if (n.fetch_add(1) < 3) input_trace::log("Steam moved the X pointer to %d,%d on X display %s", x, y, input_trace::x_display(display)); + return real ? real(display, screen, x, y, delay) : 0; +} + +// step 2 (Xwayland): the X server hands XTest input to gamescope over libei +EXPORT void ei_device_button_button(void *device, uint32_t button, bool is_press) { + static auto real = input_trace::real("ei_device_button_button", "libei.so.1"); + if (input_trace::first(input_trace::buttons, 24)) + input_trace::log("X server handed mouse button 0x%x %s to gamescope (libei)", button, is_press ? "press" : "release"); + if (real) real(device, button, is_press); +} + +EXPORT void ei_device_keyboard_key(void *device, uint32_t keycode, bool is_press) { + static auto real = input_trace::real("ei_device_keyboard_key", "libei.so.1"); + if (input_trace::first(input_trace::keys, 40)) + input_trace::log("X server handed key %u %s to gamescope (libei)", keycode, is_press ? "press" : "release"); + if (real) real(device, keycode, is_press); +} + +EXPORT bool ei_device_has_capability(void *device, uint32_t capability) { + static auto real = input_trace::real("ei_device_has_capability", "libei.so.1"); + bool has = real && real(device, capability); + if (input_trace::first(input_trace::setup, 24)) + input_trace::log("X server: gamescope's input device %p %s%s", device, has ? "can send" : "can NOT send", + input_trace::caps(capability)); + return has; +} + +EXPORT void ei_device_start_emulating(void *device, uint32_t sequence) { + static auto real = input_trace::real("ei_device_start_emulating", "libei.so.1"); + if (input_trace::first(input_trace::setup, 24)) input_trace::log("X server: input device %p starts sending", device); + if (real) real(device, sequence); +} + +EXPORT int ei_setup_backend_socket(void *ei, const char *socketpath) { + static auto real = input_trace::real("ei_setup_backend_socket", "libei.so.1"); + int result = real ? real(ei, socketpath) : -ENOSYS; + input_trace::log("X server connects to gamescope's input socket %s: %s", socketpath ? socketpath : "(LIBEI_SOCKET)", + result == 0 ? "ok" : strerror(result < 0 ? -result : result)); + return result; +} + +EXPORT void ei_device_pointer_motion(void *device, double x, double y) { + static auto real = input_trace::real("ei_device_pointer_motion", "libei.so.1"); + static std::atomic n{0}; + uint32_t i = n.fetch_add(1, std::memory_order_relaxed); + if (i < 3 || i % 500 == 0) input_trace::log("X server handed mouse motion #%u to gamescope (libei)", i + 1); + if (real) real(device, x, y); +} + +EXPORT uint32_t ei_event_get_type(void *event) { + static auto real = input_trace::real("ei_event_get_type", "libei.so.1"); + uint32_t type = real ? real(event) : 0; + input_trace::event_type_once("X server: libei", type); + return type; +} + +// step 3 (gamescope): received over libeis, then delivered to the window that has the pointer +EXPORT uint32_t eis_event_button_get_button(void *event) { + static auto real = input_trace::real("eis_event_button_get_button", "libeis.so.1"); + static auto pressed = input_trace::real("eis_event_button_get_is_press", "libeis.so.1"); + uint32_t button = real ? real(event) : 0; + if (input_trace::first(input_trace::buttons, 24)) + input_trace::log("gamescope received mouse button 0x%x %s (libeis)", button, + pressed && pressed(event) ? "press" : "release"); + return button; +} + +EXPORT uint32_t eis_event_keyboard_get_key(void *event) { + static auto real = input_trace::real("eis_event_keyboard_get_key", "libeis.so.1"); + static auto pressed = input_trace::real("eis_event_keyboard_get_key_is_press", "libeis.so.1"); + uint32_t key = real ? real(event) : 0; + if (input_trace::first(input_trace::keys, 40)) + input_trace::log("gamescope received key %u %s (libeis)", key, pressed && pressed(event) ? "press" : "release"); + return key; +} + +EXPORT void eis_client_connect(void *client) { + static auto real = input_trace::real("eis_client_connect", "libeis.so.1"); + static std::atomic n{0}; + input_trace::log("gamescope: X server #%u connected for input (libeis client %p)", n.fetch_add(1) + 1, client); + if (real) real(client); +} + +EXPORT uint32_t eis_event_get_type(void *event) { + static auto real = input_trace::real("eis_event_get_type", "libeis.so.1"); + uint32_t type = real ? real(event) : 0; + input_trace::event_type_once("gamescope: libeis", type); + return type; +} + +EXPORT void eis_device_configure_capability(void *device, uint32_t capability) { + static auto real = input_trace::real("eis_device_configure_capability", "libeis.so.1"); + if (input_trace::first(input_trace::setup, 24)) + input_trace::log("gamescope: its input device %p for the X server can send%s", device, input_trace::caps(capability)); + if (real) real(device, capability); +} +// =================== end sdl3bridge: Steam Native Hook-up =================== + __attribute__((visibility("hidden"))) static std::string note_sys_failure(int dirfd, const char *pathname) { int saved_errno = errno; std::string path; @@ -2955,7 +4786,13 @@ EXPORT int open(const char *pathname, int flags, ...) { static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags); + if (desktop_hidden_hidraw(pathname)) { // sdl3bridge: meant for Steam, not for desktop programs + errno = ENOENT; + return -1; + } if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags); + if (int hk = hookup_path_slot(pathname); hk >= 0) return open_hookup_hidraw(flags, hk); + if (is_sc1_hidraw_path(pathname)) return open_deck_hidraw(flags, 0, true); 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 (int sony = sony_pad_path(pathname)) return open_deck_hidraw(flags, static_cast(sony)); @@ -3033,7 +4870,13 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { static auto my_openat = reinterpret_cast(dlsym(RTLD_NEXT, "openat")); if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags); + if (desktop_hidden_hidraw(pathname)) { // sdl3bridge: meant for Steam, not for desktop programs + errno = ENOENT; + return -1; + } if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags); + if (int hk = hookup_path_slot(pathname); hk >= 0) return open_hookup_hidraw(flags, hk); + if (is_sc1_hidraw_path(pathname)) return open_deck_hidraw(flags, 0, true); 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 (int sony = sony_pad_path(pathname)) return open_deck_hidraw(flags, static_cast(sony)); @@ -3096,7 +4939,11 @@ 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) || is_triton_pad_path(pathname) || - sony_pad_path(pathname)) && !process_is_steam_client())) { + sony_pad_path(pathname) || is_sc1_hidraw_path(pathname)) && !process_is_steam_client())) { + errno = ENOENT; + return -1; + } + if (hookup_path_slot(pathname) >= 0 && !process_is_steam_client()) { errno = ENOENT; return -1; } @@ -3112,10 +4959,22 @@ 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 (int hk = hookup_path_slot(pathname); hk >= 0) { + memset(statbuf, 0, sizeof(*statbuf)); + statbuf->st_mode = S_IFCHR | 0666; + statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, HOOKUP_MINOR_BASE + hk); + return 0; + } + if (is_sc1_hidraw_path(pathname)) { + memset(statbuf, 0, sizeof(*statbuf)); + statbuf->st_mode = S_IFCHR | 0666; + statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, SC1_HIDRAW_MINOR); + return 0; + } if (int sony = sony_pad_path(pathname)) { memset(statbuf, 0, sizeof(*statbuf)); statbuf->st_mode = S_IFCHR | 0666; - statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, sony == 4 ? DS4_HIDRAW_MINOR : DUALSENSE_HIDRAW_MINOR); + statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, sony == 4 ? DS4_HIDRAW_MINOR : sony == 6 ? DUALSENSE_EDGE_HIDRAW_MINOR : DUALSENSE_HIDRAW_MINOR); return 0; } if (is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname)) { @@ -3171,11 +5030,14 @@ 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 + unsigned int minor = device.hookup >= 0 ? HOOKUP_MINOR_BASE + device.hookup + : device.touch ? TOUCH_HIDRAW_MINOR : device.sony == 4 ? DS4_HIDRAW_MINOR + : device.sony == 6 ? DUALSENSE_EDGE_HIDRAW_MINOR : device.sony == 5 ? DUALSENSE_HIDRAW_MINOR : device.emulated ? TRITON_PAD_HIDRAW_MINOR - : device.triton ? TRITON_HIDRAW_MINOR : DECK_HIDRAW_MINOR; + : device.triton ? TRITON_HIDRAW_MINOR + : device.sc1 ? SC1_HIDRAW_MINOR : DECK_HIDRAW_MINOR; buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, minor); return ret; } @@ -3222,11 +5084,15 @@ 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_sc1_hidraw_path(pathname) && process_is_steam_client()) return 0; + if (hookup_path_slot(pathname) >= 0 && process_is_steam_client()) return 0; if ((is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname) || sony_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_sc1_hidraw_path(pathname) || + hookup_path_slot(pathname) >= 0 || + is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname) || sony_pad_path(pathname)) { errno = ENOENT; return -1; @@ -3263,11 +5129,15 @@ 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_sc1_hidraw_path(pathname) && process_is_steam_client()) return 0; + if (hookup_path_slot(pathname) >= 0 && process_is_steam_client()) return 0; if ((is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname) || sony_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_sc1_hidraw_path(pathname) || + hookup_path_slot(pathname) >= 0 || + is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname) || sony_pad_path(pathname)) { errno = ENOENT; return -1; @@ -3396,6 +5266,11 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp); auto deck = deck_map().find(fd); if (deck != deck_map().end()) { + if (deck->second->hookup >= 0) { + std::shared_ptr pad = deck->second; + guard.unlock(); + return ioctl_hookup(*pad, op, argp); + } if (deck->second->touch) return ioctl_touch(*deck->second, op, argp); if (deck->second->sony) return ioctl_sony(*deck->second, op, argp); if (deck->second->triton) { @@ -3643,6 +5518,7 @@ static ssize_t copy_input_events(void *buf, const struct input_event *events, si static ssize_t read_input(int fd, void *buf, size_t count) { if (!watched(fd)) return syscall(SYS_read, fd, buf, count); + if (ssize_t ff = hookup_read_uinput(fd, buf, count); ff != -2) return ff; std::unique_lock guard(controller_mutex()); auto controller = controller_map().find(fd); if (controller == controller_map().end()) { @@ -3704,6 +5580,7 @@ static ssize_t read_input(int fd, void *buf, size_t count) { continue; ssize_t copied = copy_input_events(buf, input_events.data(), events); if (copied > 0) fake.read_seq += copied / sizeof(struct input_event); + if (copied > 0 && fake.uinput) hookup_meter_game_read(input_events.data(), copied / sizeof(struct input_event)); return copied; } } @@ -3743,7 +5620,18 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { return read_input(fd, buf, count); } +// sdl3bridge: read() as programs built with _FORTIFY_SOURCE call it +EXPORT ssize_t __read_chk(int fd, void *buf, size_t count, size_t buflen) { + if (count > buflen) { // a buffer overflow: the C library reports it, as it would without the hook + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "__read_chk")); + if (real) return real(fd, buf, count, buflen); + abort(); + } + return read_input(fd, buf, count); +} + EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) { + if (ssize_t mailbox = hookup_readv_mailbox(fd, iov, iovcnt); mailbox != -2) return mailbox; if (!is_fake_input_fd(fd)) return syscall(SYS_readv, fd, iov, iovcnt); if (iovcnt < 0 || iovcnt > IOV_MAX) { errno = EINVAL; @@ -3793,6 +5681,12 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) { auto made = uinput_map().find(fd); if (made != uinput_map().end()) return write_uinput(*made->second, buf, count); if (deck_map().count(fd)) { + if (deck_map()[fd]->hookup >= 0) { + std::shared_ptr pad = deck_map()[fd]; + guard.unlock(); + hookup_queue(*pad, static_cast(buf), count, 0); + return static_cast(count); + } if (deck_map()[fd]->touch) touch_output(static_cast(buf), count); else if (deck_map()[fd]->sony) sony_output(*deck_map()[fd], static_cast(buf), count); else if (deck_map()[fd]->emulated) triton_pad_output(static_cast(buf), count); @@ -3825,6 +5719,14 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) { if (deck_map().count(fd)) { ssize_t total = 0; auto device = deck_map()[fd]; + if (device->hookup >= 0) { + guard.unlock(); + for (int i = 0; i < iovcnt; i++) { + hookup_queue(*device, static_cast(iov[i].iov_base), iov[i].iov_len, 0); + total += static_cast(iov[i].iov_len); + } + return total; + } for (int i = 0; i < iovcnt; i++) { if (device->touch) touch_output(static_cast(iov[i].iov_base), iov[i].iov_len); else if (device->sony) sony_output(*device, static_cast(iov[i].iov_base), iov[i].iov_len); diff --git a/app/src/main/cpp/sdl3bridge/CMakeLists.txt b/app/src/main/cpp/sdl3bridge/CMakeLists.txt new file mode 100644 index 000000000..a9f2757fd --- /dev/null +++ b/app/src/main/cpp/sdl3bridge/CMakeLists.txt @@ -0,0 +1,15 @@ +# sdl3droiddeck: SDL3 and the SDL3 bridge's controller loop, built into the app (libSDL3.so + libmain.so, loaded by +# the bridge in its own ":sdl3" process). Written by patch-droiddeck.sh (step 30); the sources are SdlBridge's. +set(SDL_SHARED ON CACHE BOOL "" FORCE) +set(SDL_STATIC OFF CACHE BOOL "" FORCE) +set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE) +set(SDL_TESTS OFF CACHE BOOL "" FORCE) +set(SDL_EXAMPLES OFF CACHE BOOL "" FORCE) +set(SDL_ANDROID_JAR OFF CACHE BOOL "" FORCE) +set(SDL_INSTALL OFF CACHE BOOL "" FORCE) +# The bridge draws nothing (SDL3 runs headless for controllers): no OpenGL ES, so libSDL3.so needs no libGLESv1_CM.so, +# which DroidDeck's APK check (tools/release/check_android_libraries.py) does not count as a system library. +set(SDL_OPENGLES OFF CACHE BOOL "" FORCE) +add_subdirectory(SDL) +add_library(main SHARED src/main.c) +target_link_libraries(main PRIVATE SDL3::SDL3 log) diff --git a/app/src/main/cpp/sdl3bridge/src/hkshm.h b/app/src/main/cpp/sdl3bridge/src/hkshm.h new file mode 100644 index 000000000..037d50b4d --- /dev/null +++ b/app/src/main/cpp/sdl3bridge/src/hkshm.h @@ -0,0 +1,213 @@ +/* + * Steam Native Hook-up fast path: direct access to DroidDeck's shared hook-up file (".hookup"). + * + * DroidDeck passes the file to the bridge as a file descriptor (Android lets an app use a descriptor another + * app hands it, only opening the other app's file by path is forbidden). The bridge then writes every HID + * report of a hooked-up controller straight into the slot's input FIFO and wakes Steam's reader with a futex, + * and takes Steam's output reports / feature reads from the slot itself -- no Java, no Binder message, no + * polling on either side. DroidDeck still owns the slot (identity, sysfs, present flag); while the bridge + * serves a slot it keeps the slot's direct_ms heartbeat fresh and DroidDeck leaves that slot's FIFOs alone. + * + * Plain C (no SDL / JNI) so it can be tested on Linux against the real fakeinput hook. + * Layout must match HookupSlot in DroidDeck's hookup_native.inc and HookupBridge.kt. + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +enum { + HKS_MAGIC = 0x4b4f4f48, + HKS_HEADER = 4096, + HKS_SLOT = 53248, + HKS_SLOTS = 4, + HKS_SIZE = HKS_HEADER + HKS_SLOTS * HKS_SLOT, + /* slot fields */ + HKS_PRESENT = 0, + HKS_VENDOR = 8, + HKS_PRODUCT = 10, + HKS_IN_WRITE = 344, + HKS_OUT_WRITE = 352, + HKS_OUT_READ = 360, + HKS_GET_REQUEST = 368, + HKS_GET_REPLY = 376, + HKS_GET_REPORT_ID = 384, + HKS_GET_LENGTH = 388, + HKS_GET = 392, + HKS_DIRECT_MS = 520, + HKS_RATE_HZ = 528, /* the bridge's poll rate: Steam's side hands Steam at most this many reports a second */ + HKS_IN = 5120, + HKS_OUT = 41984, + HKS_IN_FIFO = 256, + HKS_OUT_FIFO = 64, + HKS_ENTRY = 144, + HKS_REPORT_MAX = 128 +}; + +#define HKS_FUTEX_WAKE 1 /* shared (not FUTEX_PRIVATE_FLAG): the waiter is Steam, another process */ + +typedef struct { + uint8_t *base; + int fd; + uint64_t ino; +} HkShm; + +typedef struct { + uint32_t kind; /* 0 output report, 1 feature report */ + int len; + uint8_t data[HKS_REPORT_MAX]; +} HksOut; + +static inline uint64_t hks_now_ms(void) +{ + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); /* = Android's SystemClock.uptimeMillis() clock */ + return (uint64_t)t.tv_sec * 1000u + (uint64_t)t.tv_nsec / 1000000u; +} + +/* Read time of an input report for DroidDeck's latency meter: CLOCK_MONOTONIC in us, low 32 bits, never 0. */ +static inline uint32_t hks_stamp_us(void) +{ + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + uint32_t us = (uint32_t)((uint64_t)t.tv_sec * 1000000u + (uint64_t)t.tv_nsec / 1000u); + return us ? us : 1; +} + +static inline uint8_t *hks_slot(const HkShm *m, int i) { return m->base + HKS_HEADER + (size_t)i * HKS_SLOT; } +static inline uint64_t *hks_u64(uint8_t *s, int off) { return (uint64_t *)(void *)(s + off); } +static inline uint32_t *hks_u32(uint8_t *s, int off) { return (uint32_t *)(void *)(s + off); } + +/* Maps the file behind fd (takes ownership of fd). 0 = ok, <0 = why not. */ +static inline int hks_map(HkShm *m, int fd) +{ + struct stat st; + if (fd < 0) return -1; + if (fstat(fd, &st) != 0 || st.st_size < HKS_SIZE) { close(fd); return -2; } + void *p = mmap(NULL, HKS_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (p == MAP_FAILED) { close(fd); return -3; } + if (__atomic_load_n((uint32_t *)p, __ATOMIC_ACQUIRE) != HKS_MAGIC) { munmap(p, HKS_SIZE); close(fd); return -4; } + m->base = (uint8_t *)p; + m->fd = fd; + m->ino = (uint64_t)st.st_ino; + return 0; +} + +static inline void hks_unmap(HkShm *m) +{ + if (m->base) munmap(m->base, HKS_SIZE); + if (m->fd >= 0) close(m->fd); + m->base = NULL; + m->fd = -1; + m->ino = 0; +} + +/* Still DroidDeck's live file? (A new DroidDeck session deletes it and makes a new one.) */ +static inline int hks_alive(const HkShm *m) +{ + struct stat st; + return m->base && fstat(m->fd, &st) == 0 && st.st_nlink > 0; +} + +/* Does DroidDeck show this controller in slot i right now? Only then may the bridge serve the slot. */ +static inline int hks_owns(const HkShm *m, int i, uint16_t vid, uint16_t pid) +{ + if (!m->base || i < 0 || i >= HKS_SLOTS) return 0; + uint8_t *s = hks_slot(m, i); + if (!__atomic_load_n(hks_u32(s, HKS_PRESENT), __ATOMIC_ACQUIRE)) return 0; + uint16_t v, p; + memcpy(&v, s + HKS_VENDOR, 2); + memcpy(&p, s + HKS_PRODUCT, 2); + return v == vid && p == pid; +} + +/* "The bridge serves this slot": DroidDeck and Steam's side read it (stale after ~1.5 s). */ +static inline void hks_heartbeat(const HkShm *m, int i, uint64_t now_ms) +{ + __atomic_store_n(hks_u64(hks_slot(m, i), HKS_DIRECT_MS), now_ms, __ATOMIC_RELEASE); +} + +/* The rate Steam's side hands this slot's reports to Steam (the bridge's Poll rate setting; 0 = every report). */ +static inline void hks_set_rate(const HkShm *m, int i, uint32_t hz) +{ + __atomic_store_n(hks_u32(hks_slot(m, i), HKS_RATE_HZ), hz, __ATOMIC_RELEASE); +} + +/* One input report (controller -> Steam): same protocol as HookupBridge.input(), then wake Steam's reader. */ +static inline void hks_write_in(const HkShm *m, int i, const uint8_t *data, int n) +{ + uint8_t *s = hks_slot(m, i); + uint64_t *write = hks_u64(s, HKS_IN_WRITE); + uint64_t idx = __atomic_load_n(write, __ATOMIC_RELAXED); + uint8_t *e = s + HKS_IN + (size_t)(idx % HKS_IN_FIFO) * HKS_ENTRY; + if (n < 0) n = 0; + if (n > HKS_REPORT_MAX) n = HKS_REPORT_MAX; + __atomic_store_n(hks_u64(e, 0), 0, __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_RELEASE); + __atomic_store_n(hks_u32(e, 8), (uint32_t)n, __ATOMIC_RELAXED); + __atomic_store_n(hks_u32(e, 12), hks_stamp_us(), __ATOMIC_RELAXED); /* when the bridge read it: latency meter */ + memcpy(e + 16, data, (size_t)n); + __atomic_thread_fence(__ATOMIC_RELEASE); + __atomic_store_n(hks_u64(e, 0), idx + 1, __ATOMIC_RELEASE); + __atomic_store_n(write, idx + 1, __ATOMIC_RELEASE); + /* Steam's reader sleeps on the low half of in_write (little endian). */ + syscall(SYS_futex, (uint32_t *)(void *)write, HKS_FUTEX_WAKE, INT_MAX, NULL, NULL, 0); +} + +/* Takes up to max queued reports Steam wrote (rumble, lights, settings). */ +static inline int hks_take_out(const HkShm *m, int i, HksOut *out, int max) +{ + uint8_t *s = hks_slot(m, i); + uint64_t read = __atomic_load_n(hks_u64(s, HKS_OUT_READ), __ATOMIC_ACQUIRE); + uint64_t written = __atomic_load_n(hks_u64(s, HKS_OUT_WRITE), __ATOMIC_ACQUIRE); + int n = 0; + while (read < written && n < max) { + uint8_t *e = s + HKS_OUT + (size_t)(read % HKS_OUT_FIFO) * HKS_ENTRY; + if (__atomic_load_n(hks_u64(e, 0), __ATOMIC_ACQUIRE) != read + 1) break; /* still being written */ + uint32_t len = __atomic_load_n(hks_u32(e, 8), __ATOMIC_RELAXED); + if (len > HKS_REPORT_MAX) len = HKS_REPORT_MAX; + out[n].len = (int)len; + out[n].kind = __atomic_load_n(hks_u32(e, 12), __ATOMIC_RELAXED); + memcpy(out[n].data, e + 16, len); + n++; + read++; + } + __atomic_store_n(hks_u64(s, HKS_OUT_READ), read, __ATOMIC_RELEASE); + return n; +} + +/* A feature / input report read Steam is waiting for? (1 = yes; *last remembers what was taken.) */ +static inline int hks_get_pending(const HkShm *m, int i, uint64_t *last, uint64_t *request, int *report_id, + int *input, int *length) +{ + uint8_t *s = hks_slot(m, i); + uint64_t r = __atomic_load_n(hks_u64(s, HKS_GET_REQUEST), __ATOMIC_ACQUIRE); + if (r == *last || r == __atomic_load_n(hks_u64(s, HKS_GET_REPLY), __ATOMIC_ACQUIRE)) return 0; + *last = r; + uint32_t word = __atomic_load_n(hks_u32(s, HKS_GET_REPORT_ID), __ATOMIC_RELAXED); + int32_t len; + memcpy(&len, s + HKS_GET_LENGTH, 4); + *request = r; + *report_id = (int)(word & 0xff); + *input = (int)((word >> 8) & 1); + *length = len < 1 ? 1 : len > HKS_REPORT_MAX ? HKS_REPORT_MAX : len; + return 1; +} + +/* Answer to that read (n <= 0: it failed). */ +static inline void hks_get_reply(const HkShm *m, int i, uint64_t request, const uint8_t *data, int n) +{ + uint8_t *s = hks_slot(m, i); + if (n < 0 || !data) n = 0; + if (n > HKS_REPORT_MAX) n = HKS_REPORT_MAX; + if (n) memcpy(s + HKS_GET, data, (size_t)n); + int32_t len = n; + __atomic_store_n((int32_t *)(void *)(s + HKS_GET_LENGTH), len, __ATOMIC_RELAXED); + __atomic_store_n(hks_u64(s, HKS_GET_REPLY), request, __ATOMIC_RELEASE); +} diff --git a/app/src/main/cpp/sdl3bridge/src/hookup.h b/app/src/main/cpp/sdl3bridge/src/hookup.h new file mode 100644 index 000000000..54f407f32 --- /dev/null +++ b/app/src/main/cpp/sdl3bridge/src/hookup.h @@ -0,0 +1,714 @@ +/* + * "Steam Native Hook-up": instead of reading a controller as a gamepad and re-presenting it, hand Steam the + * REAL controller. SDL3 is still used to find out which USB HID interface is a game controller (any + * controller SDL3's USB driver supports); that interface is then released by SDL's gamepad layer and opened + * raw, and every HID report travels unchanged between the controller and DroidDeck's hidraw node -- with the + * controller's real vendor/product id, name, serial and report descriptor -- so Steam's own driver for that + * controller runs, exactly like on a PC: a Vader 4 Pro with its back buttons, C/Z and gyro, a DualSense Edge + * with its touchpad, mute, paddles and Fn buttons, an Xbox controller, a Switch Pro controller, ... + * + * Controllers SDL3 can only reach through Android's input (Bluetooth) cannot be handed over raw; they keep + * working as before (DroidDeck's Steam Deck pad). Included once from main.c; SDL_hid calls run on the SDL thread. + */ +#pragma once + +#include +#include +#include "hkshm.h" + +#define HOOKUP_SLOTS 4 +#define HOOKUP_REPORT_MAX 128 + +typedef struct { + SDL_hid_device *dev; + char path[256]; + Uint16 vid, pid; + int iface; +} HookupDev; + +static HookupDev g_hk[HOOKUP_SLOTS]; +static _Atomic int g_hookup_on; +static _Atomic int g_hookup_held; /* on, but waiting for Steam (DroidDeck's desktop without Steam running) */ +static int g_hookup_applied = -1; /* last mode the SDL thread acted on */ +static char g_hk_types[1024]; /* "0xVVVV/0xPPPP,..." controller types handed to Steam */ +static int g_hk_iface_of_type[32]; /* interface number SDL used, per entry in g_hk_types (index order) */ +static Uint16 g_hk_type_vid[32], g_hk_type_pid[32]; +static int g_hk_type_count; +static Uint64 g_hk_scan_ms; +static Uint32 g_hk_changes = 0xffffffffu; +static jmethodID g_hk_added, g_hk_input, g_hk_removed, g_hk_reply, g_hk_prefetch, g_hk_log; + +/* ---- fast path (hkshm.h): reports go straight into DroidDeck's shared file, read by a dedicated thread ---- + * g_hk_io is held by the reader thread for each pass over the controllers, and by anyone who remaps the file + * or releases a controller, so neither disappears under the reader. */ +static HkShm g_hks = { NULL, -1, 0 }; +static pthread_mutex_t g_hk_io = PTHREAD_MUTEX_INITIALIZER; +/* Held by the writer thread while it uses the controllers (its USB writes can take a few ms, so not under g_hk_io); + * releasing a controller takes it first. Order: g_hk_wr, then g_hk_io. */ +static pthread_mutex_t g_hk_wr = PTHREAD_MUTEX_INITIALIZER; +static _Atomic int g_hk_writer_started; +static _Atomic int g_hk_gone[HOOKUP_SLOTS]; /* reader saw the controller go away; SDL thread closes it */ +static _Atomic int g_hk_reader_started; +static _Atomic int g_hk_fast_now[HOOKUP_SLOTS]; /* slot currently served through the shared file */ +static int g_hk_fast_logged[HOOKUP_SLOTS]; /* SDL thread: last fast/slow state logged per slot */ +static uint64_t g_hk_get_last[HOOKUP_SLOTS]; /* writer thread: last feature read taken from the file */ +static Uint64 g_hk_alive_ms; + +/* Logged to logcat AND sent to DroidDeck, which writes it into the session's pad.log ("sdl3bridge: ..."). */ +static void hk_log(JNIEnv *env, const char *fmt, ...) +{ + char line[400]; + va_list ap; + va_start(ap, fmt); + vsnprintf(line, sizeof(line), fmt, ap); + va_end(ap); + SDL_Log("%s", line); + if (env && g_hk_log) { + jstring s = (*env)->NewStringUTF(env, line); + if (s) { + (*env)->CallStaticVoidMethod(env, g_cls, g_hk_log, s); + (*env)->DeleteLocalRef(env, s); + } + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + } +} + +/* ---- requests from Java (any thread) -> SDL thread ---- */ +enum { HK_OUT = 0, HK_FEATURE_SET = 1, HK_GET_FEATURE = 2, HK_GET_INPUT = 3 }; +typedef struct { + int slot, type, len; + jlong request; + Uint8 data[HOOKUP_REPORT_MAX]; +} HookupReq; +#define HK_QUEUE 128 +static HookupReq g_hk_queue[HK_QUEUE]; +static int g_hk_head, g_hk_tail; +static SDL_SpinLock g_hk_lock; + +static void hk_push(int slot, int type, const Uint8 *data, int len, jlong request) +{ + if (slot < 0 || slot >= HOOKUP_SLOTS) return; + if (len < 0) len = 0; + if (len > HOOKUP_REPORT_MAX) len = HOOKUP_REPORT_MAX; + SDL_LockSpinlock(&g_hk_lock); + int next = (g_hk_tail + 1) % HK_QUEUE; + if (next != g_hk_head) { + HookupReq *r = &g_hk_queue[g_hk_tail]; + r->slot = slot; r->type = type; r->len = len; r->request = request; + if (len) memcpy(r->data, data, (size_t)len); + g_hk_tail = next; + } + SDL_UnlockSpinlock(&g_hk_lock); +} + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetHookup(JNIEnv *env, jclass c, jint on) +{ + (void)env; (void)c; + atomic_store(&g_hookup_held, on == 2); /* DroidDeck's desktop without Steam: wait (controllers stay gamepads) */ + atomic_store(&g_hookup_on, on == 1); +} + +/* DroidDeck handed over its shared hook-up file (any thread). Returns 0 when the fast path is on. */ +JNIEXPORT jint JNICALL Java_com_example_dsedge_NativeBridge_nativeHookupMapShm(JNIEnv *env, jclass c, jint fd) +{ + (void)c; + HkShm m = { NULL, -1, 0 }; + int r = hks_map(&m, fd); + pthread_mutex_lock(&g_hk_io); + int same = r == 0 && g_hks.base && g_hks.ino == m.ino && hks_alive(&g_hks); + if (r == 0 && !same) { + hks_unmap(&g_hks); + g_hks = m; + } + pthread_mutex_unlock(&g_hk_io); + if (same) hks_unmap(&m); /* already mapped: keep the old mapping */ + if (r == 0 && !same) + hk_log(env, "hookup: fast path on: reports go straight into DroidDeck's shared memory (no messages, Steam woken at once)"); + else if (r != 0) + hk_log(env, "hookup: fast path unavailable (shared file could not be mapped, %d); using messages", r); + return r; +} + +/* Inode of the mapped live file (0: none) -- DroidDeck compares it with its own file to know if it must resend. */ +JNIEXPORT jlong JNICALL Java_com_example_dsedge_NativeBridge_nativeHookupShmInode(JNIEnv *env, jclass c) +{ + (void)env; (void)c; + pthread_mutex_lock(&g_hk_io); + jlong ino = hks_alive(&g_hks) ? (jlong)g_hks.ino : 0; + pthread_mutex_unlock(&g_hk_io); + return ino; +} + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeHookupOutput(JNIEnv *env, jclass c, jint slot, jint kind, jbyteArray data) +{ + (void)c; + if (!data) return; + jsize n = (*env)->GetArrayLength(env, data); + if (n < 1) return; + Uint8 buf[HOOKUP_REPORT_MAX]; + if (n > HOOKUP_REPORT_MAX) n = HOOKUP_REPORT_MAX; + (*env)->GetByteArrayRegion(env, data, 0, n, (jbyte *)buf); + hk_push(slot, kind == 1 ? HK_FEATURE_SET : HK_OUT, buf, (int)n, 0); +} + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeHookupGet(JNIEnv *env, jclass c, jint slot, jlong request, + jint reportId, jboolean input, jint length) +{ + (void)env; (void)c; + Uint8 buf[2] = { (Uint8)reportId, (Uint8)(length > HOOKUP_REPORT_MAX ? HOOKUP_REPORT_MAX : length < 1 ? 1 : length) }; + hk_push(slot, input ? HK_GET_INPUT : HK_GET_FEATURE, buf, 2, request); +} + +/* ---- SDL thread -> Java ---- */ +static void hk_java_init(JNIEnv *env) +{ + if (g_hk_added) return; + g_hk_added = (*env)->GetStaticMethodID(env, g_cls, "onHookupAdded", "(ILjava/lang/String;III)V"); + g_hk_input = (*env)->GetStaticMethodID(env, g_cls, "onHookupInput", "(I[B)V"); + g_hk_removed = (*env)->GetStaticMethodID(env, g_cls, "onHookupRemoved", "(I)V"); + g_hk_reply = (*env)->GetStaticMethodID(env, g_cls, "onHookupReply", "(IJ[B)V"); + g_hk_prefetch = (*env)->GetStaticMethodID(env, g_cls, "prefetchHookupDescriptor", "(Ljava/lang/String;)V"); + g_hk_log = (*env)->GetStaticMethodID(env, g_cls, "onBridgeLog", "(Ljava/lang/String;)V"); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); +} + +static jbyteArray hk_bytes(JNIEnv *env, const Uint8 *data, int len) +{ + if (!data || len <= 0) return NULL; + jbyteArray arr = (*env)->NewByteArray(env, len); + if (arr) (*env)->SetByteArrayRegion(env, arr, 0, len, (const jbyte *)data); + return arr; +} + +static bool hookup_active(void) +{ + for (int i = 0; i < HOOKUP_SLOTS; i++) if (g_hk[i].dev) return true; + return false; +} + +static int hookup_count(void) +{ + int n = 0; + for (int i = 0; i < HOOKUP_SLOTS; i++) if (g_hk[i].dev) n++; + return n; +} + +/* Hooked-up controllers whose reports currently go straight into DroidDeck's shared memory. */ +static int hookup_fast_count(void) +{ + int n = 0; + for (int i = 0; i < HOOKUP_SLOTS; i++) if (g_hk[i].dev && atomic_load(&g_hk_fast_now[i])) n++; + return n; +} + +static void hk_close(JNIEnv *env, int i, const char *why) +{ + if (!g_hk[i].dev) return; + hk_log(env, "hookup: slot %d %04x:%04x released (%s)", i, g_hk[i].vid, g_hk[i].pid, why); + pthread_mutex_lock(&g_hk_wr); /* the writer thread is not using it now, and won't see it again */ + pthread_mutex_lock(&g_hk_io); /* nor is the reader thread */ + SDL_hid_device *dev = g_hk[i].dev; + memset(&g_hk[i], 0, sizeof(g_hk[i])); + atomic_store(&g_hk_gone[i], 0); + atomic_store(&g_hk_fast_now[i], 0); + pthread_mutex_unlock(&g_hk_io); + pthread_mutex_unlock(&g_hk_wr); + g_hk_fast_logged[i] = 0; + SDL_hid_close(dev); + if (g_hk_removed) (*env)->CallStaticVoidMethod(env, g_cls, g_hk_removed, (jint)i); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); +} + +static bool hk_path_open(const char *path) +{ + for (int i = 0; i < HOOKUP_SLOTS; i++) if (g_hk[i].dev && !strcmp(g_hk[i].path, path)) return true; + return false; +} + +/* + * Reader thread: takes each report the moment SDL's USB thread has queued it (checked every 250 us instead of + * once per main-loop pass) and writes it straight into Steam's FIFO, waking Steam's reader. When the fast path + * is not available for a slot, the report goes the old way (Java -> message to DroidDeck). + */ +static int SDLCALL hk_reader(void *unused) +{ + (void)unused; + JNIEnv *env = (JNIEnv *)SDL_GetAndroidJNIEnv(); + setpriority(PRIO_PROCESS, (id_t)syscall(SYS_gettid), -10); /* like Android's URGENT_DISPLAY; ignored if refused */ + /* Latency meter, every controller: do reports come from Android's USB layer one by one, or in bursts (several + * waiting at once = they were held up before the bridge got them)? Logged every 10 s: the first time, then + * when bursts or long gaps show up. */ + uint32_t m_reports[HOOKUP_SLOTS] = {0}, m_passes[HOOKUP_SLOTS] = {0}, m_burst[HOOKUP_SLOTS] = {0}; + uint64_t m_last_us[HOOKUP_SLOTS] = {0}, m_gap_us[HOOKUP_SLOTS] = {0}, m_since_us = 0; + int m_logged[HOOKUP_SLOTS] = {0}; + for (;;) { + int got = 0, any = 0; + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + uint64_t now_us = (uint64_t)ts.tv_sec * 1000000u + (uint64_t)ts.tv_nsec / 1000u; + pthread_mutex_lock(&g_hk_io); + for (int i = 0; i < HOOKUP_SLOTS; i++) { + SDL_hid_device *dev = g_hk[i].dev; + if (!dev || atomic_load(&g_hk_gone[i])) continue; + any = 1; + int fast = hks_owns(&g_hks, i, g_hk[i].vid, g_hk[i].pid); + atomic_store(&g_hk_fast_now[i], fast); + uint32_t pass = 0; + for (int reads = 0; reads < 64; reads++) { + Uint8 buf[HOOKUP_REPORT_MAX]; + int n = SDL_hid_read(dev, buf, sizeof(buf)); + if (n == 0) break; + if (n < 0) { atomic_store(&g_hk_gone[i], 1); break; } + got++; + pass++; + if (fast) { + hks_write_in(&g_hks, i, buf, n); + } else if (env && g_hk_input) { + jbyteArray arr = hk_bytes(env, buf, n); + if (arr) (*env)->CallStaticVoidMethod(env, g_cls, g_hk_input, (jint)i, arr); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + if (arr) (*env)->DeleteLocalRef(env, arr); + } + } + if (pass) { + m_reports[i] += pass; + m_passes[i]++; + if (pass > m_burst[i]) m_burst[i] = pass; + if (m_last_us[i] && now_us - m_last_us[i] > m_gap_us[i]) m_gap_us[i] = now_us - m_last_us[i]; + m_last_us[i] = now_us; + } + } + pthread_mutex_unlock(&g_hk_io); + if (!m_since_us) m_since_us = now_us; + if (now_us - m_since_us >= 10000000u) { + double secs = (now_us - m_since_us) / 1e6; + for (int i = 0; i < HOOKUP_SLOTS; i++) { + if (!m_passes[i]) continue; + if (env && (!m_logged[i]++ || m_burst[i] > 2 || m_gap_us[i] > 8000)) + hk_log(env, "latency: slot %d: %.0f reports/s from USB, up to %u waiting at once (avg %.2f), " + "longest gap %.1f ms", i, m_reports[i] / secs, m_burst[i], + (double)m_reports[i] / m_passes[i], m_gap_us[i] / 1000.0); + m_reports[i] = m_passes[i] = m_burst[i] = 0; + m_gap_us[i] = 0; + } + m_since_us = now_us; + } + if (!got) { + struct timespec t = { 0, any ? 250000L : 20000000L }; /* 250 us while controllers are handed over */ + nanosleep(&t, NULL); + } + } + return 0; +} + +/* + * Writer thread: Steam's output reports (rumble, lights, settings) and feature reads for the slots the bridge + * serves through the shared file, sent to the controller as soon as Steam queues them (checked every 500 us). + * Each USB write takes a few ms; done in the SDL main loop they waited for its next pass and for each other, + * so under heavy rumble Steam's queue filled up and Steam had to wait for room. + */ +static int SDLCALL hk_writer(void *unused) +{ + (void)unused; + setpriority(PRIO_PROCESS, (id_t)syscall(SYS_gettid), -10); + for (;;) { + int did = 0, any = 0; + pthread_mutex_lock(&g_hk_wr); + for (int i = 0; i < HOOKUP_SLOTS; i++) { + HksOut outs[16]; + int nout = 0, want_get = 0, report_id = 0, input = 0, length = 0; + uint64_t request = 0; + pthread_mutex_lock(&g_hk_io); + SDL_hid_device *dev = g_hk[i].dev; + if (dev && !atomic_load(&g_hk_gone[i]) && hks_owns(&g_hks, i, g_hk[i].vid, g_hk[i].pid)) { + nout = hks_take_out(&g_hks, i, outs, (int)SDL_arraysize(outs)); + want_get = hks_get_pending(&g_hks, i, &g_hk_get_last[i], &request, &report_id, &input, &length); + } + pthread_mutex_unlock(&g_hk_io); + if (!dev) continue; + any = 1; + for (int k = 0; k < nout; k++) { + if (outs[k].kind == 1) SDL_hid_send_feature_report(dev, outs[k].data, (size_t)outs[k].len); + else SDL_hid_write(dev, outs[k].data, (size_t)outs[k].len); + } + if (want_get) { + Uint8 buf[HOOKUP_REPORT_MAX + 1] = { 0 }; + buf[0] = (Uint8)report_id; + int n = input ? SDL_hid_get_input_report(dev, buf, (size_t)length) + : SDL_hid_get_feature_report(dev, buf, (size_t)length); + pthread_mutex_lock(&g_hk_io); + if (hks_owns(&g_hks, i, g_hk[i].vid, g_hk[i].pid)) hks_get_reply(&g_hks, i, request, buf, n); + pthread_mutex_unlock(&g_hk_io); + } + did += nout + want_get; + } + pthread_mutex_unlock(&g_hk_wr); + if (!did) { + struct timespec t = { 0, any ? 500000L : 20000000L }; + nanosleep(&t, NULL); + } + } + return 0; +} + +static void hk_start_reader(void) +{ + if (!atomic_exchange(&g_hk_writer_started, 1)) { + SDL_Thread *w = SDL_CreateThread(hk_writer, "hookup-writer", NULL); + if (w) SDL_DetachThread(w); + else atomic_store(&g_hk_writer_started, 0); + } + if (atomic_exchange(&g_hk_reader_started, 1)) return; + SDL_Thread *t = SDL_CreateThread(hk_reader, "hookup-reader", NULL); + if (t) SDL_DetachThread(t); + else atomic_store(&g_hk_reader_started, 0); +} + +/* SDL thread, every loop: the heartbeat that tells DroidDeck and Steam's side the bridge serves the slot itself. */ +static void hk_serve_fast(JNIEnv *env) +{ + uint64_t now = hks_now_ms(); + for (int i = 0; i < HOOKUP_SLOTS; i++) { + if (!g_hk[i].dev) continue; + pthread_mutex_lock(&g_hk_io); + int fast = hks_owns(&g_hks, i, g_hk[i].vid, g_hk[i].pid); + if (fast) { + hks_heartbeat(&g_hks, i, now); + int ms = atomic_load(&g_poll_ms); + hks_set_rate(&g_hks, i, ms > 0 ? (uint32_t)(1000 / ms) : 0u); /* Poll rate also paces Hook-up */ + } + pthread_mutex_unlock(&g_hk_io); + if (fast != g_hk_fast_logged[i]) { + g_hk_fast_logged[i] = fast; + if (fast) hk_log(env, "hookup: slot %d served through shared memory (fast path)", i); + } + } +} + +static int hk_free_slot(void) +{ + for (int i = 0; i < HOOKUP_SLOTS; i++) if (!g_hk[i].dev) return i; + return -1; +} + +/* Read the HID report descriptor while nobody holds the interface (Android refuses an interface request on an + * interface another connection has claimed), so Steam gets the controller's real one. */ +static void hk_prefetch(JNIEnv *env, const char *path) +{ + if (!g_hk_prefetch) return; + jstring p = (*env)->NewStringUTF(env, path); + (*env)->CallStaticVoidMethod(env, g_cls, g_hk_prefetch, p); + (*env)->DeleteLocalRef(env, p); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); +} + +static bool hk_adopt(JNIEnv *env, int slot, SDL_hid_device *dev, const char *path, Uint16 vid, Uint16 pid, int iface); + +static bool hk_open(JNIEnv *env, const char *path, Uint16 vid, Uint16 pid, int iface) +{ + if (hk_path_open(path)) return true; + int slot = hk_free_slot(); + if (slot < 0) return false; + hk_prefetch(env, path); + SDL_hid_device *dev = SDL_hid_open_path(path); + if (!dev) { + hk_log(env, "hookup: could not open %s: %s", path, SDL_GetError()); + return false; + } + SDL_hid_set_nonblocking(dev, 1); + return hk_adopt(env, slot, dev, path, vid, pid, iface); +} + +/* A raw-opened controller interface becomes hook-up slot `slot` (reader thread + DroidDeck's hidraw node). */ +static bool hk_adopt(JNIEnv *env, int slot, SDL_hid_device *dev, const char *path, Uint16 vid, Uint16 pid, int iface) +{ + pthread_mutex_lock(&g_hk_io); + SDL_strlcpy(g_hk[slot].path, path, sizeof(g_hk[slot].path)); + g_hk[slot].vid = vid; + g_hk[slot].pid = pid; + g_hk[slot].iface = iface; + atomic_store(&g_hk_gone[slot], 0); + g_hk_get_last[slot] = 0; + g_hk[slot].dev = dev; + pthread_mutex_unlock(&g_hk_io); + hk_start_reader(); + hk_log(env, "hookup: slot %d is %04x:%04x interface %d (%s), handing it to DroidDeck", slot, vid, pid, iface, path); + if (g_hk_added) { + jstring p = (*env)->NewStringUTF(env, path); + (*env)->CallStaticVoidMethod(env, g_cls, g_hk_added, (jint)slot, p, (jint)vid, (jint)pid, (jint)iface); + (*env)->DeleteLocalRef(env, p); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + hk_log(env, "hookup: slot %d: describing the controller to DroidDeck failed (Java exception, see logcat)", slot); + } + } + return true; +} + +/* SDL's gamepad layer must let go of the hooked-up controller types: the ignore hint (which also keeps the + * Steam Controller 2026, passed through by triton.h, away) plus a restart of the gamepad subsystem. */ +static void hk_apply_ignore_list(JNIEnv *env) +{ + char hint[1200] = "0x28de/0x1302,0x28de/0x1304,0x28de/0x1305"; + if (atomic_load(&g_hookup_on) && g_hk_types[0]) { + SDL_strlcat(hint, ",", sizeof(hint)); + SDL_strlcat(hint, g_hk_types, sizeof(hint)); + } + SDL_SetHint(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES, hint); +} + +static bool hk_type_known(Uint16 vid, Uint16 pid) +{ + for (int i = 0; i < g_hk_type_count; i++) if (g_hk_type_vid[i] == vid && g_hk_type_pid[i] == pid) return true; + return false; +} + +static void hk_add_type(Uint16 vid, Uint16 pid, int iface) +{ + if (hk_type_known(vid, pid) || g_hk_type_count >= (int)SDL_arraysize(g_hk_type_vid)) return; + char one[24]; + snprintf(one, sizeof(one), "%s0x%04x/0x%04x", g_hk_types[0] ? "," : "", vid, pid); + SDL_strlcat(g_hk_types, one, sizeof(g_hk_types)); + g_hk_type_vid[g_hk_type_count] = vid; + g_hk_type_pid[g_hk_type_count] = pid; + g_hk_iface_of_type[g_hk_type_count] = iface; + g_hk_type_count++; +} + +static int hk_type_iface(Uint16 vid, Uint16 pid) +{ + for (int i = 0; i < g_hk_type_count; i++) if (g_hk_type_vid[i] == vid && g_hk_type_pid[i] == pid) return g_hk_iface_of_type[i]; + return -1; +} + +static void restart_gamepads(JNIEnv *env); /* main.c */ + +/* Interface number of an SDL hid path ("///" on Android). */ +static int hk_path_iface(const char *path) +{ + const char *slash = strrchr(path, '/'); + return slash ? SDL_atoi(slash + 1) : 0; +} + +/* Hand every SDL3 USB-driver gamepad (and later copies of those controller types) over to Steam. */ +static void hookup_take_gamepads(JNIEnv *env) +{ + bool handed = false; + int cnt = 0; + SDL_JoystickID *ids = SDL_GetGamepads(&cnt); + for (int i = 0; ids && i < cnt; i++) { + const char *path = SDL_GetGamepadPathForID(ids[i]); + if (!path) continue; /* Android input (Bluetooth): cannot be handed over raw */ + Uint16 vid = SDL_GetGamepadVendorForID(ids[i]), pid = SDL_GetGamepadProductForID(ids[i]); + if (hk_type_known(vid, pid)) continue; + hk_log(env, "hookup: taking %04x:%04x \"%s\" from SDL's gamepad layer", vid, pid, + SDL_GetGamepadNameForID(ids[i]) ? SDL_GetGamepadNameForID(ids[i]) : "?"); + hk_add_type(vid, pid, hk_path_iface(path)); + handed = true; + } + SDL_free(ids); + if (handed) { + hk_log(env, "hookup: restarting SDL's gamepad layer so it lets go (ignored: %s)", g_hk_types); + hk_apply_ignore_list(env); + restart_gamepads(env); /* SDL closes them; they stay ignored; the scan below opens them raw */ + g_hk_changes = 0xffffffffu; + } +} + +static void hookup_scan(JNIEnv *env) +{ + if (!g_hk_type_count) return; + SDL_hid_device_info *list = SDL_hid_enumerate(0, 0); + for (SDL_hid_device_info *d = list; d; d = d->next) { + if (!d->path || !hk_type_known(d->vendor_id, d->product_id)) continue; + int want = hk_type_iface(d->vendor_id, d->product_id); + int iface = hk_path_iface(d->path); + if (want >= 0 && iface != want) continue; + if (d->bus_type == SDL_HID_API_BUS_BLUETOOTH) continue; + if (!hk_path_open(d->path)) hk_log(env, "hookup: found %04x:%04x at %s, opening it raw", d->vendor_id, d->product_id, d->path); + hk_open(env, d->path, d->vendor_id, d->product_id, iface); + } + SDL_hid_free_enumeration(list); +} + +static void hookup_run_queue(JNIEnv *env) +{ + for (;;) { + HookupReq r; + SDL_LockSpinlock(&g_hk_lock); + bool have = g_hk_head != g_hk_tail; + if (have) { r = g_hk_queue[g_hk_head]; g_hk_head = (g_hk_head + 1) % HK_QUEUE; } + SDL_UnlockSpinlock(&g_hk_lock); + if (!have) return; + SDL_hid_device *dev = g_hk[r.slot].dev; + if (r.type == HK_GET_FEATURE || r.type == HK_GET_INPUT) { + Uint8 buf[HOOKUP_REPORT_MAX + 1] = { 0 }; + int want = r.data[1] ? r.data[1] : HOOKUP_REPORT_MAX; + buf[0] = r.data[0]; + int n = -1; + if (dev) n = r.type == HK_GET_INPUT ? SDL_hid_get_input_report(dev, buf, (size_t)want) + : SDL_hid_get_feature_report(dev, buf, (size_t)want); + jbyteArray arr = n > 0 ? hk_bytes(env, buf, n) : NULL; + if (g_hk_reply) (*env)->CallStaticVoidMethod(env, g_cls, g_hk_reply, (jint)r.slot, r.request, arr); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + if (arr) (*env)->DeleteLocalRef(env, arr); + } else if (dev && r.type == HK_FEATURE_SET) { + SDL_hid_send_feature_report(dev, r.data, (size_t)r.len); + } else if (dev && r.type == HK_OUT) { + SDL_hid_write(dev, r.data, (size_t)r.len); + } + } +} + +/* + * Steam Controller (2026): cable 28de:1302, wireless puck 28de:1304/1305 (one controller per interface 2..5). + * SDL's gamepad layer never opens it (it is always on the ignore list; outside hook-up, triton.h passes it + * through to DroidDeck's own Steam Controller node), so hook-up finds it itself. Android doesn't say which of + * its HID interfaces is the controller, so the candidates are opened and watched, and the one that carries a + * controller -- state reports, or the puck announcing a connected controller -- is handed to Steam as itself, + * exactly like on a PC (Steam drives lizard mode, haptics, trackpads, grip sense, IMU). Only interfaces with a + * controller get a hook-up slot, so a puck doesn't use up all four. + */ +#define HK_WATCH_MAX 6 +typedef struct { + SDL_hid_device *dev; + char path[256]; + Uint16 pid; + int iface; +} HkWatch; +static HkWatch g_hkw[HK_WATCH_MAX]; +static int g_hkw_full_logged; + +static bool hk_triton_puck(Uint16 pid) { return pid == 0x1304 || pid == 0x1305; } + +static bool hk_watching(const char *path) +{ + for (int w = 0; w < HK_WATCH_MAX; w++) if (g_hkw[w].dev && !strcmp(g_hkw[w].path, path)) return true; + return false; +} + +static void hk_watch_close_all(void) +{ + for (int w = 0; w < HK_WATCH_MAX; w++) { + if (g_hkw[w].dev) SDL_hid_close(g_hkw[w].dev); + memset(&g_hkw[w], 0, sizeof(g_hkw[w])); + } +} + +static void hk_valve_scan(JNIEnv *env) +{ + SDL_hid_device_info *list = SDL_hid_enumerate(VALVE_VID, 0); + for (SDL_hid_device_info *d = list; d; d = d->next) { + if (!d->path || !triton_pid(d->product_id)) continue; + if (hk_triton_puck(d->product_id) && (d->interface_number < 2 || d->interface_number > 5)) continue; + if (d->interface_class == 3 && d->interface_subclass == 1) continue; /* boot keyboard / mouse (lizard mode) */ + if (hk_path_open(d->path) || hk_watching(d->path)) continue; + int w = -1; + for (int k = 0; k < HK_WATCH_MAX; k++) if (!g_hkw[k].dev) { w = k; break; } + if (w < 0) break; + hk_prefetch(env, d->path); /* before it is opened: the descriptor can only be read while nobody holds it */ + SDL_hid_device *dev = SDL_hid_open_path(d->path); + if (!dev) { + hk_log(env, "hookup: could not open Steam Controller (2026) interface %d: %s", d->interface_number, SDL_GetError()); + continue; + } + SDL_hid_set_nonblocking(dev, 1); + g_hkw[w].dev = dev; + SDL_strlcpy(g_hkw[w].path, d->path, sizeof(g_hkw[w].path)); + g_hkw[w].pid = d->product_id; + g_hkw[w].iface = d->interface_number; + if (!hk_triton_puck(d->product_id)) tri_lizard_off(dev); /* no stray mouse input until Steam owns it */ + hk_log(env, "hookup: Steam Controller (2026) %s, interface %d: waiting for controller data", + hk_triton_puck(d->product_id) ? "puck" : "cable", d->interface_number); + } + SDL_hid_free_enumeration(list); +} + +static void hk_valve_poll(JNIEnv *env) +{ + for (int w = 0; w < HK_WATCH_MAX; w++) { + for (int reads = 0; g_hkw[w].dev && reads < 16; reads++) { + Uint8 buf[HOOKUP_REPORT_MAX]; + int n = SDL_hid_read(g_hkw[w].dev, buf, sizeof(buf)); + if (n == 0) break; + if (n < 0) { + hk_log(env, "hookup: Steam Controller (2026) interface %d gone", g_hkw[w].iface); + SDL_hid_close(g_hkw[w].dev); + memset(&g_hkw[w], 0, sizeof(g_hkw[w])); + break; + } + Uint8 id = buf[0]; + bool controller = id == 0x42 || id == 0x45 || id == 0x47 || ((id == 0x79 || id == 0x46) && n >= 2 && buf[1] == 2); + if (!controller) continue; + int slot = hk_free_slot(); + if (slot < 0) { + if (!g_hkw_full_logged) hk_log(env, "hookup: a Steam Controller (2026) is ready but all %d hook-up slots are in use", HOOKUP_SLOTS); + g_hkw_full_logged = 1; + break; + } + g_hkw_full_logged = 0; + HkWatch h = g_hkw[w]; + memset(&g_hkw[w], 0, sizeof(g_hkw[w])); /* the handle moves to the hook-up slot */ + hk_log(env, "hookup: Steam Controller (2026) on %s interface %d goes to Steam natively", + hk_triton_puck(h.pid) ? "the puck," : "the cable,", h.iface); + hk_adopt(env, slot, h.dev, h.path, VALVE_VID, h.pid, h.iface); + } + } +} + +/* Why triton.h must not pass a Steam Controller (2026) through right now (NULL: it may). Hook-up hands it to + * Steam itself while on -- and until it has actually released it after being switched off, so the two never + * hold the same interface. */ +static const char *hookup_triton_off_why(void) +{ + return atomic_load(&g_hookup_on) || g_hookup_applied == 1 ? "handed to Steam natively (Steam Native Hook-up)" : NULL; +} + +static void hookup_poll(JNIEnv *env, Uint64 now, bool driver_off) +{ + hk_java_init(env); + int on = atomic_load(&g_hookup_on) && !driver_off; + if (on != g_hookup_applied) { + int was = g_hookup_applied; + g_hookup_applied = on; + hk_log(env, "hookup: Steam Native Hook-up %s", on ? "ON" : "OFF"); + if (on) g_hk_changes = 0xffffffffu; /* look for controllers (incl. a Steam Controller 2026) right away */ + if (!on && was == 1) { + for (int i = 0; i < HOOKUP_SLOTS; i++) hk_close(env, i, "Steam Native Hook-up off"); + hk_watch_close_all(); + g_tri_changes = 0xffffffffu; /* triton.h takes a Steam Controller (2026) back at once */ + g_hk_types[0] = 0; + g_hk_type_count = 0; + hk_apply_ignore_list(env); + restart_gamepads(env); /* SDL takes the controllers back as gamepads */ + } + } + if (!on) { + hookup_run_queue(env); /* answer stale reads */ + return; + } + static Uint64 last_take; + if (now - last_take > 250) { last_take = now; hookup_take_gamepads(env); } + Uint32 changes = SDL_hid_device_change_count(); + if (changes != g_hk_changes || now - g_hk_scan_ms > 2000) { + g_hk_changes = changes; + g_hk_scan_ms = now; + hookup_scan(env); + hk_valve_scan(env); + } + hk_valve_poll(env); + /* reports are read by hk_reader(); here: controllers it saw go away, and Steam's side of the fast path */ + for (int i = 0; i < HOOKUP_SLOTS; i++) + if (g_hk[i].dev && atomic_load(&g_hk_gone[i])) hk_close(env, i, "unplugged"); + if (now - g_hk_alive_ms > 500) { + g_hk_alive_ms = now; + int dropped = 0; + pthread_mutex_lock(&g_hk_io); + if (g_hks.base && !hks_alive(&g_hks)) { hks_unmap(&g_hks); dropped = 1; } + pthread_mutex_unlock(&g_hk_io); + if (dropped) hk_log(env, "hookup: DroidDeck's session ended; fast path off until it hands over its new shared file"); + } + hk_serve_fast(env); + hookup_run_queue(env); +} diff --git a/app/src/main/cpp/sdl3bridge/src/main.c b/app/src/main/cpp/sdl3bridge/src/main.c new file mode 100644 index 000000000..773cdbd1f --- /dev/null +++ b/app/src/main/cpp/sdl3bridge/src/main.c @@ -0,0 +1,637 @@ +/* + * SDL3 controller loop. Reads the first gamepad (DualSense, DualSense Edge, Xbox, Switch Pro, ... anything SDL3 + * supports) and pushes frames to Java (NativeBridge.onFrame). Rumble/lightbar requests come back through + * NativeBridge.nativeSetOutput. + */ +#include +#include +#include +#include +#include +#include +#include + +#define FRAME_LEN 22 +#define MAX_PADS 4 + +static JavaVM *g_vm; +static jclass g_cls; +static jmethodID g_frame, g_status, g_gone, g_info, g_caps; + +/* Per-slot output state, written from Java threads, read by the SDL thread. */ +static _Atomic int g_rl[MAX_PADS], g_rr[MAX_PADS]; +static _Atomic int g_rumble_dirty[MAX_PADS]; +static _Atomic int g_led_dirty[MAX_PADS]; +static _Atomic int g_led_r[MAX_PADS], g_led_g[MAX_PADS], g_led_b[MAX_PADS]; /* per-slot lightbar colour */ +static _Atomic int g_player_idx[MAX_PADS]; /* per-slot player LEDs: -1 = off, 0..3 = player 1..4 */ +static _Atomic int g_player_dirty[MAX_PADS]; + +JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) +{ + (void)reserved; + JNIEnv *env; + if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK) return JNI_ERR; + jclass c = (*env)->FindClass(env, "com/example/dsedge/NativeBridge"); + if (!c) return JNI_ERR; + g_cls = (jclass)(*env)->NewGlobalRef(env, c); + g_frame = (*env)->GetStaticMethodID(env, g_cls, "onFrame", "([FI)V"); + g_status = (*env)->GetStaticMethodID(env, g_cls, "onStatus", "(Ljava/lang/String;)V"); + g_gone = (*env)->GetStaticMethodID(env, g_cls, "onPadGone", "(I)V"); + g_info = (*env)->GetStaticMethodID(env, g_cls, "onPadInfo", "(IIILjava/lang/String;)V"); + g_caps = (*env)->GetStaticMethodID(env, g_cls, "onPadCaps", "(IIIILjava/lang/String;)V"); + g_vm = vm; + return JNI_VERSION_1_6; +} + +/* Slot 0: rumble + lightbar colour (the user's light settings). */ +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetLed( + JNIEnv *env, jclass cls, jint slot, jint r, jint g, jint b) +{ + (void)env; (void)cls; + if (slot < 0 || slot >= MAX_PADS) return; + atomic_store(&g_led_r[slot], r); atomic_store(&g_led_g[slot], g); atomic_store(&g_led_b[slot], b); + atomic_store(&g_led_dirty[slot], 1); +} + +/* Rumble for any slot (0..3). */ +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetRumble( + JNIEnv *env, jclass cls, jint slot, jint rl, jint rr) +{ + (void)env; (void)cls; + if (slot < 0 || slot >= MAX_PADS) return; + atomic_store(&g_rl[slot], rl); atomic_store(&g_rr[slot], rr); + atomic_store(&g_rumble_dirty[slot], 1); +} + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetPlayerLed(JNIEnv *env, jclass cls, jint slot, jint index) +{ + (void)env; (void)cls; + if (slot < 0 || slot >= MAX_PADS) return; + atomic_store(&g_player_idx[slot], index); + atomic_store(&g_player_dirty[slot], 1); +} + +static atomic_int g_force = 1; /* 1 = only accept pads served by SDL's own USB (HIDAPI) driver */ +static atomic_int g_rescan = 0; + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetForce(JNIEnv *env, jclass c, jint on) +{ + (void)env; (void)c; + atomic_store(&g_force, on ? 1 : 0); +} + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeRescan(JNIEnv *env, jclass c) +{ + (void)env; (void)c; + atomic_store(&g_rescan, 1); +} + +static atomic_int g_driver_off = 0; /* 1 = SDL3 (HIDAPI) driver disabled: release the controller to Android */ +static atomic_int g_poll_ms = 2; /* loop period: 2ms = 500Hz (lower = lower latency, more CPU) */ + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetPollMs(JNIEnv *env, jclass c, jint ms) +{ + (void)env; (void)c; + if (ms < 1) ms = 1; + if (ms > 16) ms = 16; + atomic_store(&g_poll_ms, ms); +} + +/* Hard on/off for the SDL3 driver. Off disables HIDAPI and releases the controller; on re-enables it. */ +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetDriver(JNIEnv *env, jclass c, jint on) +{ + (void)env; (void)c; + atomic_store(&g_driver_off, on ? 0 : 1); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, on ? "1" : "0"); + atomic_store(&g_rescan, 1); +} + +/* A line in DroidDeck's session log ("sdl3bridge: ...") through NativeBridge.onBridgeLog. */ +static void engine_log(JNIEnv *env, const char *msg) +{ + SDL_Log("%s", msg); + jmethodID m = (*env)->GetStaticMethodID(env, g_cls, "onBridgeLog", "(Ljava/lang/String;)V"); + if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); return; } + jstring s = (*env)->NewStringUTF(env, msg); + if (m && s) (*env)->CallStaticVoidMethod(env, g_cls, m, s); + if (s) (*env)->DeleteLocalRef(env, s); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); +} + +static void send_status(JNIEnv *env, const char *msg) +{ + jstring s = (*env)->NewStringUTF(env, msg); + (*env)->CallStaticVoidMethod(env, g_cls, g_status, s); + (*env)->DeleteLocalRef(env, s); +} + +static void send_gone(JNIEnv *env, int slot) +{ + (*env)->CallStaticVoidMethod(env, g_cls, g_gone, (jint)slot); +} + +static void send_info(JNIEnv *env, int slot, int vid, int pid, const char *name) +{ + jstring s = (*env)->NewStringUTF(env, name ? name : "gamepad"); + (*env)->CallStaticVoidMethod(env, g_cls, g_info, (jint)slot, (jint)vid, (jint)pid, s); + (*env)->DeleteLocalRef(env, s); +} + +#include "triton.h" +#include "hookup.h" + +/* SDL button -> bit in the frame mask. Bit layout is shared with the first bridge and DroidDeck's client. */ +static const struct { SDL_GamepadButton button; int bit; } BUTTONS[] = { + { SDL_GAMEPAD_BUTTON_SOUTH, 0 }, /* Cross */ + { SDL_GAMEPAD_BUTTON_EAST, 1 }, /* Circle */ + { SDL_GAMEPAD_BUTTON_WEST, 2 }, /* Square */ + { SDL_GAMEPAD_BUTTON_NORTH, 3 }, /* Triangle */ + { SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, 4 }, + { SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, 5 }, + { SDL_GAMEPAD_BUTTON_BACK, 8 }, /* Create */ + { SDL_GAMEPAD_BUTTON_START, 9 }, /* Options */ + { SDL_GAMEPAD_BUTTON_LEFT_STICK, 10 }, + { SDL_GAMEPAD_BUTTON_RIGHT_STICK, 11 }, + { SDL_GAMEPAD_BUTTON_GUIDE, 12 }, /* PS */ + { SDL_GAMEPAD_BUTTON_TOUCHPAD, 13 }, + { SDL_GAMEPAD_BUTTON_MISC1, 14 }, /* Mute */ + { SDL_GAMEPAD_BUTTON_DPAD_UP, 15 }, + { SDL_GAMEPAD_BUTTON_DPAD_DOWN, 16 }, + { SDL_GAMEPAD_BUTTON_DPAD_LEFT, 17 }, + { SDL_GAMEPAD_BUTTON_DPAD_RIGHT, 18 }, + { SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, 19 }, /* Edge left back button */ + { SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, 20 }, /* Edge right back button */ + { SDL_GAMEPAD_BUTTON_LEFT_PADDLE2, 21 }, /* Edge left Fn */ + { SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2, 22 }, /* Edge right Fn */ +}; + +/* Extra buttons that don't fit the 0..22 mask safely in a float -> a second mask in f[21]. */ +static const struct { SDL_GamepadButton button; int bit; } BUTTONS2[] = { + { SDL_GAMEPAD_BUTTON_MISC2, 0 }, /* e.g. Flydigi C */ + { SDL_GAMEPAD_BUTTON_MISC3, 1 }, /* e.g. Flydigi Z */ + { SDL_GAMEPAD_BUTTON_MISC4, 2 }, + { SDL_GAMEPAD_BUTTON_MISC5, 3 }, + { SDL_GAMEPAD_BUTTON_MISC6, 4 }, +}; + +static float stick(Sint16 v) { return (float)(((int)v + 32768) >> 8); } /* 0..255, 128 = centre */ +static float trigger(Sint16 v) { return (float)((int)v * 255 / 32767); } /* 0..255 */ + +static void send_frame(JNIEnv *env, int slot, SDL_Gamepad *pad, jfloatArray arr) +{ + float f[FRAME_LEN]; + memset(f, 0, sizeof(f)); + + int mask = 0; + for (size_t i = 0; i < SDL_arraysize(BUTTONS); i++) { + if (SDL_GetGamepadButton(pad, BUTTONS[i].button)) mask |= 1 << BUTTONS[i].bit; + } + f[1] = stick(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX)); + f[2] = stick(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY)); + f[3] = stick(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHTX)); + f[4] = stick(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHTY)); + f[5] = trigger(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER)); + f[6] = trigger(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)); + if (f[5] > 30.0f) mask |= 1 << 6; + if (f[6] > 30.0f) mask |= 1 << 7; + f[0] = (float)mask; /* < 2^24, exact in a float */ + int mask2 = 0; + for (size_t i = 0; i < SDL_arraysize(BUTTONS2); i++) { + if (SDL_GetGamepadButton(pad, BUTTONS2[i].button)) mask2 |= 1 << BUTTONS2[i].bit; + } + f[21] = (float)mask2; + + float v[3]; + if (SDL_GetGamepadSensorData(pad, SDL_SENSOR_GYRO, v, 3)) { f[7] = v[0]; f[8] = v[1]; f[9] = v[2]; } + if (SDL_GetGamepadSensorData(pad, SDL_SENSOR_ACCEL, v, 3)) { f[10] = v[0]; f[11] = v[1]; f[12] = v[2]; } + + int ntp = SDL_GetNumGamepadTouchpads(pad); + if (ntp >= 2) { + /* two trackpads (e.g. Steam Controller): finger slot 0 = pad 0, slot 1 = pad 1 */ + for (int tp = 0; tp < 2; tp++) { + bool down = false; float x = 0, y = 0, p = 0; + if (SDL_GetGamepadTouchpadFinger(pad, tp, 0, &down, &x, &y, &p) && down) { + f[13 + 3 * tp] = 1.0f; + f[14 + 3 * tp] = x * 1919.0f; + f[15 + 3 * tp] = y * 1079.0f; + } + } + } else if (ntp == 1) { + for (int i = 0; i < 2; i++) { + bool down = false; + float x = 0, y = 0, p = 0; + if (SDL_GetGamepadTouchpadFinger(pad, 0, i, &down, &x, &y, &p) && down) { + f[13 + 3 * i] = 1.0f; + f[14 + 3 * i] = x * 1919.0f; /* normalized; the view divides by the same */ + f[15 + 3 * i] = y * 1079.0f; + } + } + } + + int pct = -1; + SDL_PowerState ps = SDL_GetGamepadPowerInfo(pad, &pct); + f[19] = (float)pct; + f[20] = (float)ps; + + (*env)->SetFloatArrayRegion(env, arr, 0, FRAME_LEN, f); + (*env)->CallStaticVoidMethod(env, g_cls, g_frame, arr, (jint)slot); +} + +/* SDL's own USB driver gives the gamepad a device path; Android's basic input path does not. + * Only the USB driver provides gyro, touchpad and the Edge back buttons. */ +static bool pad_is_full_driver(SDL_Gamepad *pad) { return SDL_GetGamepadPath(pad) != NULL; } + +static void enable_sensors(SDL_Gamepad *pad) +{ + static const SDL_SensorType types[] = { SDL_SENSOR_GYRO, SDL_SENSOR_ACCEL }; + for (size_t i = 0; i < SDL_arraysize(types); i++) { + if (SDL_GamepadHasSensor(pad, types[i]) && !SDL_GamepadSensorEnabled(pad, types[i])) { + SDL_SetGamepadSensorEnabled(pad, types[i], true); + } + } +} + +static void describe(SDL_Gamepad *pad, char *buf, size_t n) +{ + const char *name = SDL_GetGamepadName(pad); + SDL_JoystickConnectionState cs = SDL_GetGamepadConnectionState(pad); + snprintf(buf, n, "Connected: %s [%s] vid=%04x pid=%04x %s | gyro:%s accel:%s touchpads:%d", + name ? name : "gamepad", + pad_is_full_driver(pad) ? "SDL3 USB driver: full features" + : "Android input (e.g. Bluetooth): SDL3's own driver can't reach it, some extras may be missing", + (unsigned)SDL_GetGamepadVendor(pad), (unsigned)SDL_GetGamepadProduct(pad), + cs == SDL_JOYSTICK_CONNECTION_WIRED ? "wired" : (cs == SDL_JOYSTICK_CONNECTION_WIRELESS ? "wireless" : "link unknown"), + SDL_GamepadHasSensor(pad, SDL_SENSOR_GYRO) ? "yes" : "no", + SDL_GamepadHasSensor(pad, SDL_SENSOR_ACCEL) ? "yes" : "no", + SDL_GetNumGamepadTouchpads(pad)); + + /* Every gamepad SDL knows about, so we can tell if the USB-driver version of the controller exists. */ + int cnt = 0; + SDL_JoystickID *ids = SDL_GetGamepads(&cnt); + SDL_strlcat(buf, " | SDL sees:", n); + for (int i = 0; ids && i < cnt; i++) { + const char *nm = SDL_GetGamepadNameForID(ids[i]); + char one[120]; + snprintf(one, sizeof(one), " [%s: %s]", SDL_GetGamepadPathForID(ids[i]) ? "usb driver" : "android", nm ? nm : "?"); + SDL_strlcat(buf, one, n); + } + if (cnt == 0) SDL_strlcat(buf, " nothing", n); + SDL_free(ids); +} + +static char g_last_diag[512]; + +/* In forced mode an Android-path gamepad (no SDL USB path) is never used. */ +static bool usable_id(SDL_JoystickID id) +{ + /* A Steam Controller (2026) is never a bridge player: it is passed through raw (see triton.h). */ + if (SDL_GetGamepadVendorForID(id) == VALVE_VID && triton_pid(SDL_GetGamepadProductForID(id))) return false; + return !atomic_load(&g_force) || SDL_GetGamepadPathForID(id) != NULL; +} + +/* The same physical controller can show up twice: once through SDL's USB driver and once through Android's basic + * input. The Android copy is a duplicate and is never used as its own player. */ +static bool android_duplicate(SDL_JoystickID id) +{ + if (SDL_GetGamepadPathForID(id) != NULL) return false; + Uint16 vid = SDL_GetGamepadVendorForID(id), pid = SDL_GetGamepadProductForID(id); + int cnt = 0, full = 0, rank = 0; + bool seen = false; + SDL_JoystickID *ids = SDL_GetGamepads(&cnt); + for (int i = 0; ids && i < cnt; i++) { + if (SDL_GetGamepadVendorForID(ids[i]) != vid || SDL_GetGamepadProductForID(ids[i]) != pid) continue; + if (SDL_GetGamepadPathForID(ids[i]) != NULL) { full++; continue; } + if (ids[i] == id) seen = true; + else if (!seen) rank++; + } + SDL_free(ids); + return rank < full; +} + +static SDL_Gamepad *g_pads[MAX_PADS]; +static SDL_JoystickID g_ids[MAX_PADS]; + +static int rumble_on[MAX_PADS]; + +static int pad_count(void) +{ + int n = 0; + for (int i = 0; i < MAX_PADS; i++) if (g_pads[i]) n++; + return n; +} + +static int slot_of(SDL_JoystickID id) +{ + for (int i = 0; i < MAX_PADS; i++) if (g_pads[i] && g_ids[i] == id) return i; + return -1; +} + +static void refresh_status(JNIEnv *env) +{ + char diag[560]; + if (atomic_load(&g_driver_off)) { + snprintf(diag, sizeof(diag), "SDL3 driver OFF \u2014 controller released to Android (tap the bubble to turn it back on)"); + } else if (g_pads[0]) { + describe(g_pads[0], diag, sizeof(diag)); + char tail[80]; + snprintf(tail, sizeof(tail), " | players:%d%s", pad_count(), + triton_active() ? " | + Steam Controller (2026) raw passthrough" : ""); + SDL_strlcat(diag, tail, sizeof(diag)); + if (hookup_active()) { + snprintf(tail, sizeof(tail), " | + %d controller(s) handed to Steam natively%s", hookup_count(), + hookup_fast_count() ? " (fast path)" : ""); + SDL_strlcat(diag, tail, sizeof(diag)); + } else if (atomic_load(&g_hookup_held)) { + SDL_strlcat(diag, " | Steam Native Hook-up waits for Steam on DroidDeck's desktop", sizeof(diag)); + } + } else if (hookup_active()) { + snprintf(diag, sizeof(diag), "Connected: Steam Native Hook-up | %d controller(s) handed to Steam as the real " + "device (Steam's own driver: all buttons, touchpads, gyro, rumble, lights)%s%s", hookup_count(), + hookup_fast_count() ? " | fast path: shared memory" : "", + triton_active() ? " | + Steam Controller (2026) raw passthrough" : ""); + } else if (triton_active()) { + snprintf(diag, sizeof(diag), "Connected: %s | raw passthrough to DroidDeck (all sensors, capacitive " + "touch, grip sense, trackpad pressure and haptics handled by Steam)", tri_kind(&g_tri[g_tri_active])); + } else { + int cnt = 0, ignored = 0; + SDL_JoystickID *ids = SDL_GetGamepads(&cnt); + for (int i = 0; ids && i < cnt; i++) if (!usable_id(ids[i])) ignored++; + SDL_free(ids); + snprintf(diag, sizeof(diag), + "Forced SDL3 USB mode: waiting for the USB driver. Plug the controller in with a USB-C cable, " + "then tap 'Force SDL3 USB driver' and allow USB access (or switch Controller source to Auto to " + "also use Bluetooth / Android-only controllers). Ignored Android-only controllers: %d", ignored); + if (!atomic_load(&g_force)) + snprintf(diag, sizeof(diag), "Waiting for a controller: plug any SDL3-supported controller in via USB-C " + "(full features) or connect it over Bluetooth"); + } + if (strcmp(diag, g_last_diag) != 0) { + SDL_strlcpy(g_last_diag, diag, sizeof(g_last_diag)); + send_status(env, diag); + } +} + +/* Which buttons/axes/touchpads this controller actually has, so the UI can draw a universal layout. */ +static void send_caps(JNIEnv *env, int slot, SDL_Gamepad *pad) +{ + int present = 0; + for (size_t i = 0; i < SDL_arraysize(BUTTONS); i++) { + if (SDL_GamepadHasButton(pad, BUTTONS[i].button)) present |= 1 << BUTTONS[i].bit; + } + int axes = 0; + if (SDL_GamepadHasAxis(pad, SDL_GAMEPAD_AXIS_LEFTX)) axes |= 1; + if (SDL_GamepadHasAxis(pad, SDL_GAMEPAD_AXIS_RIGHTX)) axes |= 2; + if (SDL_GamepadHasAxis(pad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER)) axes |= 4; + if (SDL_GamepadHasAxis(pad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)) axes |= 8; + int touchpads = SDL_GetNumGamepadTouchpads(pad); + + const char *kind; + switch (SDL_GetGamepadType(pad)) { + case SDL_GAMEPAD_TYPE_PS3: + case SDL_GAMEPAD_TYPE_PS4: + case SDL_GAMEPAD_TYPE_PS5: kind = "ps"; break; + case SDL_GAMEPAD_TYPE_XBOX360: + case SDL_GAMEPAD_TYPE_XBOXONE: kind = "xbox"; break; + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: kind = "nintendo"; break; + case SDL_GAMEPAD_TYPE_STEAM: kind = "steam"; break; + default: { + const char *nm = SDL_GetGamepadName(pad); + kind = (nm && SDL_strcasestr(nm, "steam")) ? "steam" : "generic"; + } + } + jstring k = (*env)->NewStringUTF(env, kind); + (*env)->CallStaticVoidMethod(env, g_cls, g_caps, (jint)slot, (jint)present, (jint)axes, (jint)touchpads, k); + (*env)->DeleteLocalRef(env, k); +} + +static void setup_slot(JNIEnv *env, int slot) +{ + SDL_Gamepad *pad = g_pads[slot]; + enable_sensors(pad); + atomic_store(&g_led_dirty[slot], 1); + atomic_store(&g_rumble_dirty[slot], 1); + atomic_store(&g_player_dirty[slot], 1); + send_info(env, slot, SDL_GetGamepadVendor(pad), SDL_GetGamepadProduct(pad), SDL_GetGamepadName(pad)); + send_caps(env, slot, pad); + g_last_diag[0] = 0; +} + +static void open_unopened(JNIEnv *env) +{ + if (atomic_load(&g_driver_off)) return; /* driver off: don't claim anything */ + int n = 0; + SDL_JoystickID *ids = SDL_GetGamepads(&n); + for (int i = 0; ids && i < n; i++) { + if (slot_of(ids[i]) >= 0 || !usable_id(ids[i]) || android_duplicate(ids[i])) continue; + int free_slot = -1; + for (int s = 0; s < MAX_PADS; s++) if (!g_pads[s]) { free_slot = s; break; } + if (free_slot < 0) break; + SDL_Gamepad *p = SDL_OpenGamepad(ids[i]); + if (!p) continue; + g_pads[free_slot] = p; + g_ids[free_slot] = ids[i]; + setup_slot(env, free_slot); + } + SDL_free(ids); +} + +/* Closes a slot. If it was player 1, the next controller moves up so DroidDeck's primary pad is never empty. */ +static void close_slot(JNIEnv *env, int slot) +{ + if (!g_pads[slot]) return; + SDL_RumbleGamepad(g_pads[slot], 0, 0, 0); + SDL_CloseGamepad(g_pads[slot]); + g_pads[slot] = NULL; + atomic_store(&g_rl[slot], 0); + atomic_store(&g_rr[slot], 0); + send_gone(env, slot); + if (slot == 0) { + for (int k = 1; k < MAX_PADS; k++) { + if (!g_pads[k]) continue; + g_pads[0] = g_pads[k]; + g_ids[0] = g_ids[k]; + g_pads[k] = NULL; + send_gone(env, k); + setup_slot(env, 0); + break; + } + } + g_last_diag[0] = 0; +} + +/* Drops controllers that are no longer usable (forced mode switched on, or an Android duplicate of a USB-driver pad). */ +static void drop_unusable(JNIEnv *env) +{ + for (int s = 0; s < MAX_PADS; s++) { + if (!g_pads[s]) continue; + if (!usable_id(g_ids[s]) || android_duplicate(g_ids[s])) { rumble_on[s] = 0; close_slot(env, s); } + } +} + +/* Close every bridge player and restart SDL's gamepad layer (re-enumerates; ignore hints take effect). + * Raw handles (Steam Controller passthrough, Steam Native Hook-up) survive: main() holds an SDL_hid_init + * reference so the HID layer itself stays up. */ +static void restart_gamepads(JNIEnv *env) +{ + for (int s = 0; s < MAX_PADS; s++) { + if (g_pads[s]) { SDL_CloseGamepad(g_pads[s]); g_pads[s] = NULL; send_gone(env, s); } + rumble_on[s] = 0; + } + SDL_QuitSubSystem(SDL_INIT_GAMEPAD); + SDL_InitSubSystem(SDL_INIT_GAMEPAD); + g_last_diag[0] = 0; +} + +/* SDL only reports a joystick as a gamepad when it has a mapping. SDL ships mappings for thousands of + * controllers and Android generates one for every Android gamepad, but a rare generic one (arcade stick, + * cheap USB pad) may have none -- give it a sensible default layout so it still works as a player. */ +static void map_unmapped_joystick(SDL_JoystickID id) +{ + if (SDL_IsGamepad(id)) return; + SDL_Joystick *joy = SDL_OpenJoystick(id); + if (!joy) return; + int nb = SDL_GetNumJoystickButtons(joy), na = SDL_GetNumJoystickAxes(joy), nh = SDL_GetNumJoystickHats(joy); + char guid[64], map[1024]; + SDL_GUIDToString(SDL_GetJoystickGUID(joy), guid, sizeof(guid)); + const char *name = SDL_GetJoystickName(joy); + char clean[128]; + SDL_strlcpy(clean, name ? name : "Generic controller", sizeof(clean)); + for (char *c = clean; *c; c++) if (*c == ',') *c = ' '; + SDL_CloseJoystick(joy); + if (nb == 0 && na == 0) return; + snprintf(map, sizeof(map), "%s,%s,", guid, clean); + static const char *btn[] = { "a", "b", "x", "y", "leftshoulder", "rightshoulder", "back", "start", + "leftstick", "rightstick", "guide", "lefttrigger", "righttrigger" }; + char one[48]; + int first_trigger_button = na >= 6 ? 11 : 13; /* analog triggers when there are 6+ axes */ + for (int b = 0; b < nb && b < (int)SDL_arraysize(btn); b++) { + if (b >= first_trigger_button) break; + snprintf(one, sizeof(one), "%s:b%d,", btn[b], b); + SDL_strlcat(map, one, sizeof(map)); + } + if (na < 6) { + for (int b = 11; b < nb && b < 13; b++) { + snprintf(one, sizeof(one), "%s:b%d,", btn[b], b); + SDL_strlcat(map, one, sizeof(map)); + } + } + static const char *ax[] = { "leftx", "lefty", "rightx", "righty", "lefttrigger", "righttrigger" }; + for (int a = 0; a < na && a < 6; a++) { + snprintf(one, sizeof(one), "%s:a%d,", ax[a], a); + SDL_strlcat(map, one, sizeof(map)); + } + if (nh > 0) SDL_strlcat(map, "dpup:h0.1,dpright:h0.2,dpdown:h0.4,dpleft:h0.8,", sizeof(map)); + if (SDL_AddGamepadMapping(map) >= 0) SDL_Log("bridge: default mapping for unmapped \"%s\": %s", clean, map); +} + +int main(int argc, char *argv[]) +{ + /* argv[1]: SDL3's gamepad mappings file (absolute path, made by the app). Without it SDL3 looks the file up + * through the app screen's assets -- headless there is no screen, and that lookup crashes the app. */ + if (argc > 1 && argv[1] && argv[1][0] == '/') SDL_SetHint(SDL_HINT_GAMECONTROLLERCONFIG_FILE, argv[1]); + SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_ENHANCED_REPORTS, "1"); + SDL_SetHint(SDL_HINT_ANDROID_BLOCK_ON_PAUSE, "0"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_PLAYER_LED, "1"); + /* Every SDL3 USB (HIDAPI) driver on, including Valve's (Steam Controller 2015 cable/dongle; it is off by + * default in SDL). Only the Steam Controller (2026) is kept away from SDL's gamepad layer: it is passed + * through raw instead (triton.h). That hint hides it from gamepads but not from SDL_hid_enumerate. */ + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, "1"); + SDL_SetHint(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES, "0x28de/0x1302,0x28de/0x1304,0x28de/0x1305"); + + JNIEnv *env = (JNIEnv *)SDL_GetAndroidJNIEnv(); + if (!env || !g_cls) { + SDL_Log("SDL3Bridge: no JNI environment (%p) or bridge class (%p); SDL3's loop cannot run", (void *)env, (void *)g_cls); + return 1; + } + engine_log(env, "engine: SDL3's loop started; initialising SDL3's gamepad support"); + + if (!SDL_Init(SDL_INIT_GAMEPAD)) { + char msg[200]; + snprintf(msg, sizeof(msg), "SDL init failed: %s", SDL_GetError()); + send_status(env, msg); + return 1; + } + engine_log(env, "engine: SDL3's gamepad support is up"); + SDL_hid_init(); /* keeps the HID layer alive across gamepad-layer restarts (raw passthrough handles) */ + send_status(env, "Waiting for a controller (plug it in via USB-C)"); + + jfloatArray arr = (*env)->NewFloatArray(env, FRAME_LEN); + Uint64 last_scan = 0, last_tick = 0, last_rumble[MAX_PADS] = {0}; + + for (;;) { + SDL_Event e; + while (SDL_PollEvent(&e)) { + if (e.type == SDL_EVENT_JOYSTICK_ADDED) { + map_unmapped_joystick(e.jdevice.which); + } else if (e.type == SDL_EVENT_GAMEPAD_ADDED) { + drop_unusable(env); + open_unopened(env); + } else if (e.type == SDL_EVENT_GAMEPAD_REMOVED) { + int s = slot_of(e.gdevice.which); + if (s >= 0) { + rumble_on[s] = 0; + close_slot(env, s); + if (pad_count() == 0) send_status(env, "Controller disconnected"); + } + } + } + + Uint64 now = SDL_GetTicks(); + + if (atomic_exchange(&g_rescan, 0)) { /* restart SDL's gamepad/HIDAPI enumeration */ + tri_close_all(env, "rescan"); + restart_gamepads(env); + send_status(env, "Rescanning controllers..."); + } + + if (now - last_scan > 1000) { /* fallback in case an ADDED event was missed, and housekeeping */ + last_scan = now; + drop_unusable(env); + open_unopened(env); + } + + if (now - last_tick > 1000) { + last_tick = now; + for (int s = 0; s < MAX_PADS; s++) if (g_pads[s]) enable_sensors(g_pads[s]); + refresh_status(env); + } + + triton_poll(env, now, atomic_load(&g_driver_off) ? "SDL3 driver off" : hookup_triton_off_why()); + hookup_poll(env, now, atomic_load(&g_driver_off) != 0); + + if (pad_count() > 0) SDL_UpdateGamepads(); + + for (int s = 0; s < MAX_PADS; s++) { + SDL_Gamepad *pad = g_pads[s]; + if (!pad) continue; + + if (atomic_exchange(&g_led_dirty[s], 0)) + SDL_SetGamepadLED(pad, (Uint8)atomic_load(&g_led_r[s]), (Uint8)atomic_load(&g_led_g[s]), (Uint8)atomic_load(&g_led_b[s])); + if (atomic_exchange(&g_player_dirty[s], 0)) SDL_SetGamepadPlayerIndex(pad, atomic_load(&g_player_idx[s])); + + int rl = atomic_load(&g_rl[s]), rr = atomic_load(&g_rr[s]); + int rdirty = atomic_exchange(&g_rumble_dirty[s], 0); + if (rl || rr) { + if (rdirty || now - last_rumble[s] > 400) { /* SDL rumble has a duration, so refresh it */ + SDL_RumbleGamepad(pad, (Uint16)(rl * 257), (Uint16)(rr * 257), 1000); + last_rumble[s] = now; + } + rumble_on[s] = 1; + } else if (rumble_on[s]) { + SDL_RumbleGamepad(pad, 0, 0, 0); + rumble_on[s] = 0; + } + + send_frame(env, s, pad, arr); + } + SDL_Delay((Uint32)atomic_load(&g_poll_ms)); + } + return 0; +} diff --git a/app/src/main/cpp/sdl3bridge/src/triton.h b/app/src/main/cpp/sdl3bridge/src/triton.h new file mode 100644 index 000000000..d2375ad0e --- /dev/null +++ b/app/src/main/cpp/sdl3bridge/src/triton.h @@ -0,0 +1,296 @@ +/* + * Steam Controller (2026, "Triton") raw passthrough over USB: the wired controller (28de:1302) or its + * wireless "puck" dongle (28de:1304 / 28de:1305, one controller per HID interface 2..5). + * + * Nothing is decoded here. Every state report (buttons, sticks, trackpads with pressure, capacitive stick + * touch, grip sense, IMU) goes to DroidDeck byte for byte, and everything Steam sends back (haptics, + * settings, feature reads) goes to the controller unchanged, so Steam drives it exactly like a real one -- + * just over a cable/2.4 GHz link instead of Bluetooth LE. + * + * Included once, from main.c (uses its JNI globals). All SDL_hid calls run on the SDL thread. + */ +#pragma once + +#define VALVE_VID 0x28de +#define TRITON_MAX_IFACES 4 +#define TRITON_REPORT_BYTES 64 +#define TRITON_STATE_PAYLOAD 45 /* bytes after the report id, as the Bluetooth descriptor declares */ +#define TRITON_LIZARD_REFRESH_MS 3000 +#define TRITON_RUMBLE_RESEND_MS 40 + +static bool triton_pid(Uint16 pid) { return pid == 0x1302 || pid == 0x1304 || pid == 0x1305; } +static bool triton_dongle(Uint16 pid) { return pid == 0x1304 || pid == 0x1305; } + +typedef struct { + SDL_hid_device *dev; + char path[256]; + int iface; + Uint16 pid; +} TritonIface; + +static TritonIface g_tri[TRITON_MAX_IFACES]; +static int g_tri_active = -1; /* index in g_tri of the controller being passed through */ +static Uint32 g_tri_changes = 0xffffffffu; +static Uint64 g_tri_scan_ms, g_tri_lizard_ms, g_tri_rumble_ms; +static jmethodID g_tri_state, g_tri_conn, g_tri_feature; + +/* ---- requests from Java (any thread) -> SDL thread ---- */ +enum { TRI_REQ_OUTPUT = 1, TRI_REQ_FEATURE_SET = 2, TRI_REQ_FEATURE_GET = 3 }; +typedef struct { + int type; + int len; + jlong request; + Uint8 data[TRITON_REPORT_BYTES + 1]; +} TritonReq; +#define TRI_QUEUE 64 +static TritonReq g_tri_queue[TRI_QUEUE]; +static int g_tri_head, g_tri_tail; +static SDL_SpinLock g_tri_lock; /* no init needed: Java may queue before the SDL thread starts */ +static _Atomic int g_tri_rl, g_tri_rr, g_tri_rumble_dirty; + +static void tri_lock(void) { SDL_LockSpinlock(&g_tri_lock); } +static void tri_unlock(void) { SDL_UnlockSpinlock(&g_tri_lock); } + +static void tri_push(int type, const Uint8 *data, int len, jlong request) +{ + if (len < 0) len = 0; + if (len > TRITON_REPORT_BYTES + 1) len = TRITON_REPORT_BYTES + 1; + tri_lock(); + int next = (g_tri_tail + 1) % TRI_QUEUE; + if (next != g_tri_head) { /* full: drop (Steam re-sends settings and haptics constantly) */ + TritonReq *r = &g_tri_queue[g_tri_tail]; + r->type = type; + r->len = len; + r->request = request; + if (len) memcpy(r->data, data, (size_t)len); + g_tri_tail = next; + } + tri_unlock(); +} + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeTritonOutput(JNIEnv *env, jclass c, jbyteArray data, jboolean feature) +{ + (void)c; + if (!data) return; + jsize n = (*env)->GetArrayLength(env, data); + if (n < 1) return; + Uint8 buf[TRITON_REPORT_BYTES + 1]; + if (n > (jsize)sizeof(buf)) n = (jsize)sizeof(buf); + (*env)->GetByteArrayRegion(env, data, 0, n, (jbyte *)buf); + tri_push(feature ? TRI_REQ_FEATURE_SET : TRI_REQ_OUTPUT, buf, (int)n, 0); +} + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeTritonGetFeature(JNIEnv *env, jclass c, jlong request, jint reportId) +{ + (void)env; (void)c; + Uint8 id = (Uint8)reportId; + tri_push(TRI_REQ_FEATURE_GET, &id, 1, request); +} + +JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeTritonRumble(JNIEnv *env, jclass c, jint left, jint right) +{ + (void)env; (void)c; + atomic_store(&g_tri_rl, left < 0 ? 0 : left > 65535 ? 65535 : left); + atomic_store(&g_tri_rr, right < 0 ? 0 : right > 65535 ? 65535 : right); + atomic_store(&g_tri_rumble_dirty, 1); +} + +/* ---- SDL thread -> Java ---- */ +static void tri_java_init(JNIEnv *env) +{ + if (g_tri_state) return; + g_tri_state = (*env)->GetStaticMethodID(env, g_cls, "onTritonState", "(I[B)V"); + g_tri_conn = (*env)->GetStaticMethodID(env, g_cls, "onTritonConnected", "(ZLjava/lang/String;)V"); + g_tri_feature = (*env)->GetStaticMethodID(env, g_cls, "onTritonFeature", "(J[B)V"); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); +} + +static void tri_send_conn(JNIEnv *env, bool on, const char *what) +{ + if (!g_tri_conn) return; + jstring s = (*env)->NewStringUTF(env, what ? what : ""); + (*env)->CallStaticVoidMethod(env, g_cls, g_tri_conn, (jboolean)on, s); + (*env)->DeleteLocalRef(env, s); +} + +static void tri_send_bytes(JNIEnv *env, jmethodID m, jlong request, int id, const Uint8 *data, int len, bool with_request) +{ + if (!m) return; + jbyteArray arr = NULL; + if (data && len > 0) { + arr = (*env)->NewByteArray(env, len); + if (!arr) return; + (*env)->SetByteArrayRegion(env, arr, 0, len, (const jbyte *)data); + } + if (with_request) (*env)->CallStaticVoidMethod(env, g_cls, m, request, arr); + else (*env)->CallStaticVoidMethod(env, g_cls, m, (jint)id, arr); + if (arr) (*env)->DeleteLocalRef(env, arr); +} + +static const char *tri_kind(const TritonIface *t) +{ + return triton_dongle(t->pid) ? "Steam Controller (2026) via wireless puck" : "Steam Controller (2026) via USB cable"; +} + +static void tri_deactivate(JNIEnv *env, const char *why) +{ + if (g_tri_active < 0) return; + SDL_Log("triton: passthrough stopped (%s)", why); + g_tri_active = -1; + atomic_store(&g_tri_rl, 0); + atomic_store(&g_tri_rr, 0); + tri_send_conn(env, false, why); +} + +static void tri_close(JNIEnv *env, int i, const char *why) +{ + if (!g_tri[i].dev) return; + if (g_tri_active == i) tri_deactivate(env, why); + SDL_hid_close(g_tri[i].dev); + memset(&g_tri[i], 0, sizeof(g_tri[i])); +} + +static void tri_close_all(JNIEnv *env, const char *why) +{ + for (int i = 0; i < TRITON_MAX_IFACES; i++) tri_close(env, i, why); +} + +static bool tri_is_open(const char *path) +{ + for (int i = 0; i < TRITON_MAX_IFACES; i++) if (g_tri[i].dev && !strcmp(g_tri[i].path, path)) return true; + return false; +} + +/* Lizard mode (the controller acting as a mouse + keyboard) off: SET_SETTINGS_VALUES, SETTING_LIZARD_MODE = 0. + * Steam sends this itself once it owns the controller; doing it here as well keeps the phone free of stray + * mouse/keyboard input before Steam has started. */ +static void tri_lizard_off(SDL_hid_device *dev) +{ + Uint8 buf[TRITON_REPORT_BYTES] = { 0x01, 0x87, 0x03, 0x09, 0x00, 0x00 }; + SDL_hid_send_feature_report(dev, buf, sizeof(buf)); +} + +static void tri_scan(JNIEnv *env) +{ + SDL_hid_device_info *list = SDL_hid_enumerate(VALVE_VID, 0); + for (SDL_hid_device_info *d = list; d; d = d->next) { + if (!triton_pid(d->product_id) || !d->path) continue; + /* The puck carries one controller per interface 2..5; the cable has a single controller interface. */ + if (triton_dongle(d->product_id) && (d->interface_number < 2 || d->interface_number > 5)) continue; + if (!triton_dongle(d->product_id) && d->usage_page != 0 && d->usage_page < 0xff00) continue; + if (d->interface_class == 3 && d->interface_subclass == 1) continue; /* boot keyboard / mouse (lizard mode) */ + if (tri_is_open(d->path)) continue; + int slot = -1; + for (int i = 0; i < TRITON_MAX_IFACES; i++) if (!g_tri[i].dev) { slot = i; break; } + if (slot < 0) break; + SDL_hid_device *dev = SDL_hid_open_path(d->path); + if (!dev) { + SDL_Log("triton: could not open %04x:%04x interface %d: %s", d->vendor_id, d->product_id, d->interface_number, SDL_GetError()); + continue; + } + SDL_hid_set_nonblocking(dev, 1); + g_tri[slot].dev = dev; + SDL_strlcpy(g_tri[slot].path, d->path, sizeof(g_tri[slot].path)); + g_tri[slot].iface = d->interface_number; + g_tri[slot].pid = d->product_id; + SDL_Log("triton: opened %04x:%04x interface %d", d->vendor_id, d->product_id, d->interface_number); + if (!triton_dongle(d->product_id)) tri_lizard_off(dev); + } + SDL_hid_free_enumeration(list); +} + +static void tri_run_queue(JNIEnv *env) +{ + for (;;) { + TritonReq r; + tri_lock(); + bool have = g_tri_head != g_tri_tail; + if (have) { r = g_tri_queue[g_tri_head]; g_tri_head = (g_tri_head + 1) % TRI_QUEUE; } + tri_unlock(); + if (!have) return; + SDL_hid_device *dev = g_tri_active >= 0 ? g_tri[g_tri_active].dev : NULL; + if (r.type == TRI_REQ_FEATURE_GET) { + Uint8 buf[TRITON_REPORT_BYTES + 1] = { 0 }; + buf[0] = r.data[0]; + int n = dev ? SDL_hid_get_feature_report(dev, buf, TRITON_REPORT_BYTES) : -1; + tri_send_bytes(env, g_tri_feature, r.request, 0, n > 0 ? buf : NULL, n > 0 ? n : 0, true); + } else if (dev && r.type == TRI_REQ_FEATURE_SET) { + Uint8 buf[TRITON_REPORT_BYTES] = { 0 }; + int n = r.len > TRITON_REPORT_BYTES ? TRITON_REPORT_BYTES : r.len; + memcpy(buf, r.data, (size_t)n); + SDL_hid_send_feature_report(dev, buf, TRITON_REPORT_BYTES); + } else if (dev && r.type == TRI_REQ_OUTPUT) { + SDL_hid_write(dev, r.data, (size_t)r.len); + } + } +} + +/* Game rumble for the passed-through controller (DroidDeck's virtual-pad rumble): haptic rumble report 0x80, + * re-sent every 40 ms while on, like SDL's own driver does. */ +static void tri_rumble(Uint64 now) +{ + if (g_tri_active < 0) return; + int dirty = atomic_exchange(&g_tri_rumble_dirty, 0); + int l = atomic_load(&g_tri_rl), r = atomic_load(&g_tri_rr); + if (!dirty && !(l || r)) return; + if (!dirty && now - g_tri_rumble_ms < TRITON_RUMBLE_RESEND_MS) return; + Uint8 msg[10] = { 0x80, 0, 0, 0, (Uint8)l, (Uint8)(l >> 8), 0, (Uint8)r, (Uint8)(r >> 8), 0 }; + SDL_hid_write(g_tri[g_tri_active].dev, msg, sizeof(msg)); + g_tri_rumble_ms = now; +} + +/* off_why: NULL = pass the controller through; otherwise why not (SDL3 driver off, or Steam Native Hook-up, + * which hands the Steam Controller (2026) to Steam as itself instead -- see hookup.h). */ +static void triton_poll(JNIEnv *env, Uint64 now, const char *off_why) +{ + tri_java_init(env); + if (off_why) { + tri_close_all(env, off_why); + return; + } + Uint32 changes = SDL_hid_device_change_count(); + if (changes != g_tri_changes || now - g_tri_scan_ms > 3000) { + g_tri_changes = changes; + g_tri_scan_ms = now; + tri_scan(env); + } + for (int i = 0; i < TRITON_MAX_IFACES; i++) { + TritonIface *t = &g_tri[i]; + for (int reads = 0; t->dev && reads < 32; reads++) { + Uint8 buf[TRITON_REPORT_BYTES]; + int n = SDL_hid_read(t->dev, buf, sizeof(buf)); + if (n == 0) break; + if (n < 0) { tri_close(env, i, "controller unplugged"); break; } + Uint8 id = buf[0]; + if (id == 0x42 || id == 0x45 || id == 0x47) { + if (g_tri_active < 0) { + g_tri_active = i; + SDL_Log("triton: passing through interface %d", t->iface); + tri_send_conn(env, true, tri_kind(t)); + tri_lizard_off(t->dev); + g_tri_lizard_ms = now; + } + if (g_tri_active != i) continue; + /* The wired state report (0x42) has the same layout as the Bluetooth one (0x45) that + * DroidDeck's Steam Controller node declares, so it is relabelled; 0x47 keeps its id. */ + int len = n - 1 < TRITON_STATE_PAYLOAD ? n - 1 : TRITON_STATE_PAYLOAD; + tri_send_bytes(env, g_tri_state, 0, id == 0x42 ? 0x45 : id, buf + 1, len, false); + } else if ((id == 0x79 || id == 0x46) && n >= 2) { + /* Puck wireless status: 1 = controller went away, 2 = connected. */ + if (buf[1] == 1 && g_tri_active == i) tri_deactivate(env, "controller turned off"); + } + } + } + if (g_tri_active >= 0) { + tri_run_queue(env); + tri_rumble(now); + if (now - g_tri_lizard_ms > TRITON_LIZARD_REFRESH_MS) { + g_tri_lizard_ms = now; + tri_lizard_off(g_tri[g_tri_active].dev); + } + } else { + tri_run_queue(env); /* answers pending feature reads with "no data" */ + } +} + +static bool triton_active(void) { return g_tri_active >= 0; } diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 928d7caa8..5023e703d 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -34,6 +34,7 @@ class App : Application() { com.droiddeck.launcher.stores.StoresState.init(this) return } + if (process == "$packageName:sdl3") return // sdl3droiddeck: the SDL3 bridge's process if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return CrashHandler.install(this) com.droiddeck.launcher.session.QuestExperimental.initialize(this) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 29953e6c3..c6a1dbc13 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -510,6 +510,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { bridge.setBuiltinPad(SessionState.mode != SessionService.MODE_STEAM || SessionPrefs.builtinPad(this)) padBridge = bridge SessionState.padBridge = bridge + com.droiddeck.launcher.input.DualSenseBridge.attach(this, bridge) if (Build.VERSION.SDK_INT >= 31) { val vr = com.droiddeck.launcher.input.JoyConVr(this, File(filesDir, ".wayland-rt/${com.droiddeck.launcher.input.JoyConVr.FILE}")) { taken, released -> if (joyConVr == null) return@JoyConVr @@ -2041,6 +2042,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return super.dispatchKeyEvent(event) } if (pipUi) return true + if (com.droiddeck.launcher.input.DualSenseBridge.isBridged(event)) return true val pad = com.droiddeck.launcher.input.JoyCon.normalize(event) if (pad !== event) return dispatchKeyEvent(pad) val fromController = PadBridge.isControllerKeyEvent(event) @@ -3525,6 +3527,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { handCamera?.stop() handCamera = null padBridge?.stop() + com.droiddeck.launcher.input.DualSenseBridge.screenClosed(this) steamController?.stop() if (SessionState.padBridge === padBridge) SessionState.padBridge = null padMotion?.stop() diff --git a/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt b/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt new file mode 100644 index 000000000..0e77c1984 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt @@ -0,0 +1,1058 @@ +package com.droiddeck.launcher.input + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import android.os.SystemClock +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +/** + * Receives controller input (DualSense / Edge with full features, any other SDL3-supported pad) from the companion SDL3 bridge app + * and feeds it into DroidDeck's pad pipeline. + * + * sticks / triggers / D-pad / face + shoulder buttons -> PadBridge.applyExternal + * Edge paddles (L/R) -> Deck grips L4 / R4; Edge Fn (L/R) -> L5 / R5 + * touchpad -> decoupled by the bridge: left half = left Deck trackpad, right half = right trackpad (+ click) + * gyro / accel -> FakeInputWriter.writeMotion (slot 0) + * + * Usage: DualSenseBridge.attach(context, padBridge) when the session starts, + * DualSenseBridge.detach() when it stops. + */ +object DualSenseBridge { + private const val BRIDGE_PKG = "com.example.dsedge" + private const val BRIDGE_ACTION = "com.example.dsedge.BRIDGE" + private const val MSG_REGISTER = 1 + private const val MSG_UNREGISTER = 2 + private const val MSG_OUTPUT = 3 + private const val MSG_FRAME = 10 + private const val MSG_STATUS = 11 + private const val MSG_PAD_GONE = 12 + // Steam Controller (2026) raw passthrough over USB (cable or wireless puck), see the bridge's triton.h + private const val MSG_TRITON_OUTPUT = 4 + private const val MSG_TRITON_GET = 5 + private const val MSG_TRITON_RUMBLE = 6 + private const val MSG_TRITON_STATE = 13 + private const val MSG_TRITON_CONN = 14 + private const val MSG_TRITON_FEATURE = 15 + private const val TRITON_SLOT = 0 // the ring slot DroidDeck's Steam Controller node reads + private const val TRITON_STATE_BYTES = 45 + private const val TRITON_POLL_MS = 2L + // Steam Native Hook-up (see HookupBridge.kt and the bridge's hookup.h) + private const val MSG_HOOKUP_OUT = 7 + private const val MSG_HOOKUP_GET = 8 + private const val MSG_HOOKUP_ADD = 20 + private const val MSG_HOOKUP_INPUT = 21 + private const val MSG_HOOKUP_REMOVE = 22 + private const val MSG_HOOKUP_REPLY = 23 + private const val HOOKUP_POLL_MS = 1L + private const val MSG_BRIDGE_LOG = 30 + // Background watchdog (like Steam on a PC: controllers are picked up whenever they appear) + private const val MSG_HOOKUP_SYNC = 9 // -> bridge: "send me everything you hand over right now" + private const val MSG_HOOKUP_SHM = 25 // -> bridge: "fd" = the shared hook-up file (fast path) + private const val MSG_HOOKUP_STATE = 24 // <- bridge: "slots": the hook-up slots that are live + private const val WATCHDOG_MS = 1500L + private const val SLOTS = 4 + + // ---- Tunables (verify on device) ---- + private const val DEAD_ZONE = 0.12f // same as PadBridge + private const val MIN_AXIS_INTERVAL_MS = 4L // don't flood the input ring with stick jitter + private const val GYRO_LSB_PER_DPS = 16.384f // Steam Deck IMU: 2000 dps full scale (assumed) + private const val ACCEL_LSB_PER_G = 16384f // Steam Deck IMU: 2 g full scale (assumed) + private val GYRO_SIGN = floatArrayOf(1f, 1f, 1f) // flip an entry to -1f if an axis feels inverted + private val ACCEL_SIGN = floatArrayOf(1f, 1f, 1f) + private const val GYRO_GAIN = 1f // multiplies gyro only; raise if motion is too slow + // Touchpad orientation on the DroidDeck side (the bridge app has the same settings; use one or the other, not both). + private const val TOUCH_INV_X = false + private const val TOUCH_INV_Y = false + private const val TOUCH_SWAP = false + private const val MAP_TOUCHPAD = true // left half = left trackpad, right half = right trackpad + private const val TOUCH_HALF = 960 // DualSense touchpad is 1920 x 1080 + private const val TOUCH_H = 1079f + + // bridge mask bit -> PadState button + private val BUTTONS = mapOf( + 0 to PadState.A, 1 to PadState.B, 2 to PadState.X, 3 to PadState.Y, + 4 to PadState.LB, 5 to PadState.RB, + 8 to PadState.SELECT, 9 to PadState.START, + 10 to PadState.L3, 11 to PadState.R3, + 12 to PadState.GUIDE, 14 to PadState.QAM, + ) + // bridge mask bit -> Deck grip bit + private val GRIPS = mapOf( + 19 to DeckControls.L4, 20 to DeckControls.R4, + 21 to DeckControls.L5, 22 to DeckControls.R5, + ) + + private var appContext: Context? = null + @Volatile private var owner: Any? = null + private var pad: PadBridge? = null + private var server: Messenger? = null + private var bound = false + + private var lastMask = 0 + private val lastAxes = FloatArray(6) + private var lastApplyMs = 0L + // Per bridge slot (0 = player 1 with all features, 1..3 = extra controllers: buttons, sticks, triggers, gyro, rumble). + private val vidS = IntArray(SLOTS) + private val pidS = IntArray(SLOTS) + private val frameMsS = LongArray(SLOTS) + private val ddSlot = intArrayOf(0, -1, -1, -1) // bridge slot -> DroidDeck player slot (-1 = not claimed) + private val maskS = IntArray(SLOTS) + private val axesS = Array(SLOTS) { FloatArray(6) } + private val applyMsS = LongArray(SLOTS) + private val lastTouchKey = intArrayOf(-1, -1) // [left, right] + private var clickSide = -1 // -1 none, 0 left, 1 right + + // DeckControls bit layout (those constants are private over there) + private const val DC_LEFT_TOUCH = 16 + private const val DC_RIGHT_TOUCH = 32 + private const val DC_LEFT_CLICK = 64 + private const val DC_RIGHT_CLICK = 128 + + // Separate-controller mode: the DualSense becomes its OWN DroidDeck controller slot (its own entry in Steam), + // with full back buttons, both trackpads and gyro, instead of driving the Steam Deck pad. + private var sepSlot = -1 + private var sepMs = 0L + private var sepVid = 0 + private var sepPid = 0 + private var sepMask = 0 + private val sepAxes = FloatArray(6) + private var sepApplyMs = 0L + private var lastPadType = 0 + private var lastSepControls = -1 + private val lastSepPads = ShortArray(4) + private val lastSepPressure = ShortArray(2) + + private val worker = android.os.HandlerThread("ds-bridge", android.os.Process.THREAD_PRIORITY_URGENT_DISPLAY).apply { start() } + private val workerHandler = Handler(worker.looper) + private val inbox = Messenger(object : Handler(worker.looper) { + /* + * Called on the Binder thread that received the bridge's message, before it is queued. Input must never + * wait in a queue: with a game running the worker thread can get less CPU than 500-1000 messages a second + * need, and an ever-growing queue of old input is lag that builds up over time. So: + * - controller reports for Steam (hook-up) and Steam Controller states go straight into shared memory; + * - frames are "latest wins" per controller (see offerFrame), so only fresh state waits for the worker. + */ + override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean { + if (msg.callback == null) when (msg.what) { + MSG_FRAME -> { offerFrame(msg.data); return true } + MSG_HOOKUP_INPUT -> { + msg.data.getByteArray("data")?.let { HookupBridge.input(msg.data.getInt("slot"), it) } + return true + } + MSG_TRITON_STATE -> { + val d = msg.data.getByteArray("data") + if (d != null && tritonOn) FakeInputWriter.writeTritonState(TRITON_SLOT, msg.data.getInt("id"), d) + return true + } + } + return super.sendMessageAtTime(msg, uptimeMillis) + } + + override fun handleMessage(m: Message) { + when (m.what) { + MSG_FRAME -> onFrame(m.data) + MSG_STATUS -> if (m.data.getString("text")?.startsWith("Connected") != true) release() + MSG_PAD_GONE -> padGone(m.data.getInt("slot")) + MSG_TRITON_STATE -> m.data.getByteArray("data")?.let { + if (tritonOn) FakeInputWriter.writeTritonState(TRITON_SLOT, m.data.getInt("id"), it) + } + MSG_TRITON_CONN -> setTriton(m.data.getBoolean("on"), m.data.getString("what") ?: "") + MSG_HOOKUP_ADD -> { HookupBridge.add(m.data); startHookupPoll(); updateHookupPad() } + MSG_HOOKUP_INPUT -> m.data.getByteArray("data")?.let { HookupBridge.input(m.data.getInt("slot"), it) } + MSG_HOOKUP_STATE -> { + val live = m.data.getIntArray("slots") ?: IntArray(0) + // the bridge maps a different (or no) shared file: hand it the current one (at most every 10 s) + if (m.data.getLong("fast_ino", 0L) != HookupBridge.shmInode() && + android.os.SystemClock.uptimeMillis() - shmSentMs > 10_000) sendShm() + for (s in 0 until HookupBridge.SLOTS) if (HookupBridge.isPresent(s) && s !in live) { + HookupBridge.note("hookup: the bridge no longer hands over slot $s; removing it") + HookupBridge.remove(s) + } + if (live.isNotEmpty()) startHookupPoll() + updateHookupPad() + } + MSG_BRIDGE_LOG -> m.data.getString("text")?.split('\n')?.forEach { HookupBridge.note("sdl3bridge: $it") } + MSG_HOOKUP_REMOVE -> { HookupBridge.remove(m.data.getInt("slot")); updateHookupPad() } + MSG_HOOKUP_REPLY -> HookupBridge.reply(m.data.getInt("slot"), m.data.getLong("request"), m.data.getByteArray("data")) + MSG_TRITON_FEATURE -> { + val request = m.data.getLong("request") + if (request == tritonFeatureAsked) FakeInputWriter.writeTritonFeatureReply(TRITON_SLOT, request, m.data.getByteArray("data")) + } + } + } + }) + + private val conn = object : ServiceConnection { + override fun onServiceConnected(n: ComponentName, b: IBinder) { + server = Messenger(b) + send(Message.obtain(null, MSG_REGISTER).apply { replyTo = inbox }) + sendShm() + workerHandler.post { if (onDesktop()) sendSync() } // the desktop: say at once whether Steam runs + } + override fun onServiceDisconnected(n: ComponentName) { + server = null; release() + workerHandler.post { setTriton(false, "bridge stopped"); HookupBridge.removeAll() } + } + } + + /** @return false if the bridge app is not installed / not bindable. */ + @JvmStatic + @Synchronized + fun attach(context: Context, padBridge: PadBridge): Boolean { + pad = padBridge + owner = context + if (!sessionActive) { + sessionActive = true + workerHandler.removeCallbacks(watchdog) + workerHandler.postDelayed(watchdog, WATCHDOG_MS) + } + if (bound) { + // A new session screen while the old one is still being torn down (Android recreates it when you + // come back from another app): keep the connection, and ask the bridge to resend its state. + send(Message.obtain(null, MSG_REGISTER).apply { replyTo = inbox }) + sendShm() + HookupBridge.note("bridge: new session screen, connection kept, state re-requested") + return true + } + val app = context.applicationContext + appContext = app + bound = try { + app.bindService(Intent(BRIDGE_ACTION).setPackage(bridgePackage(app)), conn, Context.BIND_AUTO_CREATE) + } catch (e: SecurityException) { false } + return bound + } + + @JvmStatic + @Synchronized + fun detach() = detach(null) + + /** + * The session screen is closing (Android also does this when you switch apps; a new screen attaches when you + * come back). Only its PadBridge is dropped -- the bridge stays connected, so Steam Native Hook-up and the + * Steam Controller passthrough keep running until the session ends or DroidDeck is closed. + */ + @JvmStatic + @Synchronized + fun screenClosed(who: Any?) { + if (who !== owner) return + HookupBridge.note("bridge: session screen closed; the bridge stays connected until the session ends") + owner = null + pad = null + } + + /** Only the screen that attached last may detach (an old screen's late onDestroy must not cut the new one off). */ + @JvmStatic + @Synchronized + fun detach(who: Any?) { + if (who != null && who !== owner) { + HookupBridge.note("bridge: an old session screen closed; the bridge stays connected") + return + } + HookupBridge.note("bridge: disconnected (session ended)") + sessionActive = false + steamAwayNoted = null + workerHandler.removeCallbacks(watchdog) + owner = null + if (bound) { + send(Message.obtain(null, MSG_UNREGISTER).apply { replyTo = inbox }) + try { appContext?.unbindService(conn) } catch (_: IllegalArgumentException) {} + bound = false + } + release() + workerHandler.post { setTriton(false, "session ended"); HookupBridge.removeAll() } + pad = null + server = null + } + + // ---------------- Steam Controller (2026) raw passthrough ---------------- + // The bridge streams the controller's own HID reports; they go into the same shared block DroidDeck's + // Bluetooth LE Steam Controller support uses, so Steam sees DroidDeck's Steam Controller node and drives + // it natively (capacitive stick touch, grip sense, trackpad pressure, IMU, haptics, settings). Steam's + // output reports and feature reads/writes travel back to the controller the same way. + @Volatile private var tritonOn = false + private var tritonOutputSeq = 0L + private var tritonFeatureAsked = 0L + private val tritonPoll = object : Runnable { + override fun run() { + if (!tritonOn) return + while (true) { + val out = FakeInputWriter.readTritonOutput(TRITON_SLOT, tritonOutputSeq) ?: break + tritonOutputSeq = java.nio.ByteBuffer.wrap(out, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long + send(Message.obtain(null, MSG_TRITON_OUTPUT).apply { + data = Bundle().apply { + putByteArray("data", out.copyOfRange(9, out.size)) + putBoolean("feature", out[8] != 0.toByte()) + } + }) + } + val request = FakeInputWriter.readTritonFeatureRequest(TRITON_SLOT) + if (request != 0L && request != tritonFeatureAsked) { + tritonFeatureAsked = request + send(Message.obtain(null, MSG_TRITON_GET).apply { + data = Bundle().apply { putLong("request", request); putInt("id", 1) } + }) + } + workerHandler.postDelayed(this, TRITON_POLL_MS) + } + } + + // ---------------- Steam Native Hook-up fast path ---------------- + // The bridge gets the shared hook-up file itself (as a descriptor: Android lets an app use a descriptor it is + // handed, only opening another app's file by path is forbidden). It then writes each report straight into + // the file and wakes Steam's reader -- no message per report, no polling -- and DroidDeck only keeps the + // controller list and its sysfs description. An older bridge simply ignores the message. + @Volatile private var shmSentMs = 0L + + private fun sendShm() { + val file = HookupBridge.shmFile ?: return + if (server == null) return + shmSentMs = android.os.SystemClock.uptimeMillis() + try { + android.os.ParcelFileDescriptor.open(file, android.os.ParcelFileDescriptor.MODE_READ_WRITE).use { pfd -> + send(Message.obtain(null, MSG_HOOKUP_SHM).apply { data = Bundle().apply { putParcelable("fd", pfd) } }) + } + } catch (e: Exception) { + HookupBridge.note("hookup: could not hand the shared file to the bridge: $e") + } + } + + // ---------------- background watchdog ---------------- + // For the whole session (not just while the session screen is open), like Steam on a PC: reconnect to the + // bridge if that connection is gone (bridge restarted, killed, installed late), and ask it every 1.5 s what it + // is handing over -- controllers that appeared are added, ones it no longer has are removed, and nothing + // changes for ones that are already there. Also keeps DroidDeck's own pad withdrawn while Steam has a real one. + @Volatile private var sessionActive = false + private var serverLostTicks = 0 + private val watchdog = object : Runnable { + override fun run() { + if (!sessionActive) return + val ctx = appContext + if (!bound && ctx != null) { + bound = try { + ctx.bindService(Intent(BRIDGE_ACTION).setPackage(bridgePackage(ctx)), conn, Context.BIND_AUTO_CREATE) + } catch (_: SecurityException) { false } + if (bound) HookupBridge.note("bridge: watchdog (re)connecting to the SDL3 bridge") + } else if (server != null) { + serverLostTicks = 0 + sendSync() + } else if (bound && ++serverLostTicks >= 4) { + // bound but no connection for ~6 s (the bridge app was force-stopped): start over + serverLostTicks = 0 + try { ctx?.unbindService(conn) } catch (_: IllegalArgumentException) {} + bound = false + HookupBridge.note("bridge: the SDL3 bridge went away; the watchdog will reconnect") + } + updateHookupPad() + workerHandler.postDelayed(this, WATCHDOG_MS) + } + } + + // ---------------- DroidDeck's desktop: Steam Native Hook-up waits for Steam ---------------- + // On the desktop Steam is one program among others, and it may not be running. The bridge then hands controllers + // to Steam only while the Steam client runs (its heartbeat in the hook-up file); until it starts -- and after it + // quits -- they stay ordinary gamepads every program can use. In the Steam session Steam always runs. + @Volatile private var steamAwayNoted: Boolean? = null + + private fun onDesktop(): Boolean = try { + com.droiddeck.launcher.session.SessionState.mode == com.droiddeck.launcher.session.SessionService.MODE_DESKTOP + } catch (_: Throwable) { false } + + private fun desktopWithoutSteam(): Boolean { + val desktop = onDesktop() + val away = desktop && !HookupBridge.steamRunning() + if (desktop && away != steamAwayNoted) { + steamAwayNoted = away + HookupBridge.note(if (away) "hookup: Steam is not running on the desktop: with Steam Native Hook-up on, the SDL3 " + + "bridge keeps controllers as ordinary gamepads until Steam starts" + else "hookup: Steam runs on the desktop: with Steam Native Hook-up on, the SDL3 bridge hands controllers to Steam") + } + return away + } + + /** "Send me everything you hand over right now" -- and, on the desktop, whether Steam is there to take it. */ + private fun sendSync() { + send(Message.obtain(null, MSG_HOOKUP_SYNC).apply { + replyTo = inbox + data = Bundle().apply { putBoolean("steamAway", desktopWithoutSteam()) } + }) + } + + // ---------------- Steam Native Hook-up: Steam -> controller ---------------- + private var hookupPolling = false + private var hookupPollTicks = 0 + private val hookupPoll = object : Runnable { + override fun run() { + if (!HookupBridge.anyPresent()) { hookupPolling = false; return } + // every ~250 ms: keep DroidDeck's own pad withdrawn (also once a bridged gamepad's last frame is + // more than a second old -- e.g. right after hook-up was switched back on) + if (++hookupPollTicks % 250 == 0) updateHookupPad() + HookupBridge.poll( + { slot, kind, report -> + send(Message.obtain(null, MSG_HOOKUP_OUT).apply { + data = Bundle().apply { putInt("slot", slot); putInt("kind", kind); putByteArray("data", report) } + }) + }, + { slot, request, id, input, length -> + send(Message.obtain(null, MSG_HOOKUP_GET).apply { + data = Bundle().apply { + putInt("slot", slot); putLong("request", request); putInt("id", id) + putBoolean("input", input); putInt("length", length) + } + }) + }) + workerHandler.postDelayed(this, HOOKUP_POLL_MS) + } + } + + // While real controllers are handed to Steam and no bridged gamepad is streaming, DroidDeck's own pad + // is withdrawn so the real controller is player 1 in Steam (pad type 7 = none), like on a PC. + private var hookupPadHidden = false + + private val padTypeField = runCatching { + FakeInputWriter::class.java.getDeclaredField("padType").apply { isAccessible = true } + }.getOrNull() + private fun currentPadType(): Int = runCatching { padTypeField?.getInt(null) ?: -2 }.getOrDefault(-2) + + private fun updateHookupPad() { + val hide = HookupBridge.anyPresent() && !isActive() + if (hide && hookupPadHidden && currentPadType() != FakeInputWriter.PAD_TYPE_NONE) { + // something switched DroidDeck's pad back on (e.g. the pad-type menu): withdraw it again + HookupBridge.note("hookup: DroidDeck's pad came back while a controller is handed to Steam; withdrawing it again") + applyPadType(FakeInputWriter.PAD_TYPE_NONE) + } + if (hide && !hookupPadHidden) { + hookupPadHidden = true + lastPadType = -1 + applyPadType(FakeInputWriter.PAD_TYPE_NONE) + } else if (!hide && hookupPadHidden) { + hookupPadHidden = false + val ctx = appContext ?: return + val pt = com.droiddeck.launcher.session.SessionPrefs.padType(com.droiddeck.launcher.session.SessionPrefs.steamController(ctx)) + lastPadType = pt + applyPadType(pt) + } + } + + private fun startHookupPoll() { + if (hookupPolling) return + hookupPolling = true + workerHandler.post(hookupPoll) + } + + /** Runs on the worker thread. */ + private fun setTriton(on: Boolean, what: String) { + if (on == tritonOn) return + tritonOn = on + HookupBridge.tritonPassthrough = on + workerHandler.removeCallbacks(tritonPoll) + if (on) { + tritonFeatureAsked = 0L + FakeInputWriter.setTritonConnected(TRITON_SLOT, true) + workerHandler.post(tritonPoll) + } else { + tritonRumbleStop?.let { rumbleTimer.removeCallbacks(it) } + tritonRumbleStop = null + FakeInputWriter.setTritonConnected(TRITON_SLOT, false) + FakeInputWriter.writeTritonState(TRITON_SLOT, 0x45, ByteArray(TRITON_STATE_BYTES)) + } + android.util.Log.i("SessionService", "steam controller: SDL3 bridge passthrough ${if (on) "on ($what)" else "off ($what)"}") + } + + /** True while a Steam Controller (2026) is passed through by the bridge (cable or puck). */ + @JvmStatic fun isTritonPassthrough(): Boolean = tritonOn + + /** + * True when the SDL3 bridge app is installed. DroidDeck then prepares its Steam Controller node at session + * start even without a Bluetooth-paired one, so a cable/puck controller can appear at any time (hot-plug). + */ + @JvmStatic + fun isBridgeInstalled(context: Context): Boolean = try { + context.packageManager.getPackageInfo(bridgePackage(context), 0); true + } catch (_: Exception) { false } + + @Volatile private var bridgePkg: String? = null + + /** + * Where the SDL3 bridge is: built into this app (SDL3DroidDeck: the same bridge, in its own ":sdl3" process), or + * the separate SDL3Bridge app (SDL3Deck). The built-in one is used whenever this app has it. + */ + @JvmStatic + fun bridgePackage(context: Context): String { + bridgePkg?.let { return it } + val own = context.packageName + val builtIn = try { + context.packageManager.resolveService(Intent(BRIDGE_ACTION).setPackage(own), 0) != null + } catch (_: Exception) { false } + return (if (builtIn) own else BRIDGE_PKG).also { bridgePkg = it } + } + + /** True when the bridge is built into this app (SDL3DroidDeck). */ + @JvmStatic + fun bridgeBuiltIn(context: Context): Boolean = bridgePackage(context) == context.packageName + + private var tritonRumbleStop: Runnable? = null + + private fun tritonRumble(strong: Int, weak: Int, ms: Int) { + rumbleTimer.post { + tritonRumbleStop?.let { rumbleTimer.removeCallbacks(it) } + tritonRumbleStop = null + sendTritonRumble(strong, weak) + if (strong == 0 && weak == 0) return@post + val hold = if (ms == RUMBLE_HOLD_MS || ms <= 0) HOLD_SAFETY_MS else ms.toLong().coerceIn(1L, 5000L) + val stop = Runnable { sendTritonRumble(0, 0); tritonRumbleStop = null } + tritonRumbleStop = stop + rumbleTimer.postDelayed(stop, hold) + } + } + + private fun sendTritonRumble(strong: Int, weak: Int) { + send(Message.obtain(null, MSG_TRITON_RUMBLE).apply { + data = Bundle().apply { putInt("l", strong.coerceIn(0, 65535)); putInt("r", weak.coerceIn(0, 65535)) } + }) + } + + /** Forward game rumble (0..255 each) to the physical controller of player 1. */ + // Per-bridge-slot pending auto-stop. The bridge's rumble is level-based (stays on until set to 0), + // so we must stop it ourselves after the effect's duration — exactly as RumbleComponent does with its + // one-shots. Without this a single "ping" (one 2000 ms buzz) would rumble the controller forever. + private val rumbleTimer = Handler(worker.looper) + private val rumbleStop = arrayOfNulls(SLOTS) + private const val RUMBLE_HOLD_MS = 0xFFFF // matches RumbleComponent.HOLD_MS + private const val HOLD_SAFETY_MS = 30_000L + + @JvmStatic + fun rumble(strong: Int, weak: Int) = rumble(0, strong, weak, RUMBLE_HOLD_MS) + + @JvmStatic + fun rumble(deckSlot: Int, strong: Int, weak: Int) = rumble(deckSlot, strong, weak, RUMBLE_HOLD_MS) + + /** + * Rumble for a DroidDeck player slot (0 = player 1), honoring the effect duration [ms]. + * HOLD effects (0xFFFF) play until the explicit stop that follows them (30 s safety cap); a timed + * one-shot (e.g. the controller "ping") plays for its own duration and then stops. + */ + @JvmStatic + fun rumble(deckSlot: Int, strong: Int, weak: Int, ms: Int) { + rumbleTimer.post { + val bridgeSlot = (0 until SLOTS).firstOrNull { ddSlot[it] == deckSlot } ?: return@post + rumbleStop[bridgeSlot]?.let { rumbleTimer.removeCallbacks(it); rumbleStop[bridgeSlot] = null } + sendRumble(bridgeSlot, strong, weak) + if (strong == 0 && weak == 0) return@post + // HOLD = play until the game's/Steam's explicit stop (they always send one); the long cap is only a + // safety net in case that stop is lost (e.g. the game crashes mid-rumble). + val hold = if (ms == RUMBLE_HOLD_MS || ms <= 0) HOLD_SAFETY_MS else ms.toLong().coerceIn(1L, 5000L) + val stop = Runnable { sendRumble(bridgeSlot, 0, 0); rumbleStop[bridgeSlot] = null } + rumbleStop[bridgeSlot] = stop + rumbleTimer.postDelayed(stop, hold) + } + } + + private fun sendRumble(bridgeSlot: Int, strong: Int, weak: Int) { + send(Message.obtain(null, MSG_OUTPUT).apply { + data = Bundle().apply { + putInt("slot", bridgeSlot) + putInt("rl", strong.coerceIn(0, 255)); putInt("rr", weak.coerceIn(0, 255)) + } + }) + } + + private fun send(m: Message) { try { server?.send(m) } catch (_: RemoteException) {} } + + // ---------------- frame handling ---------------- + + private fun stick(v: Int): Float { + val f = ((v - 128) / 127f).coerceIn(-1f, 1f) + return if (Math.abs(f) < DEAD_ZONE) 0f else f + } + + /** True while player 1's controller is streaming; PadMotion uses this to stop writing the phone's gyro. */ + @JvmStatic + fun isActive(): Boolean = frameMsS[0] != 0L && SystemClock.elapsedRealtime() - frameMsS[0] < 1000L + + // ---------------- frames: latest wins ---------------- + // Each bridge frame is a complete controller state, so when several wait for the worker only the newest matters + // -- except frames where a button, trackpad touch or click changed, which are all kept so no tap is lost. A + // frame that only moves sticks / gyro and is already older than FRAME_STALE_NS (a backlog inside Binder) is + // dropped: its state is out of date and a newer frame follows. + private const val FRAME_STALE_NS = 50_000_000L + private const val FRAME_PENDING_MAX = 16 + private val frames = FrameMailbox(SLOTS, FRAME_STALE_NS, FRAME_PENDING_MAX, + sigOf = ::frameSig, sentNsOf = { it.getLong("t", 0L) }, nowNs = SystemClock::elapsedRealtimeNanos) + private val drainFrames = Array(SLOTS) { slot -> Runnable { drainFrames(slot) } } + + /** The digital part of a frame: buttons, trackpad click side, which trackpads are touched. */ + private fun frameSig(b: Bundle): Long { + val padL = b.getFloatArray("padL") + val padR = b.getFloatArray("padR") + val touch = b.getIntArray("touch") + return (b.getInt("mask").toLong() and 0xffffffffL) or + ((b.getInt("padClick", -1) + 1).toLong() shl 32) or + ((if (padL != null && padL.isNotEmpty() && padL[0] > 0f) 1L else 0L) shl 34) or + ((if (padR != null && padR.isNotEmpty() && padR[0] > 0f) 1L else 0L) shl 35) or + (((touch?.size ?: 0) / 3).toLong() shl 36) + } + + private fun offerFrame(b: Bundle) { + val slot = b.getInt("slot", 0).coerceIn(0, SLOTS - 1) + if (frames.offer(slot, b)) workerHandler.post(drainFrames[slot]) + } + + private fun drainFrames(slot: Int) { + val now = SystemClock.elapsedRealtimeNanos() + for (f in frames.take(slot)) { + val sent = f.getLong("t", 0L) + if (sent != 0L) lagMaxNs = maxOf(lagMaxNs, now - sent) + lagHandled++ + onFrame(f) + } + lagReport(now) + } + + // Lag meter, written to pad.log every 10 s when input was late: how old frames were when handled (bridge -> + // Binder -> this thread) and how many stale ones were dropped. Lag that keeps growing shows up here. + private var lagHandled = 0 + private var lagMaxNs = 0L + private var lagSinceNs = 0L + private fun lagReport(now: Long) { + if (lagSinceNs == 0L) lagSinceNs = now + if (now - lagSinceNs < 10_000_000_000L) return + val dropped = frames.takeDropped() + if (lagMaxNs > 20_000_000L || dropped > 0) + HookupBridge.note("bridge: input lag, last 10 s: %d frames handled, oldest %.1f ms; %d stale frames dropped" + .format(lagHandled, lagMaxNs / 1e6, dropped)) + lagSinceNs = now; lagHandled = 0; lagMaxNs = 0L + } + + /** + * True when [dev] is the same physical controller the SDL3 bridge is currently streaming (any player slot). + * Android reports that controller too (basic input), so DroidDeck's own gamepad handling must + * ignore it while the bridge is active, otherwise every press arrives twice. + */ + @JvmStatic + fun isBridged(dev: InputDevice?): Boolean { + if (dev == null) return false + val now = SystemClock.elapsedRealtime() + for (i in 0 until SLOTS) { + if (vidS[i] != 0 && frameMsS[i] != 0L && now - frameMsS[i] < 1000L && + dev.vendorId == vidS[i] && dev.productId == pidS[i]) return true + } + if (sepVid != 0 && sepMs != 0L && now - sepMs < 1000L && + dev.vendorId == sepVid && dev.productId == sepPid) return true + return false + } + + /** True when DroidDeck player [deckSlot] is currently driven by a bridge controller (for rumble routing). */ + @JvmStatic + fun isBridgedSlot(deckSlot: Int): Boolean { + val now = SystemClock.elapsedRealtime() + for (i in 0 until SLOTS) { + if (ddSlot[i] == deckSlot && frameMsS[i] != 0L && now - frameMsS[i] < 1000L) return true + } + return false + } + + /** + * Called first thing in RumbleComponent.buzz(). Returns true when the effect was sent to a bridged + * controller (and must NOT also go to the phone / an Android pad). + * + * - slot 0..3: a DroidDeck player slot -> its bridged controller, if one drives it. + * - slot >= PadBridge.SLOTS (e.g. 16): a game rumbling Steam's VIRTUAL pad. Upstream sends that to the + * "last active Android controller", which is nobody while the bridge holds the pad, so it fell back to + * the phone's vibrator. Route it to player 1's bridged controller instead (unless a real Android pad is + * the active one, or it is a SteamVR Joy-Con hand slot). + */ + @JvmStatic + fun routeRumble(slot: Int, strong: Int, weak: Int, ms: Int): Boolean { + // A real controller Steam drives itself is connected (Steam Native Hook-up / Steam Controller + // passthrough): game rumble on Steam's virtual pad goes to Steam (fakeinput_steam.cpp hands it the + // uinput force-feedback request) and Steam rumbles that controller with its own driver. + if (slot >= PadBridge.SLOTS && JoyCon.vrRumbleDevice(slot) == null && HookupBridge.ffToSteam()) return true + // Only a passed-through Steam Controller (2026), no bridged gamepad: player 1 / virtual-pad rumble -> it. + if (tritonOn && !isBridgedSlot(0) && (slot == 0 || slot >= PadBridge.SLOTS) && JoyCon.vrRumbleDevice(slot) == null && + (slot == 0 || !realAndroidPadActive())) { + val on = (strong != 0 || weak != 0) && ms != 0 + if (on) tritonRumble(strong, weak, ms) else tritonRumble(0, 0, 0) + return true + } + val deckSlot = when { + slot in 0 until PadBridge.SLOTS -> if (isBridgedSlot(slot)) slot else return false + JoyCon.vrRumbleDevice(slot) != null -> return false + !isBridgedSlot(0) -> return false + else -> { + if (realAndroidPadActive()) return false // a real Android pad with motors is in use: leave it to upstream + 0 + } + } + val on = (strong != 0 || weak != 0) && ms != 0 // ms == 0 is a stop, as in RumbleComponent + if (on != rumbleLogged) { + rumbleLogged = on + android.util.Log.i("SessionService", "rumble: slot $slot -> SDL3 bridge (player ${deckSlot + 1}) ${if (on) "on ${strong shr 8}/${weak shr 8} ${ms}ms" else "off"}") + } + if (on) rumble(deckSlot, strong shr 8, weak shr 8, ms) else rumble(deckSlot, 0, 0, 0) + return true + } + @Volatile private var rumbleLogged = false + + private fun realAndroidPadActive(): Boolean { + val id = PadBridge.activeControllerId() + if (id == PadBridge.NO_CONTROLLER) return false + val dev = InputDevice.getDevice(id) ?: return false + return !isBridged(dev) && hasMotors(dev) + } + + private fun hasMotors(dev: InputDevice): Boolean = try { + if (android.os.Build.VERSION.SDK_INT >= 31) dev.vibratorManager.vibratorIds.isNotEmpty() + else @Suppress("DEPRECATION") dev.vibrator.hasVibrator() + } catch (_: Exception) { false } + + @JvmStatic fun isBridged(e: KeyEvent): Boolean = isBridged(e.device) + @JvmStatic fun isBridged(e: MotionEvent): Boolean = isBridged(e.device) + + /** A controller in bridge slot [slot] was unplugged. */ + private fun padGone(slot: Int) { + if (slot !in 0 until SLOTS) return + if (slot == 0) { release(); return } + val p = pad + val dd = ddSlot[slot] + if (p != null && dd > 0) { + p.applyExternalSlot(dd, { s: PadState -> fill(s, 0, FloatArray(6)) }, false) + p.releaseBridgeSlot(dd) + } + ddSlot[slot] = -1 + frameMsS[slot] = 0L + vidS[slot] = 0; pidS[slot] = 0 + maskS[slot] = 0; axesS[slot].fill(0f) + } + + /** Extra controllers (bridge slots 1..3) become DroidDeck players 2..4: plain gamepad plus gyro. */ + private fun onExtraFrame(slot: Int, b: Bundle) { + val p = pad ?: return + val now = SystemClock.elapsedRealtime() + frameMsS[slot] = now + vidS[slot] = b.getInt("vid"); pidS[slot] = b.getInt("pid") + var dd = ddSlot[slot] + if (dd <= 0) { + dd = p.claimBridgeSlot() + if (dd <= 0) return // all DroidDeck player slots are taken + ddSlot[slot] = dd + } + val mask = b.getInt("mask") + val ax = floatArrayOf( + stick(b.getInt("lx")), stick(b.getInt("ly")), stick(b.getInt("rx")), stick(b.getInt("ry")), + b.getInt("l2") / 255f, b.getInt("r2") / 255f, + ) + val buttonsChanged = mask != maskS[slot] + val axesChanged = !ax.contentEquals(axesS[slot]) + if (buttonsChanged || (axesChanged && now - applyMsS[slot] >= MIN_AXIS_INTERVAL_MS)) { + maskS[slot] = mask + ax.copyInto(axesS[slot]) + applyMsS[slot] = now + p.applyExternalSlot(dd, { s: PadState -> fill(s, mask, ax) }, buttonsChanged) + } + val g = b.getFloatArray("gyro") + val a = b.getFloatArray("accel") + if (g != null && a != null) { + FakeInputWriter.writeMotion(dd, shorts(a, ACCEL_LSB_PER_G, ACCEL_SIGN), shorts(g, GYRO_LSB_PER_DPS * GYRO_GAIN, GYRO_SIGN)) + } + } + + private fun axisS(v: Float): Short = (v.coerceIn(-1f, 1f) * 32767f).toInt().toShort() + + /** The DualSense as its own full-featured DroidDeck controller (separate Steam entry). */ + private fun onSeparateFrame(b: Bundle) { + val p = pad ?: return + val now = SystemClock.elapsedRealtime() + sepMs = now + sepVid = b.getInt("vid"); sepPid = b.getInt("pid") + if (sepSlot <= 0) { + sepSlot = p.claimBridgeSlot() + if (sepSlot <= 0) return // no free DroidDeck slot + lastSepControls = -1 + } + val mask = b.getInt("mask") + val ax = floatArrayOf( + stick(b.getInt("lx")), stick(b.getInt("ly")), stick(b.getInt("rx")), stick(b.getInt("ry")), + b.getInt("l2") / 255f, b.getInt("r2") / 255f, + ) + val buttonsChanged = mask != sepMask + val axesChanged = !ax.contentEquals(sepAxes) + if (buttonsChanged || (axesChanged && now - sepApplyMs >= MIN_AXIS_INTERVAL_MS)) { + sepMask = mask + ax.copyInto(sepAxes) + sepApplyMs = now + p.applyExternalSlot(sepSlot, { s: PadState -> fill(s, mask, ax) }, buttonsChanged) + } + + // back buttons + trackpads + clicks -> this slot's Deck controls + var controls = 0 + if (((mask shr 19) and 1) != 0) controls = controls or DeckControls.L4 + if (((mask shr 20) and 1) != 0) controls = controls or DeckControls.R4 + if (((mask shr 21) and 1) != 0) controls = controls or DeckControls.L5 + if (((mask shr 22) and 1) != 0) controls = controls or DeckControls.R5 + val pads = ShortArray(4) + val padL = b.getFloatArray("padL") + val padR = b.getFloatArray("padR") + if (padL != null && padL.size >= 3 && padL[0] > 0.5f) { + val xy = orientPad(padL[1], padL[2]); controls = controls or DC_LEFT_TOUCH + pads[0] = axisS(xy[0]); pads[1] = axisS(xy[1]) + } + if (padR != null && padR.size >= 3 && padR[0] > 0.5f) { + val xy = orientPad(padR[1], padR[2]); controls = controls or DC_RIGHT_TOUCH + pads[2] = axisS(xy[0]); pads[3] = axisS(xy[1]) + } + val pressure = ShortArray(2) + when (b.getInt("padClick", -1)) { + 0 -> { controls = controls or DC_LEFT_CLICK; pressure[0] = Short.MAX_VALUE } + 1 -> { controls = controls or DC_RIGHT_CLICK; pressure[1] = Short.MAX_VALUE } + } + if (controls != lastSepControls || !pads.contentEquals(lastSepPads) || !pressure.contentEquals(lastSepPressure)) { + FakeInputWriter.writeDeckControls(sepSlot, controls, pads, pressure) + lastSepControls = controls + pads.copyInto(lastSepPads) + pressure.copyInto(lastSepPressure) + } + + val g = b.getFloatArray("gyro") + val a = b.getFloatArray("accel") + if (g != null && a != null) { + FakeInputWriter.writeMotion(sepSlot, shorts(a, ACCEL_LSB_PER_G, ACCEL_SIGN), shorts(g, GYRO_LSB_PER_DPS * GYRO_GAIN, GYRO_SIGN)) + } + } + + private fun orientPad(x0: Float, y0: Float): FloatArray { + var x = x0; var y = y0 + if (TOUCH_SWAP) { val t = x; x = y; y = t } + if (TOUCH_INV_X) x = -x + if (TOUCH_INV_Y) y = -y + return floatArrayOf(x, y) + } + + private fun releaseSep() { + val p = pad + if (p != null && sepSlot > 0) { + FakeInputWriter.writeDeckControls(sepSlot, 0, ShortArray(4), ShortArray(2)) + p.applyExternalSlot(sepSlot, { s: PadState -> fill(s, 0, FloatArray(6)) }, false) + p.releaseBridgeSlot(sepSlot) + } + sepSlot = -1; sepMs = 0L; sepVid = 0; sepPid = 0; sepMask = 0; sepAxes.fill(0f) + lastSepControls = -1; lastSepPads.fill(0); lastSepPressure.fill(0) + } + + /** + * Switch the emulated controller type exactly the way DroidDeck's own QAM does: first show the + * right hidraw node (creates /sys/class/hidraw symlink, removes the others) so Steam can enumerate + * it, THEN flip the shared pad_type. Without showPad() setPadType alone only changes the report + * format, so Steam never sees the new device and keeps the Steam Deck pad. + */ + private fun applyPadType(pt: Int) { + try { com.droiddeck.launcher.session.SteamDeckPad.showPad(pt) } catch (t: Throwable) { } + FakeInputWriter.setPadType(pt) + } + + private fun onFrame(b: Bundle) { + val slot = b.getInt("slot", 0).coerceIn(0, SLOTS - 1) + if (slot != 0) { onExtraFrame(slot, b); return } + val pt = b.getInt("ptype", 0) + if (hookupPadHidden) hookupPadHidden = false // a bridged gamepad needs DroidDeck's pad again + if (pt != lastPadType) { lastPadType = pt; applyPadType(pt) } // Deck / DualSense / DS4 + val p = pad ?: return + frameMsS[0] = SystemClock.elapsedRealtime() + vidS[0] = b.getInt("vid"); pidS[0] = b.getInt("pid") + val mask = b.getInt("mask") + val ax = floatArrayOf( + stick(b.getInt("lx")), stick(b.getInt("ly")), stick(b.getInt("rx")), stick(b.getInt("ry")), + b.getInt("l2") / 255f, b.getInt("r2") / 255f, + ) + val now = SystemClock.elapsedRealtime() + val prev = lastMask + val buttonsChanged = mask != prev + val axesChanged = !ax.contentEquals(lastAxes) + if (buttonsChanged || (axesChanged && now - lastApplyMs >= MIN_AXIS_INTERVAL_MS)) { + lastMask = mask + ax.copyInto(lastAxes) + lastApplyMs = now + p.applyExternal({ s: PadState -> fill(s, mask, ax) }, true) + if (buttonsChanged) { + val diff = mask xor prev + for ((bit, grip) in GRIPS) if (((diff shr bit) and 1) != 0) { + DeckControls.setGrip(grip, ((mask shr bit) and 1) != 0) + } + if (MAP_TOUCHPAD && !b.containsKey("padL") && ((diff shr 13) and 1) != 0) { + if (((mask shr 13) and 1) != 0) { + val sides = sidesOf(b.getIntArray("touch")) + clickSide = if (sides == 1) 0 else 1 // only left finger -> left click, else right + DeckControls.setClick(clickSide == 1, true) + } else if (clickSide >= 0) { + DeckControls.setClick(clickSide == 1, false) + clickSide = -1 + } + } + } + } + if (MAP_TOUCHPAD && !decoupledTouch(b)) touch(b.getIntArray("touch")) + val g = b.getFloatArray("gyro") + val a = b.getFloatArray("accel") + if (g != null && a != null) { + FakeInputWriter.writeMotion(0, shorts(a, ACCEL_LSB_PER_G, ACCEL_SIGN), shorts(g, GYRO_LSB_PER_DPS * GYRO_GAIN, GYRO_SIGN)) + } + } + + private fun fill(s: PadState, mask: Int, ax: FloatArray) { + for ((bit, btn) in BUTTONS) s.press(btn, ((mask shr bit) and 1) != 0) + s.up = ((mask shr 15) and 1) != 0 + s.down = ((mask shr 16) and 1) != 0 + s.left = ((mask shr 17) and 1) != 0 + s.right = ((mask shr 18) and 1) != 0 + s.leftX = ax[0]; s.leftY = ax[1]; s.rightX = ax[2]; s.rightY = ax[3] + s.leftTrigger = ax[4]; s.rightTrigger = ax[5] + } + + /** + * Newer bridge builds split the touchpad themselves (see the bridge's Touchpad layout setting): + * padL / padR = [down, x, y] with x and y already in -1..1 for that trackpad, padClick = -1 / 0 (left) / 1 (right). + * @return false when the bridge does not send this data (older bridge), so the caller falls back to raw fingers. + */ + private fun decoupledTouch(b: Bundle): Boolean { + val l = b.getFloatArray("padL") ?: return false + val r = b.getFloatArray("padR") ?: return false + if (l.size < 3 || r.size < 3) return false + decoupledPad(false, l) + decoupledPad(true, r) + val c = b.getInt("padClick", -1) + if (c != clickSide) { + if (clickSide >= 0) DeckControls.setClick(clickSide == 1, false) + if (c >= 0) DeckControls.setClick(c == 1, true) + clickSide = c + } + return true + } + + private fun decoupledPad(right: Boolean, p: FloatArray) { + val slot = if (right) 1 else 0 + val touching = p[0] > 0.5f + val key = if (touching) (((p[1] * 1023f).toInt() and 0x7FF) shl 11) or ((p[2] * 1023f).toInt() and 0x7FF) else -1 + if (key == lastTouchKey[slot]) return + lastTouchKey[slot] = key + if (touching) { + var x = p[1]; var y = p[2] + if (TOUCH_SWAP) { val t = x; x = y; y = t } + if (TOUCH_INV_X) x = -x + if (TOUCH_INV_Y) y = -y + DeckControls.setPad(right, true, x, y) + } else DeckControls.setPad(right, false) + } + + /** Bit 0 = a finger on the left half, bit 1 = a finger on the right half. */ + private fun sidesOf(t: IntArray?): Int { + var r = 0 + var i = 0 + while (t != null && i + 2 < t.size) { r = r or (if (t[i + 1] < TOUCH_HALF) 1 else 2); i += 3 } + return r + } + + private fun touch(t: IntArray?) { + var left: IntArray? = null + var right: IntArray? = null + var i = 0 + while (t != null && i + 2 < t.size) { + val pt = intArrayOf(t[i + 1], t[i + 2]) + if (pt[0] < TOUCH_HALF) { if (left == null) left = pt } else { if (right == null) right = pt } + i += 3 + } + pad(false, left) + pad(true, right) + } + + private fun pad(right: Boolean, pt: IntArray?) { + val slot = if (right) 1 else 0 + val key = if (pt != null) ((pt[0] shr 4) shl 16) or (pt[1] shr 4) else -1 + if (key == lastTouchKey[slot]) return + lastTouchKey[slot] = key + if (pt == null) { DeckControls.setPad(right, false); return } + val local = if (right) pt[0] - TOUCH_HALF else pt[0] + DeckControls.setPad(right, true, local / TOUCH_HALF.toFloat() * 2f - 1f, pt[1] / TOUCH_H * 2f - 1f) + } + + private fun shorts(v: FloatArray, scale: Float, sign: FloatArray) = + ShortArray(3) { (v[it] * scale * sign[it]).coerceIn(-32768f, 32767f).toInt().toShort() } + + /** Neutral state: called when the bridge disconnects so nothing stays stuck. */ + private fun release() { + frameMsS[0] = 0L + if (lastPadType != 0 && !hookupPadHidden) { lastPadType = 0; applyPadType(0) } + updateHookupPad() + for (k in 1 until SLOTS) padGone(k) + val p = pad ?: return + lastMask = 0 + lastAxes.fill(0f) + lastTouchKey.fill(-1) + clickSide = -1 + p.applyExternal({ s: PadState -> fill(s, 0, FloatArray(6)) }, false) + for (grip in GRIPS.values) DeckControls.setGrip(grip, false) + if (MAP_TOUCHPAD) { + DeckControls.setPad(false, false); DeckControls.setPad(true, false) + DeckControls.setClick(false, false); DeckControls.setClick(true, false) + } + } +} + +// ==== FrameMailbox (self-contained: tested on its own) ==== +/** + * "Latest wins" queue of controller frames per slot, filled on Binder threads and emptied by the worker thread. + * A frame whose digital part (buttons, trackpad touches / click: [sigOf]) equals the newest waiting one replaces + * it -- only the newest analog state matters -- while every change of the digital part is kept, so no tap is lost. + * A frame that arrives already older than [staleNs] without a digital change is dropped (a backlog upstream). + */ +internal class FrameMailbox( + slots: Int, + private val staleNs: Long, + private val maxPending: Int, + private val sigOf: (T) -> Long, + private val sentNsOf: (T) -> Long, + private val nowNs: () -> Long, +) { + private val pending = Array(slots) { ArrayDeque() } + private val queued = BooleanArray(slots) + private val offeredSig = LongArray(slots) { Long.MIN_VALUE } + private var dropped = 0 + + /** @return true when the caller must schedule [take] for this slot (it wasn't scheduled yet). */ + @Synchronized + fun offer(slot: Int, frame: T): Boolean { + val sig = sigOf(frame) + val sent = sentNsOf(frame) + if (sent != 0L && nowNs() - sent > staleNs && sig == offeredSig[slot]) { dropped++; return false } + offeredSig[slot] = sig + val q = pending[slot] + val last = q.lastOrNull() + if (last != null && sigOf(last) == sig) q.removeAt(q.size - 1) + q.addLast(frame) + while (q.size > maxPending) q.removeAt(0) + if (queued[slot]) return false + queued[slot] = true + return true + } + + /** Everything waiting for this slot, oldest first (usually one frame). */ + @Synchronized + fun take(slot: Int): List { + val l = pending[slot].toList() + pending[slot].clear() + queued[slot] = false + return l + } + + @Synchronized + fun takeDropped(): Int = dropped.also { dropped = 0 } +} 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 05469e634..7699cf8bc 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -88,6 +88,9 @@ public class FakeInputWriter { public static final int PAD_TYPE_XBOX = 2; public static final int PAD_TYPE_DS4 = 3; public static final int PAD_TYPE_DUALSENSE = 4; + public static final int PAD_TYPE_DUALSENSE_EDGE = 5; + public static final int PAD_TYPE_STEAM_2015 = 6; + public static final int PAD_TYPE_NONE = 7; private static volatile int padType = -1; private static volatile boolean tritonConnected = false; private static final int TRITON_OFFSET = RING_SIZE + 64; diff --git a/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt b/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt new file mode 100644 index 000000000..46dc9c716 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt @@ -0,0 +1,380 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.os.Bundle +import android.system.Os +import android.util.Log +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.MappedByteBuffer +import java.nio.channels.FileChannel + +/** + * SDL3 bridge "Steam Native Hook-up", DroidDeck side. + * + * The bridge hands a USB controller over raw: its real identity and every HID report. Here each one becomes + * /dev/hidraw24..27 -- sysfs description written like the kernel would (real vendor/product id, name, serial, + * interface, report descriptor) and the reports moved through a file shared with fakeinput_steam.cpp + * (FAKE_HOOKUP_SHM). Steam's own driver then runs on the real controller, like on a PC. + * + * Layout must match the HookupHeader / HookupSlot structs in fakeinput_steam.cpp. + */ +object HookupBridge { + private const val TAG = "SessionService" + const val FILE = ".hookup" + private const val MAGIC = 0x4b4f4f48 + const val SLOTS = 4 + private const val IN_FIFO = 256 + private const val OUT_FIFO = 64 + private const val REPORT_MAX = 128 + private const val REPORT_SIZE = 144 + private const val DESC_MAX = 4096 + private const val HEADER_SIZE = 4096 + private const val SLOT_SIZE = 53248 + private const val SIZE = HEADER_SIZE + SLOTS * SLOT_SIZE + private const val MINOR_BASE = 24 + private const val MAJOR = 240 + + // header + private const val H_FF_TO_STEAM = 8 + private const val H_STEAM_MS = 16 // the Steam client's heartbeat (CLOCK_MONOTONIC ms), written by fakeinput + private const val STEAM_GONE_MS = 5000L + // slot + private const val S_PRESENT = 0 + private const val S_GENERATION = 4 + private const val S_VENDOR = 8 + private const val S_PRODUCT = 10 + private const val S_VERSION = 12 + private const val S_BUS = 14 + private const val S_IFACE = 16 + private const val S_DESC_LEN = 20 + private const val S_NAME = 24 + private const val S_SERIAL = 152 + private const val S_MANUFACTURER = 216 + private const val S_PRODUCT_NAME = 280 + private const val S_IN_WRITE = 344 + private const val S_OUT_WRITE = 352 + private const val S_OUT_READ = 360 + private const val S_GET_REQUEST = 368 + private const val S_GET_REPLY = 376 + private const val S_GET_REPORT_ID = 384 + private const val S_GET_LENGTH = 388 + private const val S_GET = 392 + private const val S_DIRECT_MS = 520 // SDL3 bridge heartbeat while it serves the slot itself (fast path) + private const val S_DESC = 1024 + private const val S_IN = 5120 + private const val S_OUT = 41984 + + @Volatile private var map: MappedByteBuffer? = null + /** The shared file; handed to the SDL3 bridge as a descriptor so it can serve slots directly (fast path). */ + @Volatile var shmFile: File? = null + private set + @Volatile private var logFile: File? = null + + /** Also written to the session's pad.log, next to the native side's "hookup:" lines. */ + internal fun note(line: String) { + Log.i(TAG, line) + val f = logFile ?: return + try { + val t = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date()) + f.appendText("$t app: $line\n") + } catch (_: Exception) {} + } + private val present = BooleanArray(SLOTS) + private val getAsked = LongArray(SLOTS) + @Volatile var tritonPassthrough = false + set(value) { field = value; updateFfToSteam() } + + /** Creates the shared file; returns it for FAKE_HOOKUP_SHM. Called at session start. */ + @JvmStatic + fun prepare(fakeInputDir: File, padLog: File? = null): File? = try { + logFile = padLog + val file = File(fakeInputDir, FILE) + file.delete() + RandomAccessFile(file, "rw").use { raf -> + raf.setLength(SIZE.toLong()) + val m = raf.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong()) + m.order(ByteOrder.LITTLE_ENDIAN) + m.putInt(4, 1) + m.putInt(0, MAGIC) + map = m + } + shmFile = file + file.setReadable(true, false) + file.setWritable(true, false) + present.fill(false) + note("hookup: shared file ready (${file.path}); waiting for the SDL3 bridge to hand over controllers") + file + } catch (e: Exception) { + note("hookup: no shared file: $e") + map = null + shmFile = null + null + } + + private fun slotBase(slot: Int) = HEADER_SIZE + slot * SLOT_SIZE + + /** Inode of the shared file (0 if none): the bridge reports the one it mapped, so a stale mapping is noticed. */ + @JvmStatic fun shmInode(): Long = try { shmFile?.let { Os.stat(it.path).st_ino } ?: 0L } catch (_: Exception) { 0L } + + /** + * The SDL3 bridge writes this slot's reports into the file itself and takes Steam's output / feature reads + * from it (its heartbeat is fresh): DroidDeck must leave the slot's FIFOs alone then. + */ + private fun bridgeServes(m: ByteBuffer, slot: Int): Boolean { + val beat = m.getLong(slotBase(slot) + S_DIRECT_MS) + return beat != 0L && android.os.SystemClock.uptimeMillis() - beat < 1500 + } + + /** + * Is the Steam client running in this session? Its side of the hook-up writes a heartbeat into the shared file + * every 0.1 s. (No hook-up file this session: treated as running, so nothing waits for it.) + */ + @JvmStatic fun steamRunning(): Boolean { + val m = map ?: return true + val beat = m.getLong(H_STEAM_MS) + return beat != 0L && android.os.SystemClock.uptimeMillis() - beat < STEAM_GONE_MS + } + + @JvmStatic fun anyPresent(): Boolean = present.any { it } + @JvmStatic fun isPresent(slot: Int): Boolean = slot in 0 until SLOTS && present[slot] + + /** True while Steam is handed game rumble (a real controller it drives itself is present). */ + @JvmStatic fun ffToSteam(): Boolean = map != null && (anyPresent() || tritonPassthrough) + + private fun updateFfToSteam() { + val m = map ?: return + synchronized(this) { + FakeInputWriter.storeFence() + m.putInt(H_FF_TO_STEAM, if (anyPresent() || tritonPassthrough) 1 else 0) + } + } + + private fun putString(m: ByteBuffer, at: Int, max: Int, s: String) { + val b = s.toByteArray(Charsets.UTF_8) + val n = minOf(b.size, max - 1) + for (i in 0 until max) m.put(at + i, if (i < n) b[i] else 0) + } + + // ---------------- bridge messages (DualSenseBridge's worker thread) ---------------- + + @Synchronized + fun add(id: Bundle) { + val m = map ?: run { + note("hookup: the bridge handed over a controller but this session has no hook-up file " + + "(DroidDeck build without it, or the session started without the deck pad)") + return + } + val slot = id.getInt("slot", -1) + if (slot !in 0 until SLOTS) { note("hookup: the bridge handed over slot $slot, which does not exist"); return } + val base = slotBase(slot) + val desc = id.getByteArray("desc") ?: ByteArray(0) + if (present[slot] && (m.getShort(base + S_VENDOR).toInt() and 0xffff) == id.getInt("vid") && + (m.getShort(base + S_PRODUCT).toInt() and 0xffff) == id.getInt("pid") && m.getInt(base + S_IFACE) == id.getInt("iface")) { + return // the same controller resent after a reconnect: keep it as it is (no unplug/replug in Steam) + } + if (present[slot]) remove(slot) + describeSysfs(slot, id, desc) + m.putShort(base + S_VENDOR, id.getInt("vid").toShort()) + m.putShort(base + S_PRODUCT, id.getInt("pid").toShort()) + m.putShort(base + S_VERSION, id.getInt("version").toShort()) + m.putShort(base + S_BUS, id.getInt("bus", 3).toShort()) + m.putInt(base + S_IFACE, id.getInt("iface")) + putString(m, base + S_NAME, 128, id.getString("name") ?: "") + putString(m, base + S_SERIAL, 64, id.getString("serial") ?: "") + putString(m, base + S_MANUFACTURER, 64, id.getString("manufacturer") ?: "") + putString(m, base + S_PRODUCT_NAME, 64, id.getString("product") ?: "") + val n = minOf(desc.size, DESC_MAX) + for (i in 0 until n) m.put(base + S_DESC + i, desc[i]) + m.putInt(base + S_DESC_LEN, n) + // fresh FIFOs: the readers start at the current write position, so only the counters matter + m.putLong(base + S_OUT_READ, m.getLong(base + S_OUT_WRITE)) + getAsked[slot] = m.getLong(base + S_GET_REQUEST) + m.putLong(base + S_GET_REPLY, getAsked[slot]) + m.putInt(base + S_GENERATION, m.getInt(base + S_GENERATION) + 1) + FakeInputWriter.storeFence() + m.putInt(base + S_PRESENT, 1) + present[slot] = true + updateFfToSteam() + note("hookup: /dev/hidraw${MINOR_BASE + slot} = %04x:%04x \"%s\" interface %d, descriptor %d bytes (Steam Native Hook-up)" + .format(id.getInt("vid"), id.getInt("pid"), id.getString("name"), id.getInt("iface"), desc.size)) + } + + @Synchronized + fun input(slot: Int, data: ByteArray) { + val m = map ?: return + if (slot !in 0 until SLOTS || !present[slot] || data.isEmpty()) return + if (bridgeServes(m, slot)) return // a late message from before the fast path took over + val base = slotBase(slot) + val idx = m.getLong(base + S_IN_WRITE) + val at = base + S_IN + ((idx % IN_FIFO).toInt()) * REPORT_SIZE + m.putLong(at, 0L) + FakeInputWriter.storeFence() + val n = minOf(data.size, REPORT_MAX) + m.putInt(at + 8, n) + m.putInt(at + 12, 0) + for (i in 0 until n) m.put(at + 16 + i, data[i]) + FakeInputWriter.storeFence() + m.putLong(at, idx + 1) + m.putLong(base + S_IN_WRITE, idx + 1) + } + + @Synchronized + fun remove(slot: Int) { + val m = map ?: return + if (slot !in 0 until SLOTS) return + m.putInt(slotBase(slot) + S_PRESENT, 0) + m.putLong(slotBase(slot) + S_DIRECT_MS, 0L) + FakeInputWriter.storeFence() + if (present[slot]) note("hookup: /dev/hidraw${MINOR_BASE + slot} unplugged") + present[slot] = false + unlinkClass(slot) + updateFfToSteam() + } + + @Synchronized + fun removeAll() { for (s in 0 until SLOTS) if (present[s]) remove(s) } + + @Synchronized + fun reply(slot: Int, request: Long, data: ByteArray?) { + val m = map ?: return + if (slot !in 0 until SLOTS || request != getAsked[slot]) return + val base = slotBase(slot) + val n = if (data == null) 0 else minOf(data.size, REPORT_MAX) + for (i in 0 until n) m.put(base + S_GET + i, data!![i]) + m.putInt(base + S_GET_LENGTH, n) + FakeInputWriter.storeFence() + m.putLong(base + S_GET_REPLY, request) + } + + /** + * Steam -> controller. Calls [out](slot, kind, report) for each queued output report / feature write and + * [get](slot, request, reportId, input, length) for a new feature/input read. Run every ~1 ms. + */ + @Synchronized + fun poll(out: (Int, Int, ByteArray) -> Unit, get: (Int, Long, Int, Boolean, Int) -> Unit) { + val m = map ?: return + for (slot in 0 until SLOTS) { + if (!present[slot]) continue + FakeInputWriter.storeFence() + if (bridgeServes(m, slot)) continue // the bridge takes Steam's reports from the file itself + val base = slotBase(slot) + var read = m.getLong(base + S_OUT_READ) + val written = m.getLong(base + S_OUT_WRITE) + while (read < written) { + val at = base + S_OUT + ((read % OUT_FIFO).toInt()) * REPORT_SIZE + if (m.getLong(at) != read + 1) break // still being written + val n = m.getInt(at + 8).coerceIn(0, REPORT_MAX) + val kind = m.getInt(at + 12) + val data = ByteArray(n) + for (i in 0 until n) data[i] = m.get(at + 16 + i) + read++ + if (n > 0) out(slot, kind, data) + } + m.putLong(base + S_OUT_READ, read) + val request = m.getLong(base + S_GET_REQUEST) + if (request != getAsked[slot] && request != m.getLong(base + S_GET_REPLY)) { + getAsked[slot] = request + val idWord = m.getInt(base + S_GET_REPORT_ID) + get(slot, request, idWord and 0xff, (idWord and 0x100) != 0, m.getInt(base + S_GET_LENGTH)) + } + } + } + + // ---------------- sysfs, as Linux would describe the device ---------------- + + private fun guestHid(slot: Int, id: Bundle): Triple { + val bus = 10 + slot + val usb = "/sys/devices/droiddeck/usb$bus" + val iface = "$usb/$bus-1:1.${maxOf(0, id.getInt("iface"))}" + val hid = "$iface/0003:%04X:%04X.%04X".format(id.getInt("vid"), id.getInt("pid"), 0x10 + slot) + return Triple(usb, iface, hid) + } + + private fun describeSysfs(slot: Int, id: Bundle, desc: ByteArray) { + val devices = com.droiddeck.launcher.session.SteamDeckPad.sysDevicesDir + val classDir = com.droiddeck.launcher.session.SteamDeckPad.sysHidrawClassDir + val context = com.droiddeck.launcher.session.SteamDeckPad.sysContext + if (devices == null || classDir == null || context == null) { + note("hookup: no session sysfs to describe /dev/hidraw${MINOR_BASE + slot} in (deck pad off?)") + return + } + try { + val (usb, iface, hid) = guestHid(slot, id) + fun dir(guest: String) = File(devices, guest.removePrefix("/sys/devices/droiddeck/")).apply { mkdirs() } + fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text) + fun link(file: File, target: String) { + file.parentFile?.mkdirs() + file.delete() + Os.symlink(target, file.path) + } + deleteTree(File(devices, usb.removePrefix("/sys/devices/droiddeck/"))) // the previous description + val vid = id.getInt("vid") + val pid = id.getInt("pid") + val ver = id.getInt("version") + val vidHex = "%04x".format(vid) + val pidHex = "%04x".format(pid) + val product = "%x/%x/%x".format(vid, pid, ver) + val node = "hidraw${MINOR_BASE + slot}" + val minor = MINOR_BASE + slot + val busNum = "%03d".format(10 + slot) + write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=$product\nTYPE=0/0/0\nBUSNUM=$busNum\nDEVNUM=002\n") + write(usb, "idVendor", "$vidHex\n") + write(usb, "idProduct", "$pidHex\n") + write(usb, "bcdDevice", "%04x\n".format(ver)) + write(usb, "manufacturer", "${id.getString("manufacturer") ?: ""}\n") + write(usb, "product", "${id.getString("product") ?: ""}\n") + write(usb, "serial", "${id.getString("serial") ?: ""}\n") + write(usb, "busnum", "${10 + slot}\n") + write(usb, "devnum", "2\n") + link(File(dir(usb), "subsystem"), "/sys/bus/usb") + val cls = id.getInt("ifclass", 3) + write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=$product\nINTERFACE=$cls/${id.getInt("ifsub")}/${id.getInt("ifproto")}\n") + write(iface, "bInterfaceNumber", "%02x\n".format(maxOf(0, id.getInt("iface")))) + write(iface, "bInterfaceClass", "%02x\n".format(cls)) + write(iface, "bInterfaceSubClass", "%02x\n".format(id.getInt("ifsub"))) + write(iface, "bInterfaceProtocol", "%02x\n".format(id.getInt("ifproto"))) + link(File(dir(iface), "subsystem"), "/sys/bus/usb") + val name = id.getString("name") ?: "" + val serial = id.getString("serial") ?: "" + write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:%08X:%08X\nHID_NAME=$name\n".format(vid, pid) + + "HID_PHYS=usb-droiddeck-${10 + slot}/input${maxOf(0, id.getInt("iface"))}\nHID_UNIQ=$serial\n" + + "MODALIAS=hid:b0003g0001v%08Xp%08X\n".format(vid, pid)) + File(dir(hid), "report_descriptor").writeBytes(desc) + link(File(dir(hid), "subsystem"), "/sys/bus/hid") + 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(classDir, node), hidraw) + link(File(LinuxRuntime.liveDir(context), "drm/sys/$MAJOR:$minor"), hidraw) + val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() } + File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") + } catch (e: Exception) { + note("hookup: could not describe /dev/hidraw${MINOR_BASE + slot}: $e") + } + } + + /** + * Deletes a folder without following symbolic links. (File.deleteRecursively() follows them: the description + * links "subsystem" to the phone's real /sys/bus/..., and walking into the real /sys -- full of links back into + * itself -- never ends, which froze DroidDeck's bridge thread on the second hand-over of a controller.) + */ + private fun deleteTree(f: File) { + val st = try { Os.lstat(f.path) } catch (_: Exception) { return } // gone already + if (android.system.OsConstants.S_ISDIR(st.st_mode)) f.list()?.forEach { deleteTree(File(f, it)) } + f.delete() + } + + private fun unlinkClass(slot: Int) { + val classDir = com.droiddeck.launcher.session.SteamDeckPad.sysHidrawClassDir ?: return + try { File(classDir, "hidraw${MINOR_BASE + slot}").delete() } catch (_: Exception) {} + } + + /** True when DroidDeck should set the hook-up file up this session (the bridge app is installed). */ + @JvmStatic fun wanted(context: Context): Boolean = DualSenseBridge.isBridgeInstalled(context) +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index cfb0978cb..656e44cf1 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -51,6 +51,7 @@ public final class PadBridge { private volatile java.util.function.IntPredicate excluded = id -> false; private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS]; private final PadState[] playerStates = new PadState[SLOTS]; + private final boolean[] bridgeSlots = new boolean[SLOTS]; // slots fed by the SDL3 bridge app private final PadState effectiveState = new PadState(); private final Handler mainHandler = new Handler(Looper.getMainLooper()); private boolean open; @@ -200,6 +201,7 @@ public final class PadBridge { } public static boolean isControllerKeyEvent(KeyEvent event) { + if (DualSenseBridge.isBridged(event)) return false; InputDevice device = event.getDevice(); if (device == null || device.isVirtual() || isSystemVirtualInputName(device.getName())) return false; if (KeyEvent.isGamepadButton(event.getKeyCode())) { @@ -224,6 +226,7 @@ public final class PadBridge { } public static boolean isControllerMotionEvent(MotionEvent event) { + if (DualSenseBridge.isBridged(event)) return false; InputDevice device = event.getDevice(); return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual() && !isSystemVirtualInputName(device.getName()) @@ -446,6 +449,39 @@ public final class PadBridge { publish(); } + /** SDL3 bridge: reserves a free player slot (1..3) for an extra controller. @return the slot, or -1 if none is free. */ + public synchronized int claimBridgeSlot() { + for (int slot = 1; slot < SLOTS; slot++) { + if (owners[slot] != NO_CONTROLLER || bridgeSlots[slot]) continue; + bridgeSlots[slot] = true; + playerStates[slot].clear(); + Log.i(TAG, "SDL3 bridge controller is player " + (slot + 1)); + return slot; + } + return -1; + } + + /** SDL3 bridge: frees a slot taken with {@link #claimBridgeSlot()}. */ + public synchronized void releaseBridgeSlot(int slot) { + if (slot < 1 || slot >= SLOTS || !bridgeSlots[slot]) return; + bridgeSlots[slot] = false; + playerStates[slot].clear(); + if (playerWriters[slot] != null) { + playerWriters[slot].destroy(); + playerWriters[slot] = null; + } + Log.i(TAG, "SDL3 bridge player " + (slot + 1) + " left"); + } + + /** SDL3 bridge: updates player [slot] (0 = primary pad, 1..3 = claimed bridge slots). */ + public synchronized void applyExternalSlot(int slot, java.util.function.Consumer mutation, boolean changed) { + if (slot <= 0) { applyExternal(mutation, changed); return; } + if (slot >= SLOTS || !bridgeSlots[slot]) return; + mutation.accept(playerStates[slot]); + if (changed) notePlayerInput(); + publish(slot); + } + public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) { if (!builtinPad) return; if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return; @@ -561,7 +597,7 @@ public final class PadBridge { return slot; } for (int slot = firstPlayerSlot(); slot < SLOTS; slot++) { - if (owners[slot] != NO_CONTROLLER) continue; + if (owners[slot] != NO_CONTROLLER || bridgeSlots[slot]) continue; owners[slot] = id; Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1)); return slot; diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt index bc640507a..c311b380d 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt @@ -62,7 +62,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) : val wanted = FakeInputWriter.readImuMode(SLOT) != 0 || touch != null && touch.plugged && touch.gyroRequested && touch.motionEnabled idlePolls = if (wanted) 0 else idlePolls + 1 - if (wanted && !reading) { + if (wanted && !DualSenseBridge.isActive() && !reading) { reading = true turnAt = 0L gyroscope?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) } @@ -83,6 +83,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) : } override fun onSensorChanged(event: SensorEvent) { + if (DualSenseBridge.isActive()) return if (!active) return val values = event.values if (event.timestamp - turnAt > ROTATION_REFRESH_NS) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt index f2b1c0a1e..8cc7ca3b8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt @@ -90,6 +90,7 @@ class RumbleComponent : SessionPart() { @Synchronized private fun buzz(strong: Int, weak: Int, ms: Int, slot: Int) { if (!enabled) return + if (com.droiddeck.launcher.input.DualSenseBridge.routeRumble(slot, strong, weak, ms)) return val target = target(slot) val previous = playing[slot] if (target !== previous) { 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 c49e3f7ac..26da9446d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -815,14 +815,21 @@ class SessionService : Service() { guest.add("FAKE_EVDEV_IDENTITY=xbox360") guest.add("FAKE_EVDEV_VIBRATION=1") val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists() - val wantsDeck = uinput && builtinPad && SessionState.mode == MODE_STEAM && - SessionState.steamUi != "desktop" && + // sdl3bridge: the desktop (and Steam's desktop UI) get the deck pad too while the SDL3 bridge is installed, + // so its "present as" types and Steam Native Hook-up work there like in the Steam session + val sdl3Desktop = (SessionState.mode == MODE_DESKTOP || + (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop")) && + com.droiddeck.launcher.input.HookupBridge.wanted(this) + val wantsDeck = uinput && builtinPad && (SessionState.mode == MODE_STEAM && + SessionState.steamUi != "desktop" || sdl3Desktop) && !File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists() - val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists() - val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this) + val wantsTouch = wantsDeck && !sdl3Desktop && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists() + val wantsTriton = wantsDeck && (SteamControllerBle.hasPairedController(this) || com.droiddeck.launcher.input.DualSenseBridge.isBridgeInstalled(this)) deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton, SessionPrefs.padType(SessionPrefs.steamController(this))) else emptyList() SessionState.deckPad = deckBinds.isNotEmpty() + if (sdl3Desktop && SessionState.deckPad) Log.i(TAG, "deck pad: on for the SDL3 bridge on the desktop " + + "(Steam gets the controller as the bridge presents it; other programs keep Xbox 360 pads)") if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller") logControllersAtStart() com.droiddeck.launcher.input.FakeInputWriter.setLowLatency(SessionPrefs.lowLatencyController(this), SessionPrefs.lowLatencyTouch(this)) @@ -830,6 +837,7 @@ class SessionService : Service() { guest.add("FAKE_EVDEV_UINPUT=1") if (SessionState.deckPad) { guest.add("FAKE_EVDEV_DECK=1") + if (SessionState.mode == MODE_DESKTOP) guest.add("FAKE_EVDEV_DESKTOP=1") // sdl3bridge if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1") val padType = SessionPrefs.steamController(this) guest.add("FAKE_EVDEV_PAD_TYPE=$padType") @@ -838,6 +846,8 @@ class SessionService : Service() { if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let { guest.add("FAKE_TOUCHCTL_RING=" + it.path) } + if (com.droiddeck.launcher.input.HookupBridge.wanted(this)) + com.droiddeck.launcher.input.HookupBridge.prepare(fakeInputDir, File(sessionDir, "pad.log"))?.let { guest.add("FAKE_HOOKUP_SHM=" + it.path) } guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path) } } else if (SessionState.mode == MODE_STEAM) { @@ -849,7 +859,7 @@ class SessionService : Service() { guest.add("FAKE_EVDEV_LOG=1") guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path) inputSysfs = InputSysfsComponent(fakeInputDir, File(fakeInputDir.parentFile!!.parentFile!!, "sys/input"), - File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad, !uinput && SessionState.mode == MODE_STEAM) + File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad && SessionState.mode != MODE_DESKTOP, !uinput && SessionState.mode == MODE_STEAM) .takeIf { it.prepare() } SessionState.fakeInputDir = fakeInputDir } @@ -1761,6 +1771,7 @@ class SessionService : Service() { override fun onDestroy() { stopSession(0) GameThreadHints.stop() // also when the session never got as far as running + com.droiddeck.launcher.input.DualSenseBridge.detach() if (screenReceiverRegistered) { unregisterReceiver(screenReceiver) screenReceiverRegistered = false 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 db9884dd5..39e5c025a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -27,6 +27,8 @@ object SteamDeckPad { private const val TRITON_WIRED_HID = "$TRITON_INTERFACE/0003:28DE:1302.0004" private const val SONY_DS4_MINOR = 20 private const val SONY_DUALSENSE_MINOR = 21 + private const val SONY_DUALSENSE_EDGE_MINOR = 22 + private const val SC2015_MINOR = 23 private val DS4_REPORT_DESCRIPTOR = intArrayOf( 0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x05, 0x09, 0x21, 0x95, 0x1f, 0x91, 0x02, @@ -82,6 +84,10 @@ object SteamDeckPad { fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing") + // sdl3bridge: where the session sysfs lives, for HookupBridge (Steam Native Hook-up) hot-plug + @Volatile @JvmStatic var sysDevicesDir: File? = null + @Volatile @JvmStatic var sysHidrawClassDir: File? = null + @Volatile @JvmStatic var sysContext: Context? = null private val padNodes = mutableMapOf() @Volatile private var hidrawClassDir: File? = null @@ -93,6 +99,8 @@ object SteamDeckPad { com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM -> TRITON_PAD_MINOR com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4 -> SONY_DS4_MINOR com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE -> SONY_DUALSENSE_MINOR + com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE_EDGE -> SONY_DUALSENSE_EDGE_MINOR + com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM_2015 -> SC2015_MINOR else -> -1 } for ((minor, target) in padNodes) { @@ -224,12 +232,8 @@ object SteamDeckPad { 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)") } - fun describeSony(dualSense: Boolean) { - val bus = if (dualSense) 5 else 4 - val product = if (dualSense) "0ce6" else "09cc" - val minor = if (dualSense) SONY_DUALSENSE_MINOR else SONY_DS4_MINOR + fun describeSony(bus: Int, product: String, minor: Int, name: String, sonyDescriptor: ByteArray) { val node = "hidraw$minor" - val name = if (dualSense) "DualSense Wireless Controller" else "Wireless Controller" val usb = "$GUEST_DEVICES/usb$bus" val iface = "$usb/$bus-1:1.3" val hid = "$iface/0003:054C:${product.uppercase()}.000${bus + 1}" @@ -247,7 +251,7 @@ object SteamDeckPad { write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:0000054C:0000${product.uppercase()}\n" + "HID_NAME=Sony Interactive Entertainment $name\nHID_PHYS=usb-droiddeck-$bus/input3\n" + "HID_UNIQ=5e:43:4b:44:44:02\nMODALIAS=hid:b0003g0001v0000054Cp0000${product.uppercase()}\n") - File(dir(hid), "report_descriptor").writeBytes(if (dualSense) DUALSENSE_REPORT_DESCRIPTOR else DS4_REPORT_DESCRIPTOR) + File(dir(hid), "report_descriptor").writeBytes(sonyDescriptor) link(File(dir(hid), "subsystem"), "/sys/bus/hid") val hidraw = "$hid/hidraw/$node" write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n") @@ -259,11 +263,48 @@ object SteamDeckPad { File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") Log.i(TAG, "playstation pad: /dev/$node described as 054c:$product") } - describeSony(dualSense = false) - describeSony(dualSense = true) + describeSony(4, "09cc", SONY_DS4_MINOR, "Wireless Controller", DS4_REPORT_DESCRIPTOR) + describeSony(5, "0ce6", SONY_DUALSENSE_MINOR, "DualSense Wireless Controller", DUALSENSE_REPORT_DESCRIPTOR) + describeSony(6, "0df2", SONY_DUALSENSE_EDGE_MINOR, "DualSense Edge Wireless Controller", DUALSENSE_REPORT_DESCRIPTOR) + fun describeSteamController2015() { + val usb = "$GUEST_DEVICES/usb7" + val iface = "$usb/7-1:1.2" + val hid = "$iface/0003:28DE:1102.0008" + val node = "hidraw$SC2015_MINOR" + write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1102/100\nTYPE=0/0/0\nBUSNUM=007\nDEVNUM=002\n") + write(usb, "idVendor", "28de\n") + write(usb, "idProduct", "1102\n") + write(usb, "bcdDevice", "0100\n") + write(usb, "manufacturer", "Valve Software\n") + write(usb, "product", "Steam Controller\n") + write(usb, "serial", "DROIDDECKSC15\n") + link(File(dir(usb), "subsystem"), "/sys/bus/usb") + write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1102/100\nINTERFACE=3/0/0\n") + write(iface, "bInterfaceNumber", "02\n") + write(iface, "bInterfaceClass", "03\n") + link(File(dir(iface), "subsystem"), "/sys/bus/usb") + write(hid, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001102\n" + + "HID_NAME=Valve Software Steam Controller\nHID_PHYS=usb-droiddeck-7/input2\n" + + "HID_UNIQ=DROIDDECKSC15\nMODALIAS=hid:b0003g0001v000028DEp00001102\n") + File(dir(hid), "report_descriptor").writeBytes(REPORT_DESCRIPTOR) + link(File(dir(hid), "subsystem"), "/sys/bus/hid") + val hidraw = "$hid/hidraw/$node" + write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$SC2015_MINOR\nDEVNAME=$node\n") + write(hidraw, "dev", "$MAJOR:$SC2015_MINOR\n") + link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw") + link(File(dir(hidraw), "device"), hid) + padNodes[SC2015_MINOR] = hidraw + link(File(LinuxRuntime.liveDir(context), "drm/sys/$MAJOR:$SC2015_MINOR"), hidraw) + File(udevData, "c$MAJOR:$SC2015_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") + Log.i(TAG, "steam controller: /dev/$node described as the 2015 Steam Controller (28de:1102)") + } + describeSteamController2015() describeTriton(wired = true) if (triton) describeTriton(wired = false) hidrawClassDir = hidrawClass + sysDevicesDir = devices + sysHidrawClassDir = hidrawClass + sysContext = context.applicationContext showPad(padType) Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 8f92dbbc6..375e31456 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -181,7 +181,7 @@ internal fun SetupPanel( onDismiss = { editDownloadPrefix = false }, onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false }, ) - val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses)) + val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses), "SDL3Bridge") var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) } SetupTabs.openLauncher = false val chipFocus = remember { List(tabs.size) { FocusRequester() } } @@ -460,6 +460,7 @@ internal fun SetupPanel( } } GLASSES_TAB -> GlassesSetupPanel() + GLASSES_TAB + 1 -> SdlBridgeSettingsTab(host) // sdl3bridge: SDL3Bridge tab } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt new file mode 100644 index 000000000..59381bee0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt @@ -0,0 +1,373 @@ +package com.droiddeck.launcher.ui + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +// sdl3bridge: SDL3Bridge settings tab +/** + * DroidDeck's link to the SDL3 bridge app's settings. A connection of its own (not DualSenseBridge's): it never + * registers for controller frames, it only asks for the settings state (MSG_SETTINGS_GET, once a second while + * the tab is open) and sends single changes (MSG_SETTINGS_SET); the bridge answers both with MSG_SETTINGS. + * The bridge sends the choices too (mode names, button names, ...), so nothing here has to match its lists. + */ +internal object SdlBridgeLink { + private const val ACTION = "com.example.dsedge.BRIDGE" + private const val SCREEN = "com.example.dsedge.BridgeActivity" + // the bridge built into this app (SDL3DroidDeck), else the separate SDL3Bridge app + private fun pkg(ctx: Context) = com.droiddeck.launcher.input.DualSenseBridge.bridgePackage(ctx) + private const val MSG_SETTINGS_GET = 40 + private const val MSG_SETTINGS_SET = 41 + private const val MSG_SETTINGS = 42 + + var snapshot by mutableStateOf(null) + private set + private var server: Messenger? = null + private var bound = false + private var users = 0 + + private val inbox = Messenger(object : Handler(Looper.getMainLooper()) { + override fun handleMessage(m: Message) { + if (m.what == MSG_SETTINGS) snapshot = Bundle(m.data) + } + }) + + private val conn = object : ServiceConnection { + override fun onServiceConnected(n: ComponentName, b: IBinder) { + server = Messenger(b) + refresh() + } + override fun onServiceDisconnected(n: ComponentName) { server = null } + } + + fun installed(ctx: Context): Boolean = try { ctx.packageManager.getPackageInfo(pkg(ctx), 0); true } catch (_: Exception) { false } + + fun start(ctx: Context) { + users++ + if (bound) return + bound = try { + ctx.applicationContext.bindService(Intent(ACTION).setPackage(pkg(ctx)), conn, Context.BIND_AUTO_CREATE) + } catch (_: Exception) { false } + } + + fun stop(ctx: Context) { + users = maxOf(0, users - 1) + if (users > 0 || !bound) return + try { ctx.applicationContext.unbindService(conn) } catch (_: Exception) {} + bound = false + server = null + } + + fun connected(): Boolean = server != null + + fun refresh() = send(Message.obtain(null, MSG_SETTINGS_GET)) + + /** One change. Shown at once; the bridge's answer then confirms it. */ + fun set(key: String, p: Int = 0, i: Int = 0, int: Int? = null, bool: Boolean? = null) { + snapshot?.let { old -> snapshot = Bundle(old).also { applyLocally(it, key, p, i, int, bool) } } + send(Message.obtain(null, MSG_SETTINGS_SET).apply { + data = Bundle().apply { + putString("key", key); putInt("p", p); putInt("i", i) + if (int != null) putInt("int", int) + if (bool != null) putBoolean("bool", bool) + } + }) + } + + /** The bridge's own screen (controller test, USB access, ...): in this app when built in, else SDL3Bridge. */ + fun open(ctx: Context) { + val screen = Intent().setClassName(pkg(ctx), SCREEN).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { ctx.startActivity(screen) } catch (_: Exception) { + val launch = ctx.packageManager.getLaunchIntentForPackage(pkg(ctx)) ?: return + try { ctx.startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } catch (_: Exception) {} + } + } + + private fun send(m: Message) { + val s = server ?: return + m.replyTo = inbox + try { s.send(m) } catch (_: RemoteException) { server = null } + } + + private fun applyLocally(b: Bundle, key: String, p: Int, i: Int, int: Int?, bool: Boolean?) { + val remapCount = b.getStringArray("remapTargets")?.size ?: 8 + fun setInt(k: String, index: Int, v: Int) { + val a = b.getIntArray(k)?.clone() ?: return + if (index in a.indices) a[index] = v + b.putIntArray(k, a) + } + fun setBool(k: String, index: Int, v: Boolean) { + val a = b.getBooleanArray(k)?.clone() ?: return + if (index in a.indices) a[index] = v + b.putBooleanArray(k, a) + } + when (key) { + "presentAs", "pollMs" -> if (int != null) b.putInt(key, int) + "driverEnabled", "forceUsb", "bubble" -> if (bool != null) b.putBoolean(key, bool) + "gyroMap" -> if (int != null) setInt(key, p * 3 + i, int) + "remap" -> if (int != null) setInt(key, p * remapCount + i, int) + "calibrate" -> b.putInt("calibrating", p) + "resetCalibration" -> b.getFloatArray("gyroBias")?.clone()?.let { a -> + for (k in 0 until 3) if (p * 3 + k in a.indices) a[p * 3 + k] = 0f + b.putFloatArray("gyroBias", a) + } + else -> { + if (int != null) setInt(key, p, int) + if (bool != null) setBool(key, p, bool) + } + } + } +} + +/** + * The SDL3Bridge settings page: a tab of DroidDeck's Setup screen, and a page of the in-session drawer. + * [track] gives each control the drawer's focus tracking (controller navigation); Setup passes nothing. + */ +@Composable +internal fun SdlBridgeSettingsTab(host: MenuHost, track: (String) -> Modifier = { Modifier }) { + val ctx = LocalContext.current + val installed = remember { SdlBridgeLink.installed(ctx) } + if (!installed) { + SettingsGroup("SDL3Bridge") { + SettingsRow("SDL3Bridge is not installed", + "Install the SDL3Bridge app to use any SDL3-supported controller with DroidDeck: USB or Bluetooth, " + + "re-presented as a Steam Deck / Sony / Steam Controller pad, or handed to Steam as itself.") {} + } + return + } + var waited by remember { mutableIntStateOf(0) } + DisposableEffect(Unit) { + SdlBridgeLink.start(ctx) + onDispose { SdlBridgeLink.stop(ctx) } + } + LaunchedEffect(Unit) { + while (true) { + SdlBridgeLink.refresh() + delay(1000) + waited++ + } + } + val s = SdlBridgeLink.snapshot + + // ---------------- status ---------------- + SettingsGroup("SDL3Bridge") { + if (s == null) { + SettingsRow(if (SdlBridgeLink.connected()) "Waiting for SDL3Bridge…" else "Connecting to SDL3Bridge…", + if (waited >= 3) "No answer yet. Update SDL3Bridge (this tab needs a recent build), or open it once." else null) {} + } else { + SettingsRow("Status", s.getString("status") ?: "") {} + val engineError = s.getString("engineError") ?: "" + val engineMode = s.getString("engineMode") ?: "" + if (engineError.isNotEmpty()) SettingsRow("SDL3 is not running", engineError) {} + else if (engineMode.isNotEmpty() && engineMode != "background service") SettingsRow("SDL3 runs in", engineMode) {} + if (!s.getBoolean("notifications", true)) SettingsRow("Status bar icon is hidden", + "SDL3Bridge may not show notifications: open it once and tap Allow (or allow it in Android's app settings).") {} + val padNames = s.getStringArray("padNames") ?: emptyArray() + val padIds = s.getStringArray("padIds") ?: emptyArray() + val battery = s.getIntArray("padBattery") ?: IntArray(0) + val hookNames = s.getStringArray("hookNames") ?: emptyArray() + val hookIds = s.getStringArray("hookIds") ?: emptyArray() + val triton = s.getString("triton") ?: "" + var any = false + padNames.forEachIndexed { i, name -> + if (name.isEmpty()) return@forEachIndexed + any = true + val bat = battery.getOrElse(i) { -1 } + SettingsRow("Player ${i + 1}: $name", + listOfNotNull(padIds.getOrNull(i), if (bat >= 0) "battery $bat%" else null).joinToString(" · ")) {} + } + hookNames.forEachIndexed { i, name -> + if (name.isEmpty()) return@forEachIndexed + any = true + SettingsRow("Handed to Steam: $name", + listOfNotNull(hookIds.getOrNull(i), "/dev/hidraw${24 + i}", + if (s.getBoolean("fastPath")) "fast path" else null).joinToString(" · ")) {} + } + if (triton.isNotEmpty()) { + any = true + SettingsRow("Steam Controller (2026)", "$triton · raw passthrough") {} + } + if (!any) SettingsRow("No controller", "Plug a controller in with USB-C, or pair it over Bluetooth.") {} + } + ActionRow("Open SDL3Bridge", "Controller test screen, USB access, USB audio and everything else.", "Open", + onClick = { SdlBridgeLink.open(ctx) }, modifier = track("sdl3-open")) + } + if (s == null) return + + val ints = { k: String -> s.getIntArray(k) ?: IntArray(0) } + val bools = { k: String -> s.getBooleanArray(k) ?: BooleanArray(0) } + val names = { k: String -> s.getStringArray(k) ?: emptyArray() } + fun indexed(k: String) = names(k).mapIndexed { i, n -> i to n } + + // ---------------- global ---------------- + SettingsGroup("Controller mode") { + BridgeChoiceRow(host, "sdl3-present", "Present controller as", + "How Steam sees the controller, in the Steam session and on the desktop. Steam Native Hook-up hands USB " + + "controllers to Steam as themselves, like on a PC (on the desktop: while Steam runs; other programs " + + "there see an Xbox 360 controller).", + indexed("presentNames"), s.getInt("presentAs"), chipModifier = track("sdl3-present")) { SdlBridgeLink.set("presentAs", int = it) } + ToggleRow(host, "sdl3-driver", "SDL3 driver", + "On: SDL3 reads the controller (all its features). Off: the controller goes back to Android and DroidDeck.", + s.getBoolean("driverEnabled"), chipModifier = track("sdl3-driver")) { SdlBridgeLink.set("driverEnabled", bool = it) } + BridgeChoiceRow(host, "sdl3-source", "Controller source", "Which controllers SDL3 uses.", + listOf(false to "USB + Bluetooth (auto)", true to "USB only (forced)"), s.getBoolean("forceUsb"), chipModifier = track("sdl3-source")) { + SdlBridgeLink.set("forceUsb", bool = it) + } + val rateMs = ints("rateMs") + BridgeChoiceRow(host, "sdl3-rate", "Poll rate", "Higher = lower gyro and trackpad latency, a little more battery. In Steam Native Hook-up it is also how often Steam gets the newest report (lower if games lag).", + names("rateNames").mapIndexed { i, n -> rateMs.getOrElse(i) { 2 } to n }, s.getInt("pollMs"), chipModifier = track("sdl3-rate")) { + SdlBridgeLink.set("pollMs", int = it) + } + val overlay = s.getBoolean("overlayAllowed") + ToggleRow(host, "sdl3-bubble", "Floating bubble", + if (overlay) "Tap: SDL3 driver on/off. Double tap: Steam Native Hook-up. Hold 3 s: open SDL3Bridge." + else "Needs SDL3Bridge's \"Draw over other apps\" permission: turn the bubble on once in SDL3Bridge.", + s.getBoolean("bubble"), enabled = overlay || s.getBoolean("bubble"), chipModifier = track("sdl3-bubble")) { SdlBridgeLink.set("bubble", bool = it) } + } + + // ---------------- per player ---------------- + var player by rememberSaveable { mutableIntStateOf(0) } + val p = player + Box(Modifier.fillMaxWidth().padding(top = 16.dp)) { + SegmentedTabs((0 until 4).map { it to "P${it + 1}" }, player, tabModifier = { track("sdl3-player-$it") }) { player = it } + } + + SettingsGroup("Gyro · player ${p + 1}") { + SliderRow("Gyro speed", "100 % = 1:1", ints("gyroGain").getOrElse(p) { 100 }, 5..800, step = 5, + format = { "$it %" }, modifier = track("sdl3-gyrospeed-$p")) { SdlBridgeLink.set("gyroGain", p, int = it) } + BridgeChoiceRow(host, "sdl3-gyroinv-$p", "Invert gyro axes", null, indexed("gyroInvNames"), + ints("gyroInvert").getOrElse(p) { 0 }, chipModifier = track("sdl3-gyroinv-$p")) { SdlBridgeLink.set("gyroInvert", p, int = it) } + for (i in 0 until 3) { + BridgeChoiceRow(host, "sdl3-gyromap-$p-$i", "DroidDeck gyro ${"XYZ"[i]} comes from", null, indexed("axisNames"), + ints("gyroMap").getOrElse(p * 3 + i) { i }, chipModifier = track("sdl3-gyromap-$p-$i")) { SdlBridgeLink.set("gyroMap", p, i, int = it) } + } + ToggleRow(host, "sdl3-accel-$p", "Accelerometer follows the axis remap", + "Keeps gravity consistent with rotation.", bools("accelFollows").getOrElse(p) { true }, chipModifier = track("sdl3-accel-$p")) { + SdlBridgeLink.set("accelFollows", p, bool = it) + } + SliderRow("Gyro deadzone", "Motion slower than this is ignored, so the view doesn't drift while you hold still.", + ints("gyroDeadzone").getOrElse(p) { 0 }, 0..50, format = { "$it °/s" }, modifier = track("sdl3-gyrodeadzone-$p")) { SdlBridgeLink.set("gyroDeadzone", p, int = it) } + val bias = s.getFloatArray("gyroBias") ?: FloatArray(0) + val calibrating = s.getInt("calibrating", -1) == p + val b = (0 until 3).map { bias.getOrElse(p * 3 + it) { 0f } } + ActionRow("Calibrate gyro", + when { + calibrating -> "Hold player ${p + 1}'s controller flat and still…" + b.all { it == 0f } -> "Lay the controller flat and still, then tap. Not calibrated yet." + else -> "Drift removed: x %.2f, y %.2f, z %.2f °/s".format(b[0], b[1], b[2]) + }, "Calibrate", onClick = { SdlBridgeLink.set("calibrate", p) }, + modifier = track("sdl3-calibrate-$p")) + ActionRow("Reset calibration", null, "Reset", onClick = { SdlBridgeLink.set("resetCalibration", p) }, + modifier = track("sdl3-uncalibrate-$p")) + } + + SettingsGroup("Touchpad · player ${p + 1}") { + BridgeChoiceRow(host, "sdl3-touch-$p", "Layout", "How the controller's touchpad maps to the Deck's two trackpads.", + indexed("touchNames"), ints("touchMode").getOrElse(p) { 0 }, chipModifier = track("sdl3-touch-$p")) { + SdlBridgeLink.set("touchMode", p, int = it) + } + ToggleRow(host, "sdl3-tinvx-$p", "Invert X", null, bools("touchInvX").getOrElse(p) { false }, chipModifier = track("sdl3-tinvx-$p")) { SdlBridgeLink.set("touchInvX", p, bool = it) } + ToggleRow(host, "sdl3-tinvy-$p", "Invert Y", null, bools("touchInvY").getOrElse(p) { false }, chipModifier = track("sdl3-tinvy-$p")) { SdlBridgeLink.set("touchInvY", p, bool = it) } + ToggleRow(host, "sdl3-tswap-$p", "Swap X and Y", "Applied before inverting.", bools("touchSwap").getOrElse(p) { false }, chipModifier = track("sdl3-tswap-$p")) { + SdlBridgeLink.set("touchSwap", p, bool = it) + } + } + + SettingsGroup("Button remap · player ${p + 1}") { + val targets = names("remapTargets") + val remap = ints("remap") + val sources = indexed("srcNames") + targets.forEachIndexed { i, target -> + BridgeChoiceRow(host, "sdl3-remap-$p-$i", target, "Pressed by", sources, remap.getOrElse(p * targets.size + i) { 0 }, chipModifier = track("sdl3-remap-$p-$i")) { + SdlBridgeLink.set("remap", p, i, int = it) + } + } + } + + SettingsGroup("Lightbar · player ${p + 1}") { + SliderRow("Red", null, ints("lightR").getOrElse(p) { 0 }, 0..255, modifier = track("sdl3-red-$p")) { SdlBridgeLink.set("lightR", p, int = it) } + SliderRow("Green", null, ints("lightG").getOrElse(p) { 0 }, 0..255, modifier = track("sdl3-green-$p")) { SdlBridgeLink.set("lightG", p, int = it) } + SliderRow("Blue", null, ints("lightB").getOrElse(p) { 255 }, 0..255, modifier = track("sdl3-blue-$p")) { SdlBridgeLink.set("lightB", p, int = it) } + SliderRow("Brightness", null, ints("brightness").getOrElse(p) { 100 }, 0..100, format = { "$it %" }, modifier = track("sdl3-brightness-$p")) { + SdlBridgeLink.set("brightness", p, int = it) + } + BridgeChoiceRow(host, "sdl3-pled-$p", "Player LEDs", null, indexed("playerLedNames"), ints("playerLed").getOrElse(p) { 0 }, chipModifier = track("sdl3-pled-$p")) { + SdlBridgeLink.set("playerLed", p, int = it) + } + } +} + +/** "Steam Native Hook-up (Steam sees the real controller)" -> "Steam Native Hook-up" + "Steam sees the real controller"; + * "Split (decoupled): left half = ..." -> "Split (decoupled)" + "left half = ...". */ +private fun splitName(name: String): Pair { + val colon = name.indexOf(": ") + if (colon > 0) return name.substring(0, colon) to name.substring(colon + 2) + val paren = name.indexOf(" (") + if (paren > 0 && name.endsWith(")")) return name.substring(0, paren) to name.substring(paren + 2, name.length - 1) + return name to null +} + +/** + * DroidDeck's ChoiceRow, for the bridge's (long) names. The chip shows the short name and the menu the short name + * with the rest under it. When the chip would squeeze the label -- the session drawer is narrow, and a long chip + * left the label no width at all, so the rows looked empty -- the chip goes under the label instead of beside it. + */ +@Composable +private fun BridgeChoiceRow( + host: MenuHost, key: String, label: String, hint: String?, + options: List>, selected: T, chipModifier: Modifier = Modifier, + onPick: (T) -> Unit, +) { + val open = host.open == key + val parts = remember(options) { options.map { (value, name) -> splitName(name).let { Triple(value, it.first, it.second) } } } + val chip = parts.firstOrNull { it.first == selected }?.second ?: "-" + BoxWithConstraints(Modifier.fillMaxWidth()) { + // ~7 dp per character of the chip's 13 sp text, plus its padding and caret; beside the label only while + // the label keeps at least half of the row + val chipWidth = (parts.maxOfOrNull { it.second.length } ?: 0) * 7f + 40f + val stacked = chipWidth > maxWidth.value * 0.5f + Column(Modifier.fillMaxWidth()) { + SettingsRow(label, hint, highlighted = open, stacked = stacked) { + Box { + ValueChip(chip, open, modifier = chipModifier.testTag("setting-$key")) { host.open = if (open) null else key } + AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label) { firstItemFocus -> + parts.forEachIndexed { index, (value, text, detail) -> + MenuItem(text, checked = value == selected, detail = detail, + focusRequester = if (index == 0) firstItemFocus else null) { + onPick(value) + host.open = null + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt index fb123fbd8..d247fa8ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt @@ -8,6 +8,7 @@ internal enum class SessionDrawerPage(val firstControl: String) { EFFECTS("look"), VR("steamvr"), GAMES("anisotropy"), + SDL3BRIDGE("sdl3-open"), // sdl3bridge: SDL3Bridge page SESSION("suspend"), } 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 ef90316ea..17360192a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -42,6 +42,7 @@ import androidx.compose.material.icons.outlined.PictureInPictureAlt import androidx.compose.material.icons.automirrored.outlined.ExitToApp import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Usb import androidx.compose.material3.Icon import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults @@ -157,6 +158,7 @@ private val SessionDrawerPage.title: Int get() = when (this) { SessionDrawerPage.VR -> R.string.vr_title SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects SessionDrawerPage.GAMES -> R.string.game_settings_title + SessionDrawerPage.SDL3BRIDGE -> R.string.drawer_page_sdl3bridge SessionDrawerPage.SESSION -> R.string.mode_session } @@ -168,6 +170,7 @@ private val SessionDrawerPage.icon get() = when (this) { SessionDrawerPage.VR -> VrHeadsetIcon SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh SessionDrawerPage.GAMES -> Icons.Outlined.Layers + SessionDrawerPage.SDL3BRIDGE -> Icons.Outlined.Usb SessionDrawerPage.SESSION -> Icons.Outlined.Settings } @@ -642,6 +645,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont track = { key -> focus.track(page, key) }, ) } + SessionDrawerPage.SDL3BRIDGE -> SdlBridgeSettingsTab(host) { key -> focus.track(page, key) } SessionDrawerPage.SESSION -> { if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) { SettingsRow(stringResource(R.string.drawer_launch_app), null) { diff --git a/app/src/main/java/com/example/dsedge/BridgeActivity.java b/app/src/main/java/com/example/dsedge/BridgeActivity.java new file mode 100644 index 000000000..822836b22 --- /dev/null +++ b/app/src/main/java/com/example/dsedge/BridgeActivity.java @@ -0,0 +1,765 @@ +package com.example.dsedge; + +import android.app.PendingIntent; +import android.content.Intent; +import android.hardware.usb.UsbInterface; +import android.os.Build; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.content.res.ColorStateList; +import android.util.TypedValue; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.TextView; + +import org.libsdl.app.SDLControllerManager; + +/** + * The bridge's screen: live controller test and settings. SDL3 itself runs in the background service + * (SdlEngine), so this screen can open and close at any time without affecting the controllers. (Fallback, if + * SDL3 can't run in the background with this build: opening this screen starts SDL3 in a hidden screen behind it.) + */ +public class BridgeActivity extends android.app.Activity implements NativeBridge.Listener { + private interface IntSink { void set(int v); } + + private final Handler ui = new Handler(Looper.getMainLooper()); + private TextView statusView, summaryView, usbView; + private Button updateButton; + private ControllerView pad; + private String updateUrl; + private long lastSummary; + private static final float RAD_TO_DEG = 57.29578f; + private static final int CALIB_SAMPLES = 180; // ~0.7s at 4ms/frame + private volatile int calibCount = -1; // -1 = not calibrating + private final float[] calibSum = new float[3]; + private int calibPlayer = 0; + private int uiPlayer = 0; + private LinearLayout playerBox; + private Button[] playerTabs; + private TextView gyroCalibView; + private boolean pendingBubble; // user asked for the bubble; waiting for overlay permission + private TextView audioView; + + + // ---- design tokens ---- + private static final int BG = 0xFF0E1116, CARD = 0xFF171C24, CARD2 = 0xFF222A35, + ACCENT = 0xFF4FC3F7, TEXT = 0xFFECEFF1, SUB = 0xFF8A97A7, + OKC = 0xFF34D399, WARNC = 0xFFF59E0B, BADC = 0xFFEF4444; + private TextView statusPill; + + private int dp(int v) { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()); } + + /** True while the bridge screen is open. */ + static volatile boolean running; + + @Override protected void onCreate(Bundle state) { + NativeBridge.appContext = getApplicationContext(); + running = true; + super.onCreate(state); + Settings.init(this); + SdlEngine.start(this); + // Android 13+: the status bar icon (the bridge's notification) needs this permission. + if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[] { android.Manifest.permission.POST_NOTIFICATIONS }, 1); + } + UpdateChecker.BuildInfo info = UpdateChecker.readBuildInfo(this); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(BG); + root.setPadding(dp(14), dp(18), dp(14), dp(18)); + + // ---- header bar ---- + LinearLayout bar = new LinearLayout(this); + bar.setOrientation(LinearLayout.HORIZONTAL); + bar.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout titleCol = new LinearLayout(this); + titleCol.setOrientation(LinearLayout.VERTICAL); + TextView appTitle = new TextView(this); + appTitle.setText("SDL3 Bridge"); + appTitle.setTextColor(TEXT); + appTitle.setTextSize(21f); + appTitle.setTypeface(Typeface.DEFAULT_BOLD); + TextView appSub = new TextView(this); + appSub.setText(info.tag + " · SDL " + info.sdl); + appSub.setTextColor(SUB); + appSub.setTextSize(11f); + titleCol.addView(appTitle); + titleCol.addView(appSub); + bar.addView(titleCol, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + statusPill = new TextView(this); + statusPill.setText("Starting"); + statusPill.setTextColor(0xFF0E1116); + statusPill.setTextSize(11f); + statusPill.setTypeface(Typeface.DEFAULT_BOLD); + statusPill.setPadding(dp(12), dp(6), dp(12), dp(6)); + statusPill.setBackground(pill(SUB)); + bar.addView(statusPill); + root.addView(bar); + spacer(root, 14); + + // ---- controller test card ---- + LinearLayout test = card(root); + statusView = sub(NativeBridge.lastStatus, 12f); + usbView = sub("USB: checking...", 11f); + pad = new ControllerView(this); + test.addView(statusView); + LinearLayout padWrap = new LinearLayout(this); + padWrap.setBackground(rounded(0xFF0B0E13, dp(10))); + padWrap.setPadding(dp(4), dp(4), dp(4), dp(4)); + padWrap.addView(pad, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + LinearLayout.LayoutParams pw = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + pw.topMargin = dp(8); pw.bottomMargin = dp(8); + test.addView(padWrap, pw); + summaryView = sub("Press every button and move everything to test it.", 12f); + test.addView(summaryView); + test.addView(usbView); + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + LinearLayout.LayoutParams rp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + rp.topMargin = dp(8); + row.addView(primaryButton("Test rumble", v -> rumbleTest()), weight()); + gap(row); + row.addView(button("Reset", v -> { pad.resetTested(); summaryView.setText(pad.summary()); }), weight()); + gap(row); + updateButton = button("Update", v -> { if (updateUrl != null) openUrl(updateUrl); else checkUpdate(); }); + row.addView(updateButton, weight()); + test.addView(row, rp); + + // ---- per-player settings ---- + LinearLayout playerCard = card(root); + cardTitle(playerCard, "Player settings"); + LinearLayout tabs = new LinearLayout(this); + tabs.setOrientation(LinearLayout.HORIZONTAL); + tabs.setBackground(rounded(CARD2, dp(10))); + tabs.setPadding(dp(4), dp(4), dp(4), dp(4)); + playerTabs = new Button[Settings.PLAYERS]; + for (int i = 0; i < Settings.PLAYERS; i++) { + final int pi = i; + Button t = new Button(this); + t.setText("P" + (i + 1)); + t.setAllCaps(false); + t.setTextSize(13f); + t.setOnClickListener(v -> showPlayer(pi)); + playerTabs[i] = t; + tabs.addView(t, weight()); + } + playerCard.addView(tabs); + playerBox = new LinearLayout(this); + playerBox.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams pbp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + pbp.topMargin = dp(10); + playerCard.addView(playerBox, pbp); + showPlayer(0); + + // ---- SDL3 driver ---- + LinearLayout drv = card(root); + cardTitle(drv, "SDL3 driver"); + addSpinner(drv, "Controller source", new String[] { + "Auto: any controller (SDL3 USB driver when plugged in, else Android/Bluetooth)", + "Forced: only SDL3's USB driver (USB cable only)" + }, Settings.forceUsb ? 1 : 0, v -> { + Settings.forceUsb = v == 1; + Settings.save(); + NativeBridge.applyForce(); + }); + drv.addView(primaryButton("Force SDL3 USB driver", v -> forceUsbDriver())); + drv.addView(sub("Plug the controller in with USB-C, tap the button and allow USB access " + + "(choose 'always' to skip this next time). Works for any SDL3-supported controller.", 11f)); + + // ---- Controller mode ---- + LinearLayout mode = card(root); + cardTitle(mode, "Controller mode"); + addSpinner(mode, "Present controller as", Settings.PRESENT_NAMES, Settings.presentAs, v -> { + Settings.presentAs = v; + if (!Settings.isHookup()) Settings.lastPresentAs = v; + Settings.save(); + NativeBridge.applyHookup(); + BridgeService.refreshBubble(); + }); + mode.addView(sub("Steam Deck: the controller is DroidDeck's Deck pad (default, full features). " + + "DualSense / DualShock 4: Steam sees a NATIVE Sony controller (hidraw) with gyro, touchpad and back buttons. " + + "Steam Native Hook-up: every USB controller is handed to Steam as itself (its real name and ids), " + + "so Steam's own driver gives it all its features, like on a PC; Bluetooth controllers stay a Deck pad. " + + "Give Steam a few seconds, or restart the session, after changing.", 11f)); + addSpinner(mode, "Poll rate", Settings.RATE_NAMES, rateIndex(), v -> { + Settings.pollMs = Settings.RATE_MS[v]; + Settings.save(); + NativeBridge.applyRate(); + }); + mode.addView(sub("Higher rate = lower gyro/trackpad latency, a little more CPU and battery. No accuracy is lost either way.", 11f)); + + // ---- Floating bubble ---- + LinearLayout bub = card(root); + cardTitle(bub, "Floating bubble"); + addCheck(bub, "Show floating bubble over other apps", Settings.bubble, on -> { + if (on) { + if (!android.provider.Settings.canDrawOverlays(this)) { + pendingBubble = true; + try { + startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + getPackageName()))); + } catch (RuntimeException ignored) { } + return; + } + Settings.bubble = true; + startService(new Intent(this, BridgeService.class).setAction("BUBBLE").putExtra("on", true)); + } else { + Settings.bubble = false; + startService(new Intent(this, BridgeService.class).setAction("BUBBLE").putExtra("on", false)); + } + }); + bub.addView(sub("A draggable chat-head: tap toggles the SDL3 driver (green on / red off), hold 3s opens this app, " + + "drag to the bottom to remove. Needs the \"Draw over other apps\" permission.", 11f)); + + // ---- USB audio ---- + LinearLayout aud = card(root); + cardTitle(aud, "USB audio"); + audioView = sub(audioInfo(), 12f); + aud.addView(audioView); + aud.addView(button("Developer options → USB audio routing", v -> openDevSettings())); + aud.addView(sub("When the controller is plugged in via USB-C, Android may grab it as an audio device and route sound to it. " + + "This opens Developer options — turn ON \"Disable USB audio routing\" to keep audio on the phone. " + + "(If Developer options aren't enabled, tap Build number 7 times in About phone first.)", 11f)); + + root.addView(sub("Orange = held now, green = worked at least once. Leave this app open once, then switch to DroidDeck.", 11f)); + + ScrollView scroll = new ScrollView(this); + scroll.setBackgroundColor(BG); + scroll.addView(root); + addContentView(scroll, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + NativeBridge.listeners.add(this); + try { + startForegroundService(new Intent(this, BridgeService.class)); + } catch (RuntimeException ignored) { } + NativeBridge.applyDriver(); + NativeBridge.applyRate(); + NativeBridge.applyOutput(); + checkUpdate(); + ui.post(usbRefresh); + } + + private void showPlayer(int p) { + uiPlayer = p; + for (int i = 0; i < playerTabs.length; i++) { + boolean sel = i == p; + playerTabs[i].setBackground(rounded(sel ? ACCENT : 0x00000000, dp(8))); + playerTabs[i].setTextColor(sel ? 0xFF0E1116 : SUB); + playerTabs[i].setTypeface(sel ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT); + } + playerBox.removeAllViews(); + buildPlayerSection(playerBox, p); + } + + private void buildPlayerSection(LinearLayout box, final int p) { + subhead(box, "Gyro"); + addSlider(box, "Gyro speed % (100 = 1:1)", 800, Settings.gyroGain[p], v -> Settings.gyroGain[p] = Math.max(1, v)); + addSpinner(box, "Invert gyro axes", Settings.GYRO_INV_NAMES, Settings.gyroInvert[p], v -> { Settings.gyroInvert[p] = v; Settings.save(); }); + for (int i = 0; i < 3; i++) { + final int out = i; + addSpinner(box, "DroidDeck gyro " + "XYZ".charAt(i) + " comes from", Settings.AXIS_NAMES, Settings.gyroMap[p][i], v -> { Settings.gyroMap[p][out] = v; Settings.save(); }); + } + addSpinner(box, "Apply remap/invert to accelerometer too", new String[] { "Yes (recommended)", "No" }, + Settings.accelFollows[p] ? 0 : 1, v -> { Settings.accelFollows[p] = v == 0; Settings.save(); }); + addSlider(box, "Gyro deadzone (deg/s)", 50, Settings.gyroDeadzone[p], v -> Settings.gyroDeadzone[p] = v); + gyroCalibView = sub(calibLabel(p), 11f); + LinearLayout gc = new LinearLayout(this); + gc.setOrientation(LinearLayout.HORIZONTAL); + gc.addView(button("Calibrate (hold still)", v -> startGyroCalibration()), weight()); + gap(gc); + gc.addView(button("Reset calibration", v -> resetGyroCalibration()), weight()); + box.addView(gc); + box.addView(gyroCalibView); + box.addView(sub("Calibration samples the controller shown above (player 1 slot). Deadzone ignores tiny drift.", 11f)); + + subhead(box, "Touchpad"); + addCheck(box, "Invert touchpad X (left/right)", Settings.touchInvX[p], on -> Settings.touchInvX[p] = on); + addCheck(box, "Invert touchpad Y (up/down)", Settings.touchInvY[p], on -> Settings.touchInvY[p] = on); + addCheck(box, "Swap touchpad X and Y (before invert)", Settings.touchSwap[p], on -> Settings.touchSwap[p] = on); + addSpinner(box, "Layout", Settings.TOUCH_NAMES, Settings.touchMode[p], v -> { Settings.touchMode[p] = v; Settings.save(); pad.postInvalidate(); }); + + subhead(box, "Button remap"); + box.addView(sub("Map any controller button to a Deck grip (L4/R4/L5/R5) or a trackpad touch/click. " + + "Useful for extra buttons like a Flydigi's C and Z.", 11f)); + for (int i = 0; i < Settings.REMAP_COUNT; i++) { + final int target = i; + addSpinner(box, Settings.REMAP_TARGETS[i] + " pressed by", Settings.SRC_NAMES, Settings.remap[p][i], v -> { Settings.remap[p][target] = v; Settings.save(); }); + } + + subhead(box, "Light"); + addSlider(box, "Red", 255, Settings.lightR[p], v -> { Settings.lightR[p] = v; applyLight(p); }); + addSlider(box, "Green", 255, Settings.lightG[p], v -> { Settings.lightG[p] = v; applyLight(p); }); + addSlider(box, "Blue", 255, Settings.lightB[p], v -> { Settings.lightB[p] = v; applyLight(p); }); + addSlider(box, "Brightness %", 100, Settings.brightness[p], v -> { Settings.brightness[p] = v; applyLight(p); }); + addSpinner(box, "Player LEDs", Settings.PLAYER_NAMES, Settings.playerLed[p], v -> { Settings.playerLed[p] = v; Settings.save(); applyLight(p); }); + } + + private void applyLight(int p) { NativeBridge.applyOutput(p); } + + // ---------- styled UI helpers ---------- + + private GradientDrawable rounded(int color, int radius) { + GradientDrawable g = new GradientDrawable(); + g.setColor(color); + g.setCornerRadius(radius); + return g; + } + + private GradientDrawable pill(int color) { + GradientDrawable g = new GradientDrawable(); + g.setColor(color); + g.setCornerRadius(dp(20)); + return g; + } + + private LinearLayout card(LinearLayout parent) { + LinearLayout c = new LinearLayout(this); + c.setOrientation(LinearLayout.VERTICAL); + c.setBackground(rounded(CARD, dp(16))); + c.setPadding(dp(16), dp(14), dp(16), dp(14)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lp.bottomMargin = dp(12); + parent.addView(c, lp); + return c; + } + + private void cardTitle(LinearLayout card, String s) { + TextView t = new TextView(this); + t.setText(s.toUpperCase(java.util.Locale.ROOT)); + t.setTextColor(ACCENT); + t.setTextSize(12f); + t.setTypeface(Typeface.DEFAULT_BOLD); + t.setLetterSpacing(0.08f); + t.setPadding(0, 0, 0, dp(8)); + card.addView(t); + } + + private void subhead(LinearLayout box, String s) { + TextView t = new TextView(this); + t.setText(s); + t.setTextColor(TEXT); + t.setTextSize(14f); + t.setTypeface(Typeface.DEFAULT_BOLD); + t.setPadding(0, dp(14), 0, dp(4)); + box.addView(t); + } + + private void spacer(LinearLayout parent, int h) { + View v = new View(this); + parent.addView(v, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(h))); + } + + private void gap(LinearLayout row) { + View v = new View(this); + row.addView(v, new LinearLayout.LayoutParams(dp(8), 1)); + } + + private TextView text(String s, float size) { + TextView t = new TextView(this); + t.setTextColor(TEXT); + t.setTextSize(size); + t.setPadding(0, dp(4), 0, dp(4)); + t.setText(s); + return t; + } + + private TextView sub(String s, float size) { + TextView t = text(s, size); + t.setTextColor(SUB); + t.setLineSpacing(dp(2), 1f); + return t; + } + + private Button button(String s, View.OnClickListener l) { + Button b = new Button(this); + b.setText(s); + b.setAllCaps(false); + b.setTextSize(13f); + b.setTextColor(TEXT); + b.setBackground(rounded(CARD2, dp(10))); + b.setStateListAnimator(null); + b.setPadding(dp(14), dp(10), dp(14), dp(10)); + b.setOnClickListener(l); + return b; + } + + private Button primaryButton(String s, View.OnClickListener l) { + Button b = button(s, l); + b.setBackground(rounded(ACCENT, dp(10))); + b.setTextColor(0xFF0E1116); + b.setTypeface(Typeface.DEFAULT_BOLD); + return b; + } + + private static LinearLayout.LayoutParams weight() { + return new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + } + + private ArrayAdapter styledAdapter(String[] names) { + return new ArrayAdapter(this, android.R.layout.simple_spinner_item, names) { + @Override public View getView(int pos, View cv, ViewGroup parent) { + TextView t = (TextView) super.getView(pos, cv, parent); + t.setTextColor(TEXT); t.setTextSize(13f); + return t; + } + @Override public View getDropDownView(int pos, View cv, ViewGroup parent) { + TextView t = (TextView) super.getDropDownView(pos, cv, parent); + t.setTextColor(0xFF10151C); t.setTextSize(14f); + t.setPadding(dp(14), dp(12), dp(14), dp(12)); + return t; + } + }; + } + + private void addSpinner(LinearLayout root, String label, String[] names, int selected, final IntSink sink) { + root.addView(sub(label, 12f)); + Spinner sp = new Spinner(this); + sp.setAdapter(styledAdapter(names)); + sp.setSelection(selected); + sp.setBackground(rounded(CARD2, dp(8))); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lp.bottomMargin = dp(6); + sp.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { sink.set(position); } + @Override public void onNothingSelected(AdapterView parent) { } + }); + root.addView(sp, lp); + } + + private void addSlider(LinearLayout root, final String label, int max, int value, final IntSink sink) { + final TextView t = sub(label + ": " + value, 12f); + SeekBar sb = new SeekBar(this); + sb.setMax(max); + sb.setProgress(value); + sb.setProgressTintList(ColorStateList.valueOf(ACCENT)); + sb.setThumbTintList(ColorStateList.valueOf(ACCENT)); + sb.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override public void onProgressChanged(SeekBar s, int v, boolean fromUser) { + t.setText(label + ": " + v); + if (fromUser) sink.set(v); + } + @Override public void onStartTrackingTouch(SeekBar s) { } + @Override public void onStopTrackingTouch(SeekBar s) { Settings.save(); } + }); + root.addView(t); + root.addView(sb); + } + + private interface BoolSink { void set(boolean on); } + + private void addCheck(LinearLayout root, String label, boolean value, final BoolSink sink) { + CheckBox c = new CheckBox(this); + c.setText(label); + c.setTextColor(TEXT); + c.setTextSize(13f); + c.setButtonTintList(ColorStateList.valueOf(ACCENT)); + c.setPadding(dp(6), dp(6), 0, dp(6)); + c.setChecked(value); + c.setOnCheckedChangeListener((v, on) -> { sink.set(on); Settings.save(); }); + root.addView(c); + } + + // ---------- USB diagnostics ---------- + + private final Runnable usbRefresh = new Runnable() { + @Override public void run() { + int n = BridgeService.clientCount; + usbView.setText(usbInfo() + "\nDroidDeck link: " + (n > 0 ? (n + " app(s) bound — sending frames") : "NOT bound (open a DroidDeck game session)")); + ui.postDelayed(this, 2000); + } + }; + + /** Android's view of the controller on USB: is it there, and may this app talk to it? */ + private String usbInfo() { + try { + UsbManager um = (UsbManager) getSystemService(USB_SERVICE); + StringBuilder sb = new StringBuilder("USB: "); + boolean any = false; + for (UsbDevice d : um.getDeviceList().values()) { + if (!looksLikeController(d)) continue; + any = true; + String name = d.getProductName() != null ? d.getProductName() : "controller"; + sb.append(String.format(java.util.Locale.ROOT, "%s %04x:%04x access=%s ", name, + d.getVendorId(), d.getProductId(), um.hasPermission(d) ? "yes" : "NO (unplug and replug, tap OK)")); + } + if (!any) sb.append("no controller on USB (use a USB-C data cable)"); + return sb.toString(); + } catch (RuntimeException e) { + return "USB: unavailable"; + } + } + + /** Lists any USB / headset audio outputs Android currently sees (e.g. the controller's audio device). */ + private int rateIndex() { + for (int i = 0; i < Settings.RATE_MS.length; i++) if (Settings.RATE_MS[i] == Settings.pollMs) return i; + return 1; + } + + private String audioInfo() { + try { + AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE); + StringBuilder sb = new StringBuilder("Audio out: "); + boolean any = false; + for (AudioDeviceInfo d : am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + int t = d.getType(); + if (t == AudioDeviceInfo.TYPE_USB_DEVICE || t == AudioDeviceInfo.TYPE_USB_HEADSET || t == AudioDeviceInfo.TYPE_USB_ACCESSORY) { + any = true; + CharSequence nm = d.getProductName(); + sb.append(nm != null ? nm : (t == AudioDeviceInfo.TYPE_USB_HEADSET ? "USB headset" : "USB audio")).append(" "); + } + } + if (!any) sb.append("no USB audio device (plug the controller in with USB-C, or no audio interface)"); + return sb.toString(); + } catch (RuntimeException e) { + return "Audio out: unavailable"; + } + } + + /** Opens Developer options, where "Disable USB audio routing" keeps audio on the phone. */ + private void openDevSettings() { + // There is no public intent to a single developer toggle, so open the Developer options screen. + try { + startActivity(new Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + return; + } catch (RuntimeException ignored) { } + // Developer options not enabled or unavailable: fall back to the main Settings app. + try { + startActivity(new Intent(android.provider.Settings.ACTION_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } catch (RuntimeException ignored) { } + } + + // ---------- tests ---------- + + private void rumbleTest() { + NativeBridge.setRumble(uiPlayer, 220, 220); + ui.postDelayed(() -> NativeBridge.setRumble(uiPlayer, 0, 0), 700); + } + + // ---------- updates ---------- + + private void checkUpdate() { + updateUrl = null; + updateButton.setText("Checking for updates..."); + UpdateChecker.check(this, (msg, url) -> { + updateUrl = url; + updateButton.setText(msg); + }); + } + + private void openUrl(String url) { + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + } catch (RuntimeException e) { + updateButton.setText("Could not open the download link."); + } + } + + // ---------- SDL USB handling ---------- + + @Override protected void onDestroy() { + running = false; + ui.removeCallbacks(usbRefresh); + NativeBridge.listeners.remove(this); + super.onDestroy(); + } + + // SDL releases the USB controller when the activity pauses or loses focus. Undo that so input + // keeps flowing while DroidDeck is in front. + /** Asks Android for USB access to every game-controller-like device, using SDL's own permission + * action so SDL's receiver opens the device straight away, then restarts SDL's enumeration. */ + private void forceUsbDriver() { + UsbManager um = (UsbManager) getSystemService(USB_SERVICE); + int asked = 0, have = 0, seen = 0; + if (um != null) { + for (UsbDevice d : um.getDeviceList().values()) { + if (!looksLikeController(d)) continue; + seen++; + if (um.hasPermission(d)) { have++; continue; } + Intent i = new Intent("org.libsdl.app.USB_PERMISSION").setPackage(getPackageName()); + int flags = Build.VERSION.SDK_INT >= 31 ? 0x02000000 : 0; // FLAG_MUTABLE + try { + um.requestPermission(d, PendingIntent.getBroadcast(this, 0, i, flags)); + asked++; + } catch (RuntimeException ignored) { } + } + } + usbView.setText(seen == 0 + ? "No USB controller found. Use a USB-C cable (Bluetooth is not enough) and try again." + : "USB controllers: " + seen + ", already allowed: " + have + ", asked now: " + asked); + keepControllerClaimed(); + ui.postDelayed(NativeBridge::rescan, asked > 0 ? 4000 : 300); + } + + /** Any USB device with a HID or vendor-specific interface (SDL3 decides whether it really is a controller). */ + private static boolean looksLikeController(UsbDevice d) { + for (int n = 0; n < d.getInterfaceCount(); n++) { + UsbInterface f = d.getInterface(n); + int c = f.getInterfaceClass(); + if (c == 255 || (c == 3 && f.getInterfaceProtocol() == 0)) return true; // vendor-specific (Xbox) or generic HID + } + return false; + } + + @Override protected void onResume() { + super.onResume(); + if (android.provider.Settings.canDrawOverlays(this)) { + if (pendingBubble) { + pendingBubble = false; + Settings.bubble = true; + Settings.save(); + } + if (Settings.bubble) { + startService(new Intent(this, BridgeService.class).setAction("BUBBLE").putExtra("on", true)); + } + } + } + + @Override protected void onPause() { + super.onPause(); + if (isFinishing()) running = false; + keepControllerClaimed(); + } + + @Override public void onBackPressed() { + // Fallback only: SDL3 runs in a screen behind this one, and closing them would stop it -- step back instead. + if (SdlHostActivity.running) moveTaskToBack(true); + else super.onBackPressed(); + } + + @Override public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + keepControllerClaimed(); + } + + private void keepControllerClaimed() { + // When the driver is off, let go of the controller (freeze = release the USB interface) so Android can use it. + SdlEngine.setHidFrozen(!Settings.driverEnabled); + } + + /** Release or re-claim the USB interface (the driver was switched off / on). */ + static void setHidFrozen(boolean frozen) { + SdlEngine.setHidFrozen(frozen); + } + + // Controllers that reach the phone through Android's input (Bluetooth) deliver their buttons and sticks to the + // focused window: while this screen is in front, hand them to SDL like SDL's own activity did. + private static boolean fromGamepad(int source) { + return (source & android.view.InputDevice.SOURCE_GAMEPAD) == android.view.InputDevice.SOURCE_GAMEPAD + || (source & android.view.InputDevice.SOURCE_JOYSTICK) == android.view.InputDevice.SOURCE_JOYSTICK + || (source & android.view.InputDevice.SOURCE_DPAD) == android.view.InputDevice.SOURCE_DPAD; + } + + @Override public boolean dispatchKeyEvent(android.view.KeyEvent e) { + if (SdlEngine.isRunning() && fromGamepad(e.getSource())) { + try { + if (SDLControllerManager.isDeviceSDLJoystick(e.getDeviceId())) { + if (e.getAction() == android.view.KeyEvent.ACTION_DOWN + && SDLControllerManager.onNativePadDown(e.getDeviceId(), e.getKeyCode(), e.getScanCode())) return true; + if (e.getAction() == android.view.KeyEvent.ACTION_UP + && SDLControllerManager.onNativePadUp(e.getDeviceId(), e.getKeyCode(), e.getScanCode())) return true; + } + } catch (UnsatisfiedLinkError | RuntimeException ignored) { } + } + return super.dispatchKeyEvent(e); + } + + @Override public boolean dispatchGenericMotionEvent(android.view.MotionEvent e) { + if (SdlEngine.isRunning() && (e.getSource() & android.view.InputDevice.SOURCE_JOYSTICK) == android.view.InputDevice.SOURCE_JOYSTICK) { + try { + if (SDLControllerManager.handleJoystickMotionEvent(e)) return true; + } catch (UnsatisfiedLinkError | RuntimeException ignored) { } + } + return super.dispatchGenericMotionEvent(e); + } + + // ---------- NativeBridge.Listener ---------- + + private String calibLabel(int p) { + float[] b = Settings.gyroBias[p]; + if (b[0] == 0f && b[1] == 0f && b[2] == 0f) return "Calibration: none"; + return String.format(java.util.Locale.ROOT, "Calibration bias (deg/s): x=%.2f y=%.2f z=%.2f", b[0], b[1], b[2]); + } + + private void startGyroCalibration() { + calibPlayer = uiPlayer; + calibSum[0] = calibSum[1] = calibSum[2] = 0f; + calibCount = 0; + if (gyroCalibView != null) gyroCalibView.setText("Hold the controller still..."); + } + + private void resetGyroCalibration() { + calibCount = -1; + float[] b = Settings.gyroBias[uiPlayer]; + b[0] = b[1] = b[2] = 0f; + Settings.save(); + if (gyroCalibView != null) gyroCalibView.setText(calibLabel(uiPlayer)); + } + + @Override public void onFrame(float[] f, int slot) { + if (slot != 0) return; // the test screen shows player 1 + if (calibCount >= 0 && f.length >= 13) { + calibSum[0] += f[7]; calibSum[1] += f[8]; calibSum[2] += f[9]; + if (++calibCount >= CALIB_SAMPLES) { + float[] b = Settings.gyroBias[calibPlayer]; + b[0] = calibSum[0] / CALIB_SAMPLES * RAD_TO_DEG; + b[1] = calibSum[1] / CALIB_SAMPLES * RAD_TO_DEG; + b[2] = calibSum[2] / CALIB_SAMPLES * RAD_TO_DEG; + Settings.save(); + calibCount = -1; + ui.post(() -> { if (gyroCalibView != null && uiPlayer == calibPlayer) gyroCalibView.setText(calibLabel(calibPlayer)); }); + } + } + pad.update(f); + long now = System.nanoTime(); + if (now - lastSummary > 300_000_000L) { + lastSummary = now; + ui.post(() -> summaryView.setText(pad.summary())); + } + } + + @Override public void onPadCaps(int slot, int present, int axes, int touchpads, String kind) { + if (slot != 0) return; + pad.setCaps(present, axes, touchpads, kind); + } + + @Override public void onStatus(final String s) { + ui.post(() -> { + statusView.setText(s); + boolean off = s.contains("driver OFF"); + boolean waiting = s.startsWith("Controller disconnected") || s.startsWith("Waiting") || s.startsWith("Starting"); + if (off) { + statusPill.setText("Driver off"); + statusPill.setBackground(pill(BADC)); + } else if (waiting) { + statusPill.setText("Waiting"); + statusPill.setBackground(pill(WARNC)); + } else { + statusPill.setText("Connected"); + statusPill.setBackground(pill(OKC)); + } + if (off || waiting) pad.setDisconnected(); + }); + } +} diff --git a/app/src/main/java/com/example/dsedge/BridgeService.java b/app/src/main/java/com/example/dsedge/BridgeService.java new file mode 100644 index 000000000..4e0a437cf --- /dev/null +++ b/app/src/main/java/com/example/dsedge/BridgeService.java @@ -0,0 +1,975 @@ +package com.example.dsedge; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.graphics.Color; +import android.graphics.PixelFormat; +import android.graphics.drawable.GradientDrawable; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowManager; +import android.widget.TextView; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; +import android.os.SystemClock; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Streams controller frames (from the SDL3 loop) to bound clients such as DroidDeck, and passes their + * rumble / lightbar requests back. Same wire protocol as the first bridge, plus decoupled touchpad data. + */ +public class BridgeService extends Service implements NativeBridge.Listener { + static final int MSG_REGISTER = 1; + static final int MSG_UNREGISTER = 2; + static final int MSG_OUTPUT = 3; + static final int MSG_FRAME = 10; + static final int MSG_STATUS = 11; + static final int MSG_PAD_GONE = 12; + // Steam Controller (2026) raw passthrough. Client -> bridge: + static final int MSG_TRITON_OUTPUT = 4; // "data": report (id first), "feature": feature write? + static final int MSG_TRITON_GET = 5; // "request": long, "id": feature report id + static final int MSG_TRITON_RUMBLE = 6; // "l", "r": 0..65535 + // Bridge -> client: + static final int MSG_TRITON_STATE = 13; // "id": report id, "data": payload + static final int MSG_TRITON_CONN = 14; // "on": boolean, "what": description + static final int MSG_TRITON_FEATURE = 15; // "request": long, "data": reply (absent = no answer) + // Steam Native Hook-up. Client -> bridge: + static final int MSG_HOOKUP_OUT = 7; // "slot", "kind" (0 output report, 1 feature write), "data" + static final int MSG_HOOKUP_GET = 8; // "slot", "request", "id", "input", "length" + // Bridge -> client: + static final int MSG_HOOKUP_ADD = 20; // HookupIdentity bundle + static final int MSG_HOOKUP_INPUT = 21; // "slot", "data" + static final int MSG_HOOKUP_REMOVE = 22; // "slot" + static final int MSG_HOOKUP_REPLY = 23; // "slot", "request", "data" (absent = no answer) + static final int MSG_BRIDGE_LOG = 30; // "text": one or more log lines (newline separated) + static final int MSG_HOOKUP_SYNC = 9; // client watchdog: resend what is handed over (re-registers if needed) + static final int MSG_HOOKUP_STATE = 24; // "slots": int[] of live hook-up slots; "fast_ino": mapped shared file + static final int MSG_HOOKUP_SHM = 25; // <- DroidDeck: "fd": its shared hook-up file (fast path) + // DroidDeck's SDL3Bridge settings tab (no REGISTER needed: it gets no frames, only replies) + static final int MSG_SETTINGS_GET = 40; // <- replyTo: send MSG_SETTINGS + static final int MSG_SETTINGS_SET = 41; // <- "key" [+ "p" player, "i" index] + "int" / "bool"; replyTo: send MSG_SETTINGS + static final int MSG_SETTINGS = 42; // -> the whole settings state + controller status (see settingsSnapshot) + + private static final float RAD_TO_DEG = 57.29578f; + private static final float G = 9.80665f; + private static final float PAD_W = 1920f, PAD_H = 1080f; + + private final CopyOnWriteArrayList clients = new CopyOnWriteArrayList<>(); + static volatile int clientCount; // how many clients (DroidDeck) are bound + registered + + private final Handler handler = new Handler(Looper.getMainLooper()) { + @Override public void handleMessage(Message m) { + switch (m.what) { + case MSG_REGISTER: + if (m.replyTo != null) { + if (!clients.contains(m.replyTo)) clients.add(m.replyTo); // re-register = resend state, no duplicate + clientCount = clients.size(); sendStatus(m.replyTo, NativeBridge.lastStatus); + if (NativeBridge.tritonConnected) sendTritonConn(m.replyTo, true, NativeBridge.tritonWhat); + String backlog; + synchronized (NativeBridge.bridgeLog) { backlog = String.join("\n", NativeBridge.bridgeLog); } + if (!backlog.isEmpty()) { + Bundle lb = new Bundle(); + lb.putString("text", "(earlier) " + backlog.replace("\n", "\n(earlier) ")); + sendOne(m.replyTo, MSG_BRIDGE_LOG, lb); + } + for (Bundle id : NativeBridge.hookupIdentity) if (id != null) sendOne(m.replyTo, MSG_HOOKUP_ADD, id); + } + break; + case MSG_UNREGISTER: + if (m.replyTo != null) { clients.remove(m.replyTo); clientCount = clients.size(); } + if (clients.isEmpty()) NativeBridge.setSteamAway(false); // DroidDeck's session ended + break; + case MSG_OUTPUT: + mergeOutput(m.getData()); + break; + case MSG_TRITON_OUTPUT: { + byte[] data = m.getData().getByteArray("data"); + if (data != null) NativeBridge.tritonOutput(data, m.getData().getBoolean("feature")); + break; + } + case MSG_TRITON_GET: + NativeBridge.tritonGetFeature(m.getData().getLong("request"), m.getData().getInt("id", 1)); + break; + case MSG_HOOKUP_OUT: { + byte[] data = m.getData().getByteArray("data"); + if (data != null) NativeBridge.hookupOutput(m.getData().getInt("slot"), m.getData().getInt("kind"), data); + break; + } + case MSG_HOOKUP_SYNC: + // DroidDeck's desktop: is the Steam client running there? (Absent = yes: the Steam session.) + NativeBridge.setSteamAway(m.getData() != null && m.getData().getBoolean("steamAway", false)); + handler.removeCallbacks(steamAwayLapse); + if (NativeBridge.hookupWaitsForSteam()) handler.postDelayed(steamAwayLapse, 10100); + if (m.replyTo != null) { + // A client the bridge forgot (the bridge was restarted) is registered again. + if (!clients.contains(m.replyTo)) { + clients.add(m.replyTo); clientCount = clients.size(); + sendStatus(m.replyTo, NativeBridge.lastStatus); + } + if (NativeBridge.tritonConnected) sendTritonConn(m.replyTo, true, NativeBridge.tritonWhat); + int[] live = new int[NativeBridge.MAX_PADS]; + int n = 0; + for (int i = 0; i < NativeBridge.MAX_PADS; i++) { + Bundle id = NativeBridge.hookupIdentity[i]; + if (id == null) continue; + sendOne(m.replyTo, MSG_HOOKUP_ADD, id); + live[n++] = i; + } + NativeBridge.retryUndescribed(); + Bundle st = new Bundle(); + st.putIntArray("slots", java.util.Arrays.copyOf(live, n)); + st.putLong("fast_ino", NativeBridge.hookupShmInode()); + sendOne(m.replyTo, MSG_HOOKUP_STATE, st); + } + ensureBridgeLoop(); + break; + case MSG_HOOKUP_SHM: { + Bundle d = m.getData(); + if (d != null) d.setClassLoader(android.os.ParcelFileDescriptor.class.getClassLoader()); + @SuppressWarnings("deprecation") + android.os.ParcelFileDescriptor pfd = d == null ? null : d.getParcelable("fd"); + NativeBridge.hookupMapShm(pfd); + break; + } + case MSG_SETTINGS_SET: + applyRemote(m.getData()); + // fall through: answer with the new state + case MSG_SETTINGS_GET: + if (m.replyTo != null) sendOne(m.replyTo, MSG_SETTINGS, settingsSnapshot()); + if (m.what == MSG_SETTINGS_GET) ensureBridgeLoop(); // the tab shows live controllers + break; + case MSG_HOOKUP_GET: + NativeBridge.hookupGet(m.getData().getInt("slot"), m.getData().getLong("request"), m.getData().getInt("id"), + m.getData().getBoolean("input"), m.getData().getInt("length", 64)); + break; + case MSG_TRITON_RUMBLE: + NativeBridge.tritonRumble(m.getData().getInt("l"), m.getData().getInt("r")); + break; + } + } + }; + private final Messenger messenger = new Messenger(handler); + /** DroidDeck stopped saying that Steam is away (it went away itself): hook-up goes on again. */ + private final Runnable steamAwayLapse = NativeBridge::recheckHookup; + + // ---- floating bubble ---- + private final Handler main = new Handler(Looper.getMainLooper()); + private WindowManager wm; + private TextView bubble; + private WindowManager.LayoutParams bubbleLp; + private int bubbleSize; + private View removeZone; + private boolean longPressFired; + private Runnable bubbleLongPress; + private volatile boolean connectedState; + + static volatile BridgeService instance; + + @Override public void onCreate() { + NativeBridge.appContext = getApplicationContext(); + NativeBridge.clients = clients::size; + instance = this; + super.onCreate(); + Settings.init(this); + NativeBridge.listeners.add(this); + // SDL3 runs here, in the background: nothing has to open when DroidDeck (or a plugged-in controller) + // starts the bridge. The notification is the bridge's icon in the status bar. + SdlEngine.start(this); + // The status bar icon. Started by DroidDeck's connection it is a plain ongoing notification (that + // connection keeps the bridge alive); started from the bridge screen or a plugged-in controller, + // onStartCommand turns it into the foreground service's notification. + postNotification(false); + } + + // ---------- status bar notification ---------- + private static final int NOTE_ID = 1; + private String noteText = ""; + private long noteMs; + + private Notification buildNotification() { + NotificationManager nm = getSystemService(NotificationManager.class); + nm.createNotificationChannel(new NotificationChannel("bridge", "Controller bridge", NotificationManager.IMPORTANCE_LOW)); + android.app.PendingIntent open = android.app.PendingIntent.getActivity(this, 0, + new Intent(this, BridgeActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + android.app.PendingIntent.FLAG_IMMUTABLE | android.app.PendingIntent.FLAG_UPDATE_CURRENT); + String text = SdlEngine.error != null ? SdlEngine.error : NativeBridge.lastStatus; + return new Notification.Builder(this, "bridge") + .setContentTitle(!Settings.isHookup() ? "SDL3Bridge" + : NativeBridge.hookupWaitsForSteam() ? "SDL3Bridge · Hook-up waits for Steam (DroidDeck desktop)" + : "SDL3Bridge · Steam Native Hook-up") + .setContentText(text) + .setStyle(new Notification.BigTextStyle().bigText(text)) + .setSmallIcon(com.droiddeck.launcher.R.drawable.sdl3_ic_stat_bridge) + .setColor(0xFF4FC3F7) + .setContentIntent(open) + .setOngoing(true) + .setShowWhen(false) + .setOnlyAlertOnce(true) + .build(); + } + + /** foreground: as the foreground service's notification; else a plain ongoing notification. */ + private boolean postNotification(boolean foreground) { + try { + Notification n = buildNotification(); + if (foreground) { + if (Build.VERSION.SDK_INT >= 34) startForeground(NOTE_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); + else startForeground(NOTE_ID, n); + } else { + getSystemService(NotificationManager.class).notify(NOTE_ID, n); + } + return true; + } catch (RuntimeException e) { + android.util.Log.w("SDL3Bridge", "notification: " + e); + return false; + } + } + + /** Status changed: refresh the notification text (at most every 2 s). */ + private void refreshNotification(String status) { + if (status == null || status.equals(noteText)) return; + long now = SystemClock.uptimeMillis(); + if (now - noteMs < 2000) { + main.removeCallbacks(noteRefresh); + main.postDelayed(noteRefresh, 2000 - (now - noteMs)); + return; + } + noteText = status; + noteMs = now; + try { getSystemService(NotificationManager.class).notify(NOTE_ID, buildNotification()); } catch (RuntimeException ignored) { } + } + private final Runnable noteRefresh = () -> refreshNotification(NativeBridge.lastStatus); + + @Override public IBinder onBind(Intent i) { return messenger.getBinder(); } + + @Override public int onStartCommand(Intent i, int flags, int startId) { + SdlEngine.start(this); + if (i != null && "BUBBLE".equals(i.getAction())) { + if (i.getBooleanExtra("on", false)) showBubble(); else hideBubble(); + } else if (Settings.bubble) { + showBubble(); + } + if (!postNotification(true)) postNotification(false); + return START_NOT_STICKY; + } + + @Override public void onDestroy() { + if (instance == this) instance = null; + NativeBridge.listeners.remove(this); + hideBubble(); + super.onDestroy(); + } + + // ---------- floating bubble (chat-head) ---------- + // tap -> toggle the SDL3 forced driver (green = on, red = off) + // double tap -> toggle Steam Native Hook-up (blue = on: Steam gets the real controllers) + // hold 3s -> open the bridge app + // drag to the bottom remove zone -> dismiss the bubble + + @SuppressWarnings("ClickableViewAccessibility") + private void showBubble() { + if (bubble != null) return; + if (!android.provider.Settings.canDrawOverlays(this)) return; // no permission yet + wm = (WindowManager) getSystemService(WINDOW_SERVICE); + + bubbleSize = dp(52); + TextView b = new TextView(this); + b.setText(Settings.isHookup() ? "HOOK" : "SDL3"); + b.setTextColor(Color.WHITE); + b.setGravity(Gravity.CENTER); + b.setTextSize(12f); + b.setWidth(bubbleSize); + b.setHeight(bubbleSize); + b.setBackground(bubbleBg(Settings.driverEnabled)); + b.setElevation(dp(4)); + + int type = Build.VERSION.SDK_INT >= 26 + ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + : WindowManager.LayoutParams.TYPE_PHONE; + WindowManager.LayoutParams lp = new WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, type, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT); + lp.gravity = Gravity.TOP | Gravity.START; + lp.x = Settings.bubbleX; + lp.y = Settings.bubbleY; + + final int slop = dp(10); + b.setOnTouchListener(new View.OnTouchListener() { + float downX, downY; int startX, startY; boolean moved; + @Override public boolean onTouch(View v, MotionEvent e) { + switch (e.getAction()) { + case MotionEvent.ACTION_DOWN: + downX = e.getRawX(); downY = e.getRawY(); + startX = bubbleLp.x; startY = bubbleLp.y; moved = false; longPressFired = false; + bubbleLongPress = () -> { longPressFired = true; openBridgeApp(); }; + main.postDelayed(bubbleLongPress, 3000); + return true; + case MotionEvent.ACTION_MOVE: { + int nx = startX + (int) (e.getRawX() - downX); + int ny = startY + (int) (e.getRawY() - downY); + if (!moved && Math.abs(nx - startX) + Math.abs(ny - startY) > slop) { + moved = true; + if (bubbleLongPress != null) main.removeCallbacks(bubbleLongPress); + showRemoveZone(); + } + bubbleLp.x = nx; bubbleLp.y = ny; + try { wm.updateViewLayout(bubble, bubbleLp); } catch (RuntimeException ignored) { } + if (moved) highlightRemoveZone(overRemoveZone()); + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + if (bubbleLongPress != null) main.removeCallbacks(bubbleLongPress); + boolean removed = false; + if (moved && overRemoveZone()) { removeBubbleByUser(); removed = true; } + hideRemoveZone(); + if (!removed) { + Settings.bubbleX = bubbleLp.x; Settings.bubbleY = bubbleLp.y; Settings.save(); + if (!moved && !longPressFired) onBubbleTap(); + } + return true; + } + return false; + } + }); + + try { + wm.addView(b, lp); + bubble = b; + bubbleLp = lp; + } catch (RuntimeException e) { + bubble = null; + } + } + + private void openBridgeApp() { + try { + startActivity(new Intent(BridgeService.this, BridgeActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } catch (RuntimeException ignored) { } + } + + // A single tap waits this long for a second tap before it acts, so a double tap doesn't also flip the driver. + private static final long DOUBLE_TAP_MS = 300; + private boolean tapPending; + + private void onBubbleTap() { + if (tapPending) { + tapPending = false; // the pending single tap sees this and does nothing + toggleHookup(); + } else { + tapPending = true; + main.postDelayed(() -> { if (tapPending) { tapPending = false; toggleDriver(); } }, DOUBLE_TAP_MS); + } + } + + /** + * Double-tap action: Steam Native Hook-up on/off. Hook-up needs the SDL3 driver, so switching it on also + * switches the driver on; switching it off puts back what was there before -- including the driver being + * off (red bubble), so the controller goes back to Android / DroidDeck's own input instead of SDL3. + */ + private void toggleHookup() { + boolean on = !Settings.isHookup(); + boolean freeze = false; + if (on) { + Settings.lastPresentAs = Settings.presentAs; + Settings.driverBeforeHookup = Settings.driverEnabled; + Settings.presentAs = Settings.PRESENT_HOOKUP; + if (!Settings.driverEnabled) { + Settings.driverEnabled = true; + try { SdlEngine.setHidFrozen(false); } catch (Throwable ignored) { } + } + } else { + Settings.presentAs = Settings.lastPresentAs; + if (!Settings.driverBeforeHookup && Settings.driverEnabled) { + Settings.driverEnabled = false; + freeze = true; + } + } + Settings.save(); + NativeBridge.applyDriver(); // also applies hook-up (native code releases the hooked-up controllers) + if (freeze) { + try { SdlEngine.setHidFrozen(true); } catch (Throwable ignored) { } + } + updateBubble(); + try { + android.widget.Toast.makeText(this, on + ? "Steam Native Hook-up ON: Steam gets the real USB controllers" + : !Settings.driverEnabled ? "Steam Native Hook-up OFF: SDL3 driver off again" + : "Steam Native Hook-up OFF: back to " + Settings.PRESENT_NAMES[Settings.presentAs], + android.widget.Toast.LENGTH_SHORT).show(); + } catch (RuntimeException ignored) { } + } + + /** Recolor the bubble after a settings change made elsewhere (the app screen). */ + static void refreshBubble() { + BridgeService s = instance; + if (s != null) s.updateBubble(); + } + + /** Tap action: flip the SDL3 forced driver and recolor the bubble. */ + private void toggleDriver() { + Settings.driverEnabled = !Settings.driverEnabled; + Settings.save(); + NativeBridge.applyDriver(); + try { SdlEngine.setHidFrozen(!Settings.driverEnabled); } catch (Throwable ignored) { } + updateBubble(); + } + + private void removeBubbleByUser() { + Settings.bubble = false; + Settings.save(); + hideBubble(); + } + + private void hideBubble() { + if (bubble != null && wm != null) { + try { wm.removeView(bubble); } catch (RuntimeException ignored) { } + } + bubble = null; + hideRemoveZone(); + } + + // ---- remove zone (the drop target at the bottom) ---- + + private void showRemoveZone() { + if (removeZone != null || wm == null) return; + int zs = dp(64); + TextView z = new TextView(this); + z.setText("✕"); + z.setTextColor(Color.WHITE); + z.setGravity(Gravity.CENTER); + z.setTextSize(22f); + z.setWidth(zs); + z.setHeight(zs); + z.setBackground(removeZoneBg(false)); + WindowManager.LayoutParams lp = new WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, + bubbleLp.type, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT); + lp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + lp.y = dp(48); + try { wm.addView(z, lp); removeZone = z; } catch (RuntimeException e) { removeZone = null; } + } + + private void hideRemoveZone() { + if (removeZone != null && wm != null) { + try { wm.removeView(removeZone); } catch (RuntimeException ignored) { } + } + removeZone = null; + } + + private void highlightRemoveZone(boolean over) { + if (removeZone instanceof TextView) { + removeZone.setBackground(removeZoneBg(over)); + ((TextView) removeZone).setScaleX(over ? 1.25f : 1f); + ((TextView) removeZone).setScaleY(over ? 1.25f : 1f); + } + } + + private GradientDrawable removeZoneBg(boolean over) { + GradientDrawable g = new GradientDrawable(); + g.setShape(GradientDrawable.OVAL); + g.setColor(over ? 0xFFC62828 : 0xCC424242); + g.setStroke(dp(2), 0xCCFFFFFF); + return g; + } + + /** True when the bubble's centre is over the bottom remove zone. */ + private boolean overRemoveZone() { + if (removeZone == null) return false; + android.util.DisplayMetrics dm = getResources().getDisplayMetrics(); + float bx = bubbleLp.x + bubbleSize / 2f; + float by = bubbleLp.y + bubbleSize / 2f; + float zx = dm.widthPixels / 2f; + float zy = dm.heightPixels - dp(48) - dp(32); // zone centre (bottom margin + half height) + float dx = bx - zx, dy = by - zy; + return Math.sqrt(dx * dx + dy * dy) < dp(80); + } + + private GradientDrawable bubbleBg(boolean driverOn) { + GradientDrawable g = new GradientDrawable(); + g.setShape(GradientDrawable.OVAL); + // blue = Steam Native Hook-up, green = SDL3 driver on, red = off + g.setColor(!driverOn ? 0xFFC62828 : Settings.isHookup() ? 0xFF1565C0 : 0xFF2E7D32); + g.setStroke(dp(2), 0xCCFFFFFF); + return g; + } + + private void updateBubble() { + if (bubble == null) return; + main.post(() -> { + if (bubble == null) return; + bubble.setBackground(bubbleBg(Settings.driverEnabled)); + bubble.setText(Settings.isHookup() ? "HOOK" : "SDL3"); + }); + } + + private int dp(int v) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()); + } + + // ---------- client -> controller ---------- + + /** Applies the touchpad swap / invert settings to one trackpad's [down, x, y]. */ + private static void orient(float[] p, int player) { + if (Settings.touchSwap[player]) { float t = p[1]; p[1] = p[2]; p[2] = t; } + if (Settings.touchInvX[player]) p[1] = -p[1]; + if (Settings.touchInvY[player]) p[2] = -p[2]; + } + + private void mergeOutput(Bundle b) { + if (b == null) return; + if (b.containsKey("rl") || b.containsKey("rr")) { + NativeBridge.setRumble(b.getInt("slot", 0), clamp(b.getInt("rl")), clamp(b.getInt("rr"))); + } + // A client that sets the lightbar overrides the saved colour until the bridge screen changes it again. + if (b.containsKey("r") || b.containsKey("g") || b.containsKey("b")) { + int ps = Math.max(0, Math.min(Settings.PLAYERS - 1, b.getInt("slot", 0))); + if (b.containsKey("r")) Settings.lightR[ps] = clamp(b.getInt("r")); + if (b.containsKey("g")) Settings.lightG[ps] = clamp(b.getInt("g")); + if (b.containsKey("b")) Settings.lightB[ps] = clamp(b.getInt("b")); + NativeBridge.applyOutput(ps); + } + } + + private static int clamp(int v) { return Math.max(0, Math.min(255, v)); } + + // ---------- controller -> clients ---------- + + // A frame goes to DroidDeck only when the controller state changed (and for FRAME_SETTLE_MS after a change, so + // DroidDeck's stick throttle always gets the final position), plus a keep-alive every FRAME_KEEPALIVE_MS. The + // loop runs at up to 1000 Hz but most controllers report at 250-500 Hz, and an idle controller not at all: + // sending every pass only fills DroidDeck's queue with copies. + private static final long FRAME_SETTLE_MS = 20, FRAME_KEEPALIVE_MS = 100; + private final float[][] lastFrame = new float[NativeBridge.MAX_PADS][]; + private final long[] lastFrameChangeMs = new long[NativeBridge.MAX_PADS], lastFrameSentMs = new long[NativeBridge.MAX_PADS]; + + private boolean frameWorthSending(float[] f, int slot) { + if (slot < 0 || slot >= NativeBridge.MAX_PADS) return true; + long now = SystemClock.uptimeMillis(); + float[] last = lastFrame[slot]; + if (last == null || last.length != f.length) last = lastFrame[slot] = new float[f.length]; + if (!java.util.Arrays.equals(f, last)) { + System.arraycopy(f, 0, last, 0, f.length); // f is native's reused array: keep a copy + lastFrameChangeMs[slot] = now; + } else if (now - lastFrameChangeMs[slot] > FRAME_SETTLE_MS && now - lastFrameSentMs[slot] < FRAME_KEEPALIVE_MS) { + return false; + } + lastFrameSentMs[slot] = now; + return true; + } + + @Override public void onFrame(float[] f, int slot) { + if (slot >= 0 && slot < NativeBridge.MAX_PADS && f.length >= NativeBridge.FRAME_LEN) { + lastBattery[slot] = (int) f[19]; + calibrationSample(f, slot); + } + if (clients.isEmpty() || f.length < NativeBridge.FRAME_LEN) return; + if (!frameWorthSending(f, slot)) return; + Bundle b = new Bundle(); + b.putByteArray("raw", new byte[0]); + b.putLong("t", SystemClock.elapsedRealtimeNanos()); + int rawMask = (int) f[0]; + int mask2 = f.length >= 22 ? (int) f[21] : 0; // extra buttons (MISC2..6, e.g. Flydigi C/Z) + int mask = Settings.applyGripRemap(rawMask, mask2, slot); // user button -> L4/R4/L5/R5 + b.putInt("mask", mask); + b.putInt("lx", (int) f[1]); b.putInt("ly", (int) f[2]); + b.putInt("rx", (int) f[3]); b.putInt("ry", (int) f[4]); + b.putInt("l2", (int) f[5]); b.putInt("r2", (int) f[6]); + float gk = Settings.gyroGain[slot] / 100f; + int inv = Settings.gyroInvert[slot]; + float dz = Settings.gyroDeadzone[slot]; + float[] bias = Settings.gyroBias[slot]; + int[] gmap = Settings.gyroMap[slot]; + boolean accFollows = Settings.accelFollows[slot]; + float[] gy = {f[7] * RAD_TO_DEG, f[8] * RAD_TO_DEG, f[9] * RAD_TO_DEG}; // deg/s + float[] ac = {f[10] / G, f[11] / G, f[12] / G}; // g + for (int k = 0; k < 3; k++) { + gy[k] -= bias[k]; // remove resting drift (from Calibrate) + if (Math.abs(gy[k]) < dz) gy[k] = 0f; // deadzone, in real deg/s (before gain) + } + float[] go = new float[3], ao = new float[3]; + for (int i = 0; i < 3; i++) { + float sign = (inv & (1 << i)) != 0 ? -1f : 1f; + int src = gmap[i]; + go[i] = gy[src] * gk * sign; + ao[i] = accFollows ? ac[src] * sign : ac[i]; + } + // Sony family only (DualShock 4 = 3, DualSense = 4, DualSense Edge = 5): their report has ONE + // full-width multitouch pad and remaps the IMU axes (X, Z, -Y) vs the Deck IMU. The Steam Deck (0) + // and Steam Controller (1) have two trackpads and use the Deck IMU convention, so they are left as-is. + int presentType = Settings.PRESENT_PAD_TYPE[Settings.presentAs]; + boolean sony = Settings.isSonyPadType(presentType); + if (sony) { + float tg = go[1]; go[1] = -go[2]; go[2] = tg; + float ta = ao[1]; ao[1] = -ao[2]; ao[2] = ta; + } + b.putFloatArray("gyro", go); + b.putFloatArray("accel", ao); + + + // Raw fingers (kept for older clients) ... + List t = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + if (f[13 + 3 * i] > 0.5f) { t.add(i + 1); t.add((int) f[14 + 3 * i]); t.add((int) f[15 + 3 * i]); } + } + int[] touch = new int[t.size()]; + for (int i = 0; i < touch.length; i++) touch[i] = t.get(i); + b.putIntArray("touch", touch); + + // ... and the decoupled version: each half is its own trackpad with local -1..1 coordinates. + float[] padL = new float[3], padR = new float[3]; + int mode = Settings.touchMode[slot]; + // A 2015 Steam Controller has two real trackpads: always split the touchpad into left/right halves. + if (presentType == Settings.PAD_TYPE_STEAM_2015 && mode != Settings.TOUCH_OFF) mode = Settings.TOUCH_SPLIT; + // A native Sony pad (DualShock 4, DualSense, DualSense Edge) has ONE full-width multitouch + // surface, so ignore the Deck split there: keep SDL's finger slots (finger 0 is the primary + // touch the game tracks) and map both across the whole pad. padL carries finger 0, padR finger 1. + // Only the Steam Deck pad (type 0) keeps the left/right split, since it has two real trackpads. + if (mode != Settings.TOUCH_OFF) { + if (sony) { + for (int i = 0; i < 2; i++) { + if (f[13 + 3 * i] <= 0.5f) continue; + float[] p = (i == 0) ? padL : padR; + p[0] = 1f; p[1] = f[14 + 3 * i] / PAD_W * 2f - 1f; p[2] = f[15 + 3 * i] / PAD_H * 2f - 1f; + } + } else for (int i = 0; i < 2; i++) { + if (f[13 + 3 * i] <= 0.5f) continue; + float x = f[14 + 3 * i], y = f[15 + 3 * i]; + float ny = y / PAD_H * 2f - 1f; + if (mode == Settings.TOUCH_SINGLE) { + if (padR[0] == 0f) { padR[0] = 1f; padR[1] = x / PAD_W * 2f - 1f; padR[2] = ny; } + } else if (x < PAD_W / 2f) { + if (padL[0] == 0f) { padL[0] = 1f; padL[1] = x / (PAD_W / 2f) * 2f - 1f; padL[2] = ny; } + } else { + if (padR[0] == 0f) { padR[0] = 1f; padR[1] = (x - PAD_W / 2f) / (PAD_W / 2f) * 2f - 1f; padR[2] = ny; } + } + } + } + int click = -1; // -1 none, 0 left trackpad, 1 right trackpad + if (((mask >> 13) & 1) != 0 && mode != Settings.TOUCH_OFF) { + click = (mode == Settings.TOUCH_SPLIT && padL[0] > 0f && padR[0] == 0f) ? 0 : 1; + } + // Button -> trackpad touch / click remaps (targets 4..7) + int[] rm = Settings.remap[slot]; + if (Settings.srcDown(rawMask, mask2, rm[6]) && padL[0] == 0f) { padL[0] = 1f; padL[1] = 0f; padL[2] = 0f; } + if (Settings.srcDown(rawMask, mask2, rm[7]) && padR[0] == 0f) { padR[0] = 1f; padR[1] = 0f; padR[2] = 0f; } + if (Settings.srcDown(rawMask, mask2, rm[4])) { if (padL[0] == 0f) padL[0] = 1f; click = 0; } + if (Settings.srcDown(rawMask, mask2, rm[5])) { if (padR[0] == 0f) padR[0] = 1f; click = 1; } + orient(padL, slot); + orient(padR, slot); + b.putFloatArray("padL", padL); + b.putFloatArray("padR", padR); + b.putInt("padClick", click); + + int state = (int) f[20]; + b.putInt("slot", slot); + b.putInt("ptype", Settings.PRESENT_PAD_TYPE[Settings.presentAs]); + b.putInt("vid", NativeBridge.vids[slot]); + b.putInt("pid", NativeBridge.pids[slot]); + b.putInt("battery", Math.max(0, (int) f[19])); + b.putString("charge", state == 3 ? "charging" : state == 4 ? "full" : state == 1 ? "discharging" : "unknown"); + for (Messenger c : clients) { + try { + Message m = Message.obtain(null, MSG_FRAME); + m.setData(b); + c.send(m); + } catch (RemoteException e) { clients.remove(c); clientCount = clients.size(); } + } + } + + @Override public void onPadGone(int slot) { + for (Messenger c : clients) { + try { + Bundle b = new Bundle(); + b.putInt("slot", slot); + Message m = Message.obtain(null, MSG_PAD_GONE); + m.setData(b); + c.send(m); + } catch (RemoteException e) { clients.remove(c); } + } + } + + // ---------- Steam Controller (2026) raw passthrough -> clients ---------- + @Override public void onTritonState(int reportId, byte[] payload) { + if (clients.isEmpty() || payload == null) return; + Bundle b = new Bundle(); + b.putInt("id", reportId); + b.putByteArray("data", payload); + sendAll(MSG_TRITON_STATE, b); + } + + @Override public void onTritonConnected(boolean on, String what) { + for (Messenger c : clients) sendTritonConn(c, on, what); + } + + @Override public void onTritonFeature(long request, byte[] report) { + Bundle b = new Bundle(); + b.putLong("request", request); + if (report != null) b.putByteArray("data", report); + sendAll(MSG_TRITON_FEATURE, b); + } + + private void sendTritonConn(Messenger c, boolean on, String what) { + try { + Bundle b = new Bundle(); + b.putBoolean("on", on); + b.putString("what", what); + Message m = Message.obtain(null, MSG_TRITON_CONN); + m.setData(b); + c.send(m); + } catch (RemoteException e) { clients.remove(c); clientCount = clients.size(); } + } + + /** SDL3's loop must run for controllers to work: it runs in this service, no screen needed. */ + private void ensureBridgeLoop() { + SdlEngine.start(this); + } + + // ---------- Steam Native Hook-up -> clients ---------- + @Override public void onHookupAdded(int slot, Bundle identity) { sendAll(MSG_HOOKUP_ADD, identity); } + + @Override public void onHookupInput(int slot, byte[] report) { + if (clients.isEmpty() || report == null) return; + Bundle b = new Bundle(); + b.putInt("slot", slot); + b.putByteArray("data", report); + sendAll(MSG_HOOKUP_INPUT, b); + } + + @Override public void onBridgeLog(String line) { + Bundle b = new Bundle(); + b.putString("text", line); + sendAll(MSG_BRIDGE_LOG, b); + } + + @Override public void onHookupRemoved(int slot) { + Bundle b = new Bundle(); + b.putInt("slot", slot); + sendAll(MSG_HOOKUP_REMOVE, b); + } + + @Override public void onHookupReply(int slot, long request, byte[] report) { + Bundle b = new Bundle(); + b.putInt("slot", slot); + b.putLong("request", request); + if (report != null) b.putByteArray("data", report); + sendAll(MSG_HOOKUP_REPLY, b); + } + + // ---------- DroidDeck's SDL3Bridge settings tab ---------- + private final int[] lastBattery = { -1, -1, -1, -1 }; + + // Gyro calibration started from DroidDeck: averages the resting gyro of that player's own controller. + private static final int CALIB_SAMPLES = 180; // ~0.7 s at 4 ms per frame + private final Object calibLock = new Object(); + private int calibPlayer = -1, calibCount; + private final float[] calibSum = new float[3]; + + private void startCalibration(int p) { + synchronized (calibLock) { calibPlayer = p; calibCount = 0; calibSum[0] = calibSum[1] = calibSum[2] = 0f; } + } + + private void calibrationSample(float[] f, int slot) { + synchronized (calibLock) { + if (calibPlayer != slot) return; + calibSum[0] += f[7]; calibSum[1] += f[8]; calibSum[2] += f[9]; + if (++calibCount < CALIB_SAMPLES) return; + float[] b = Settings.gyroBias[slot]; + for (int k = 0; k < 3; k++) b[k] = calibSum[k] / CALIB_SAMPLES * RAD_TO_DEG; + calibPlayer = -1; + } + Settings.save(); + } + + /** Everything the tab shows: settings, their choices (so DroidDeck needs no copy of them) and live status. */ + private Bundle settingsSnapshot() { + Bundle b = new Bundle(); + b.putInt("v", 1); + b.putString("status", NativeBridge.lastStatus); + b.putBoolean("overlayAllowed", android.provider.Settings.canDrawOverlays(this)); + b.putBoolean("notifications", getSystemService(NotificationManager.class).areNotificationsEnabled()); + b.putString("engineError", SdlEngine.error == null ? "" : SdlEngine.error); + b.putString("engineMode", SdlEngine.mode); + b.putBoolean("fastPath", NativeBridge.hookupShmInode() != 0); + b.putString("triton", NativeBridge.tritonConnected ? NativeBridge.tritonWhat : ""); + String[] padNames = new String[NativeBridge.MAX_PADS], padIds = new String[NativeBridge.MAX_PADS]; + String[] hookNames = new String[NativeBridge.MAX_PADS], hookIds = new String[NativeBridge.MAX_PADS]; + for (int i = 0; i < NativeBridge.MAX_PADS; i++) { + String n = NativeBridge.names[i]; + padNames[i] = n == null ? "" : n; + padIds[i] = n == null ? "" : String.format("%04x:%04x", NativeBridge.vids[i], NativeBridge.pids[i]); + Bundle h = NativeBridge.hookupIdentity[i]; + hookNames[i] = h == null ? "" : h.getString("name", ""); + hookIds[i] = h == null ? "" : String.format("%04x:%04x", h.getInt("vid"), h.getInt("pid")); + } + b.putStringArray("padNames", padNames); + b.putStringArray("padIds", padIds); + b.putIntArray("padBattery", lastBattery.clone()); + b.putStringArray("hookNames", hookNames); + b.putStringArray("hookIds", hookIds); + synchronized (calibLock) { b.putInt("calibrating", calibPlayer); } + // global settings + b.putInt("presentAs", Settings.presentAs); + b.putBoolean("driverEnabled", Settings.driverEnabled); + b.putBoolean("forceUsb", Settings.forceUsb); + b.putInt("pollMs", Settings.pollMs); + b.putBoolean("bubble", Settings.bubble); + // choices + b.putStringArray("presentNames", Settings.PRESENT_NAMES); + b.putStringArray("rateNames", Settings.RATE_NAMES); + b.putIntArray("rateMs", Settings.RATE_MS); + b.putStringArray("touchNames", Settings.TOUCH_NAMES); + b.putStringArray("srcNames", Settings.SRC_NAMES); + b.putStringArray("remapTargets", Settings.REMAP_TARGETS); + b.putStringArray("axisNames", Settings.AXIS_NAMES); + b.putStringArray("gyroInvNames", Settings.GYRO_INV_NAMES); + b.putStringArray("playerLedNames", Settings.PLAYER_NAMES); + // per player (index p, or p * n + i for the ones with several entries) + int P = Settings.PLAYERS; + int[] gyroMap = new int[P * 3], remap = new int[P * Settings.REMAP_COUNT]; + float[] bias = new float[P * 3]; + for (int p = 0; p < P; p++) { + for (int i = 0; i < 3; i++) { gyroMap[p * 3 + i] = Settings.gyroMap[p][i]; bias[p * 3 + i] = Settings.gyroBias[p][i]; } + for (int i = 0; i < Settings.REMAP_COUNT; i++) remap[p * Settings.REMAP_COUNT + i] = Settings.remap[p][i]; + } + b.putIntArray("touchMode", Settings.touchMode.clone()); + b.putBooleanArray("touchInvX", Settings.touchInvX.clone()); + b.putBooleanArray("touchInvY", Settings.touchInvY.clone()); + b.putBooleanArray("touchSwap", Settings.touchSwap.clone()); + b.putIntArray("gyroGain", Settings.gyroGain.clone()); + b.putIntArray("gyroInvert", Settings.gyroInvert.clone()); + b.putIntArray("gyroDeadzone", Settings.gyroDeadzone.clone()); + b.putIntArray("gyroMap", gyroMap); + b.putFloatArray("gyroBias", bias); + b.putBooleanArray("accelFollows", Settings.accelFollows.clone()); + b.putIntArray("remap", remap); + b.putIntArray("lightR", Settings.lightR.clone()); + b.putIntArray("lightG", Settings.lightG.clone()); + b.putIntArray("lightB", Settings.lightB.clone()); + b.putIntArray("brightness", Settings.brightness.clone()); + b.putIntArray("playerLed", Settings.playerLed.clone()); + return b; + } + + private static int clampInt(int v, int lo, int hi) { return Math.max(lo, Math.min(hi, v)); } + + /** One change from DroidDeck's tab, with the same effect as the same change on this app's own screen. */ + private void applyRemote(Bundle d) { + if (d == null) return; + String key = d.getString("key", ""); + int p = clampInt(d.getInt("p", 0), 0, Settings.PLAYERS - 1); + int i = d.getInt("i", 0); + int v = d.getInt("int", 0); + boolean on = d.getBoolean("bool", false); + switch (key) { + case "presentAs": + Settings.presentAs = clampInt(v, 0, Settings.PRESENT_NAMES.length - 1); + if (!Settings.isHookup()) Settings.lastPresentAs = Settings.presentAs; + Settings.save(); + NativeBridge.applyHookup(); + updateBubble(); + break; + case "driverEnabled": + if (Settings.driverEnabled != on) toggleDriver(); + break; + case "forceUsb": + Settings.forceUsb = on; + Settings.save(); + NativeBridge.applyForce(); + break; + case "pollMs": + Settings.pollMs = clampInt(v, 1, 16); + Settings.save(); + NativeBridge.applyRate(); + break; + case "bubble": + if (on && !android.provider.Settings.canDrawOverlays(this)) break; // needs the permission first + Settings.bubble = on; + Settings.save(); + if (on) showBubble(); else hideBubble(); + break; + case "touchMode": Settings.touchMode[p] = clampInt(v, 0, 2); Settings.save(); break; + case "touchInvX": Settings.touchInvX[p] = on; Settings.save(); break; + case "touchInvY": Settings.touchInvY[p] = on; Settings.save(); break; + case "touchSwap": Settings.touchSwap[p] = on; Settings.save(); break; + case "gyroGain": Settings.gyroGain[p] = clampInt(v, 1, 800); Settings.save(); break; + case "gyroInvert": Settings.gyroInvert[p] = clampInt(v, 0, 7); Settings.save(); break; + case "gyroDeadzone": Settings.gyroDeadzone[p] = clampInt(v, 0, 50); Settings.save(); break; + case "gyroMap": + if (i >= 0 && i < 3) { Settings.gyroMap[p][i] = clampInt(v, 0, 2); Settings.save(); } + break; + case "accelFollows": Settings.accelFollows[p] = on; Settings.save(); break; + case "remap": + if (i >= 0 && i < Settings.REMAP_COUNT) { Settings.remap[p][i] = clampInt(v, 0, Settings.SRC_NAMES.length - 1); Settings.save(); } + break; + case "lightR": Settings.lightR[p] = clampInt(v, 0, 255); Settings.save(); NativeBridge.applyOutput(p); break; + case "lightG": Settings.lightG[p] = clampInt(v, 0, 255); Settings.save(); NativeBridge.applyOutput(p); break; + case "lightB": Settings.lightB[p] = clampInt(v, 0, 255); Settings.save(); NativeBridge.applyOutput(p); break; + case "brightness": Settings.brightness[p] = clampInt(v, 0, 100); Settings.save(); NativeBridge.applyOutput(p); break; + case "playerLed": Settings.playerLed[p] = clampInt(v, 0, 4); Settings.save(); NativeBridge.applyOutput(p); break; + case "calibrate": startCalibration(p); break; + case "resetCalibration": + synchronized (calibLock) { if (calibPlayer == p) calibPlayer = -1; } + Settings.gyroBias[p][0] = Settings.gyroBias[p][1] = Settings.gyroBias[p][2] = 0f; + Settings.save(); + break; + default: + android.util.Log.w("SDL3Bridge", "settings: unknown key from DroidDeck: " + key); + } + } + + private void sendOne(Messenger c, int what, Bundle b) { + try { + Message m = Message.obtain(null, what); + m.setData(b); + c.send(m); + } catch (RemoteException e) { clients.remove(c); clientCount = clients.size(); } + } + + private void sendAll(int what, Bundle b) { + for (Messenger c : clients) { + try { + Message m = Message.obtain(null, what); + m.setData(b); + c.send(m); + } catch (RemoteException e) { clients.remove(c); clientCount = clients.size(); } + } + } + + @Override public void onStatus(String s) { + connectedState = s != null && s.startsWith("Connected"); + updateBubble(); + main.post(() -> refreshNotification(s)); + for (Messenger c : clients) sendStatus(c, s); + } + + private void sendStatus(Messenger c, String s) { + try { + Bundle b = new Bundle(); + b.putString("text", s); + Message m = Message.obtain(null, MSG_STATUS); + m.setData(b); + c.send(m); + } catch (RemoteException e) { clients.remove(c); } + } +} diff --git a/app/src/main/java/com/example/dsedge/ControllerView.java b/app/src/main/java/com/example/dsedge/ControllerView.java new file mode 100644 index 000000000..69b4328f6 --- /dev/null +++ b/app/src/main/java/com/example/dsedge/ControllerView.java @@ -0,0 +1,329 @@ +package com.example.dsedge; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.view.View; + +/** + * Universal live gamepad diagram for any SDL3 controller (DualSense / Edge, Xbox, Switch Pro, + * Steam Controller 2015 & 2026, generic pads). It draws only the controls the controller actually + * reports (via NativeBridge caps) and labels the face / menu buttons for the controller kind. + * + * Orange = held now, green = pressed at least once (works), grey = present but untested. + */ +public class ControllerView extends View { + private static final int ORANGE = 0xFFFF9800, GREEN = 0xFF2E7D32, GRAY = 0xFF37474F, LINE = 0xFF90A4AE; + + // frame-mask bit numbers + private static final int B_SOUTH = 0, B_EAST = 1, B_WEST = 2, B_NORTH = 3, B_L1 = 4, B_R1 = 5, + B_L2 = 6, B_R2 = 7, B_BACK = 8, B_START = 9, B_L3 = 10, B_R3 = 11, B_GUIDE = 12, + B_TOUCH = 13, B_MISC = 14, B_UP = 15, B_DOWN = 16, B_LEFT = 17, B_RIGHT = 18, + B_P1 = 19, B_P2 = 20, B_P3 = 21, B_P4 = 22; + + // tested-flag bits (not button bits) + private static final int T_LS = 1, T_RS = 2, T_L2 = 4, T_R2 = 8, T_GYRO = 16, T_ACCEL = 32, T_TOUCH = 64; + + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF rect = new RectF(); + + private volatile float[] frame = new float[NativeBridge.FRAME_LEN]; + private volatile int mask; + private volatile int testedButtons; + private volatile int testedFlags; + private volatile boolean connected; + private long lastInvalidate; + + // capabilities (default: assume everything until the controller reports otherwise) + private volatile int present = (1 << 23) - 1; + private volatile int axes = 0xF; + private volatile int touchpads = 1; + private volatile String kind = "generic"; + + public ControllerView(Context c) { + super(c); + setBackgroundColor(0xFF111111); + stroke.setStyle(Paint.Style.STROKE); + stroke.setStrokeWidth(0.5f); + text.setTextAlign(Paint.Align.CENTER); + text.setColor(0xFFECEFF1); + frame[1] = frame[2] = frame[3] = frame[4] = 128f; + } + + /** Capabilities of the displayed controller, from the native layer. */ + public void setCaps(int present, int axes, int touchpads, String kind) { + this.present = present; + this.axes = axes; + this.touchpads = touchpads; + this.kind = kind == null ? "generic" : kind; + postInvalidate(); + } + + /** Called from any thread with each native frame. */ + public void update(float[] f) { + if (f == null || f.length < NativeBridge.FRAME_LEN) return; + float[] c = f.clone(); + frame = c; + connected = true; + int m = (int) c[0]; + mask = m; + testedButtons |= m; + int t = testedFlags; + if (Math.abs(c[1] - 128f) > 60f || Math.abs(c[2] - 128f) > 60f) t |= T_LS; + if (Math.abs(c[3] - 128f) > 60f || Math.abs(c[4] - 128f) > 60f) t |= T_RS; + if (c[5] > 100f) t |= T_L2; + if (c[6] > 100f) t |= T_R2; + if (Math.abs(c[7]) + Math.abs(c[8]) + Math.abs(c[9]) > 0.6f) t |= T_GYRO; + if (Math.abs(c[10]) + Math.abs(c[11]) + Math.abs(c[12]) > 3f) t |= T_ACCEL; + if (c[13] > 0.5f || c[16] > 0.5f) t |= T_TOUCH; + testedFlags = t; + long now = System.nanoTime(); + if (now - lastInvalidate > 16_000_000L) { + lastInvalidate = now; + postInvalidate(); + } + } + + public void setDisconnected() { connected = false; mask = 0; postInvalidate(); } + + public void resetTested() { testedButtons = 0; testedFlags = 0; postInvalidate(); } + + /** One-line summary of what has been verified so far (only counts controls the pad has). */ + public String summary() { + int p = present & ((1 << 23) - 1); + int n = Integer.bitCount(p); + int done = Integer.bitCount(testedButtons & p); + int t = testedFlags; + StringBuilder sb = new StringBuilder("Tested: buttons ").append(done).append('/').append(n); + if ((axes & 1) != 0 || (axes & 2) != 0) sb.append(" sticks ").append(mark(stickOk(t))); + if ((axes & 12) != 0) sb.append(" triggers ").append(mark(trigOk(t))); + sb.append(" gyro ").append(mark((t & T_GYRO) != 0)); + sb.append(" accel ").append(mark((t & T_ACCEL) != 0)); + if (touchpads > 0) sb.append(" touchpad ").append(mark((t & T_TOUCH) != 0)); + return sb.toString(); + } + + private boolean stickOk(int t) { + boolean l = (axes & 1) == 0 || (t & T_LS) != 0; + boolean r = (axes & 2) == 0 || (t & T_RS) != 0; + return l && r; + } + + private boolean trigOk(int t) { + boolean l = (axes & 4) == 0 || (t & T_L2) != 0; + boolean r = (axes & 8) == 0 || (t & T_R2) != 0; + return l && r; + } + + private static String mark(boolean ok) { return ok ? "OK" : "-"; } + + @Override protected void onMeasure(int w, int h) { + int width = MeasureSpec.getSize(w); + setMeasuredDimension(width, (int) (width * 0.82f)); + } + + // ---------------- labels per controller kind ---------------- + + private String[] faceLabels() { + switch (kind) { + case "ps": return new String[] {"✕", "●", "■", "▲"}; // ✕ ● ■ ▲ + case "nintendo": return new String[] {"B", "A", "Y", "X"}; + default: return new String[] {"A", "B", "X", "Y"}; // xbox / steam / generic + } + } + + private String backLabel() { switch (kind) { case "ps": return "Create"; case "xbox": return "View"; case "nintendo": return "-"; case "steam": return "Back"; default: return "Back"; } } + private String startLabel() { switch (kind) { case "ps": return "Options"; case "xbox": return "Menu"; case "nintendo": return "+"; case "steam": return "Start"; default: return "Start"; } } + private String guideLabel() { switch (kind) { case "ps": return "PS"; case "nintendo": return "Home"; case "steam": return "Steam"; default: return "Guide"; } } + private String miscLabel() { switch (kind) { case "ps": return "Mute"; case "xbox": return "Share"; case "nintendo": return "Capt"; default: return "Misc"; } } + + // ---------------- drawing (100 x 72 unit canvas) ---------------- + + private boolean has(int bit) { return ((present >> bit) & 1) != 0; } + + private int col(int bit) { + if (((mask >> bit) & 1) != 0) return ORANGE; + if (((testedButtons >> bit) & 1) != 0) return GREEN; + return GRAY; + } + + private void label(Canvas c, String s, float x, float y, float size) { + text.setTextSize(size); + c.drawText(s, x, y + size * 0.35f, text); + } + + /** Rounded rectangle button, drawn only if present. */ + private void rbtn(Canvas c, float l, float t, float r, float b, int bit, String s) { + if (!has(bit)) return; + rect.set(l, t, r, b); + fill.setColor(col(bit)); + c.drawRoundRect(rect, 1f, 1f, fill); + stroke.setColor(LINE); + c.drawRoundRect(rect, 1f, 1f, stroke); + if (s != null && !s.isEmpty()) label(c, s, (l + r) / 2f, (t + b) / 2f, 2.2f); + } + + private void cbtn(Canvas c, float cx, float cy, float rad, int bit, String s) { + if (!has(bit)) return; + fill.setColor(col(bit)); + c.drawCircle(cx, cy, rad, fill); + stroke.setColor(LINE); + c.drawCircle(cx, cy, rad, stroke); + label(c, s, cx, cy, 2.4f); + } + + private void meter(Canvas c, float l, float t, float r, float b, float value01, boolean tested) { + rect.set(l, t, r, b); + fill.setColor(0xFF263238); + c.drawRect(rect, fill); + rect.set(l, t, l + (r - l) * Math.max(0f, Math.min(1f, value01)), b); + fill.setColor(value01 > 0.02f ? ORANGE : (tested ? GREEN : GRAY)); + c.drawRect(rect, fill); + rect.set(l, t, r, b); + stroke.setColor(LINE); + c.drawRect(rect, stroke); + } + + private void stick(Canvas c, float cx, float cy, float x, float y, int clickBit, boolean tested) { + boolean clickable = has(clickBit); + boolean down = clickable && ((mask >> clickBit) & 1) != 0; + stroke.setColor(down ? ORANGE : (tested ? 0xFF66BB6A : LINE)); + stroke.setStrokeWidth(1f); + c.drawCircle(cx, cy, 9f, stroke); + stroke.setStrokeWidth(0.5f); + fill.setColor(down ? ORANGE : (tested ? GREEN : GRAY)); + float dx = (x - 128f) / 128f * 6f, dy = (y - 128f) / 128f * 6f; + c.drawCircle(cx + dx, cy + dy, 3.6f, fill); + } + + @Override protected void onDraw(Canvas c) { + super.onDraw(c); + float u = getWidth() / 100f; + c.save(); + c.scale(u, u); + float[] f = frame; + int t = testedFlags; + String[] fl = faceLabels(); + + // shoulders + triggers + if ((axes & 4) != 0) meter(c, 6, 1, 30, 4, f[5] / 255f, (t & T_L2) != 0); else rbtn(c, 6, 1, 30, 4, B_L2, "L2"); + if ((axes & 8) != 0) meter(c, 70, 1, 94, 4, f[6] / 255f, (t & T_R2) != 0); else rbtn(c, 70, 1, 94, 4, B_R2, "R2"); + rbtn(c, 6, 5.5f, 30, 10, B_L1, "L1"); + rbtn(c, 70, 5.5f, 94, 10, B_R1, "R1"); + + drawTouchpads(c); + + rbtn(c, 27, 14, 36, 18, B_BACK, backLabel()); + rbtn(c, 64, 14, 73, 18, B_START, startLabel()); + rbtn(c, 45, 36, 55, 39.5f, B_MISC, miscLabel()); + cbtn(c, 50, 44, 2.8f, B_GUIDE, guideLabel()); + + // d-pad + if (has(B_UP) || has(B_DOWN) || has(B_LEFT) || has(B_RIGHT)) { + rbtn(c, 18, 20, 22, 24, B_UP, ""); + rbtn(c, 18, 32, 22, 36, B_DOWN, ""); + rbtn(c, 14, 26, 18, 30, B_LEFT, ""); + rbtn(c, 22, 26, 26, 30, B_RIGHT, ""); + label(c, "D-pad", 20, 40, 2.2f); + } + + // face buttons (diamond): N top, S bottom, W left, E right + cbtn(c, 80, 21, 3.2f, B_NORTH, fl[3]); + cbtn(c, 80, 35, 3.2f, B_SOUTH, fl[0]); + cbtn(c, 73, 28, 3.2f, B_WEST, fl[2]); + cbtn(c, 87, 28, 3.2f, B_EAST, fl[1]); + + // sticks + if ((axes & 1) != 0) { stick(c, 34, 53, f[1], f[2], B_L3, (t & T_LS) != 0); label(c, has(B_L3) ? "L3" : "L", 34, 64.5f, 2.2f); } + if ((axes & 2) != 0) { stick(c, 66, 53, f[3], f[4], B_R3, (t & T_RS) != 0); label(c, has(B_R3) ? "R3" : "R", 66, 64.5f, 2.2f); } + + // paddles / grips + rbtn(c, 1.5f, 40, 6.5f, 56, B_P1, "L4"); + rbtn(c, 93.5f, 40, 98.5f, 56, B_P2, "R4"); + rbtn(c, 9, 60, 21, 64.5f, B_P3, "L5"); + rbtn(c, 79, 60, 91, 64.5f, B_P4, "R5"); + + // ---- bottom readout strip (own background + divider, clear of the diagram) ---- + rect.set(0, 66.5f, 100, getHeight() / u); + fill.setColor(0xFF0B0E13); + c.drawRect(rect, fill); + stroke.setColor(0xFF2A3340); + c.drawLine(3, 66.5f, 97, 66.5f, stroke); + + String s1 = connected + ? String.format(java.util.Locale.ROOT, "%s · Gyro %.0f, %.0f, %.0f °/s", + kindName(), f[7] * 57.29578f, f[8] * 57.29578f, f[9] * 57.29578f) + : "No controller connected"; + String s2 = connected + ? String.format(java.util.Locale.ROOT, "Accel %.2f, %.2f, %.2f g · Battery %d%%", + f[10] / 9.80665f, f[11] / 9.80665f, f[12] / 9.80665f, (int) Math.max(0f, f[19])) + : ""; + label(c, s1, 50, 71.3f, 2.6f); + if (!s2.isEmpty()) label(c, s2, 50, 76.3f, 2.6f); + + c.restore(); + } + + private String kindName() { + switch (kind) { + case "ps": return "PlayStation"; + case "xbox": return "Xbox"; + case "nintendo": return "Nintendo"; + case "steam": return "Steam Controller"; + default: return "Gamepad"; + } + } + + + /** Draws 0, 1 or 2 trackpads with their finger dots. */ + private void drawTouchpads(Canvas c) { + float[] f = frame; + if (touchpads <= 0) { + label(c, "no touchpad", 50, 24, 2.2f); + return; + } + if (touchpads >= 2) { + padRect(c, 30, 12, 48, 34, "L pad", B_TOUCH); + padRect(c, 52, 12, 70, 34, "R pad", B_TOUCH); + fingerOnRect(c, 30, 48, 12, 34, 0, 0xFFFFFFFF); // finger slot 0 -> left pad + fingerOnRect(c, 52, 70, 12, 34, 1, 0xFF4FC3F7); // finger slot 1 -> right pad + return; + } + // single touchpad + padRect(c, 36, 12, 64, 34, null, B_TOUCH); + if (Settings.touchMode[0] == Settings.TOUCH_SPLIT) { + stroke.setColor(LINE); + c.drawLine(50, 12, 50, 34, stroke); + label(c, "L pad", 43, 32.5f, 2f); + label(c, "R pad", 57, 32.5f, 2f); + } else if (Settings.touchMode[0] == Settings.TOUCH_OFF) { + label(c, "touchpad off", 50, 32.5f, 2f); + } else { + label(c, "whole pad", 50, 32.5f, 2f); + } + fingerOnRect(c, 36, 64, 12, 34, 0, 0xFFFFFFFF); + fingerOnRect(c, 36, 64, 12, 34, 1, 0xFF4FC3F7); + } + + private void padRect(Canvas c, float l, float t, float r, float b, String lbl, int bit) { + rect.set(l, t, r, b); + fill.setColor(((mask >> bit) & 1) != 0 ? ORANGE : (((testedButtons >> bit) & 1) != 0 ? GREEN : 0xFF263238)); + c.drawRoundRect(rect, 2f, 2f, fill); + stroke.setColor(LINE); + c.drawRoundRect(rect, 2f, 2f, stroke); + if (lbl != null) label(c, lbl, (l + r) / 2f, b - 1.5f, 2f); + } + + private void fingerOnRect(Canvas c, float l, float r, float t, float b, int fingerSlot, int color) { + float[] f = frame; + if (f[13 + 3 * fingerSlot] <= 0.5f) return; + float nx = f[14 + 3 * fingerSlot] / 1919f, ny = f[15 + 3 * fingerSlot] / 1079f; + if (Settings.touchInvX[0]) nx = 1f - nx; + if (Settings.touchInvY[0]) ny = 1f - ny; + fill.setColor(color); + c.drawCircle(l + nx * (r - l), t + ny * (b - t), 1.8f, fill); + } +} diff --git a/app/src/main/java/com/example/dsedge/HookupIdentity.java b/app/src/main/java/com/example/dsedge/HookupIdentity.java new file mode 100644 index 000000000..87aaa9b39 --- /dev/null +++ b/app/src/main/java/com/example/dsedge/HookupIdentity.java @@ -0,0 +1,186 @@ +package com.example.dsedge; + +import android.content.Context; +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; +import android.os.Bundle; +import android.util.Log; + +/** + * Steam Native Hook-up: everything Steam on Linux reads about a USB HID controller before it picks a driver -- + * vendor/product id, release number, name, serial, interface and the HID report descriptor -- taken from the + * real USB device, so DroidDeck can describe a hidraw node that is indistinguishable from it. + */ +final class HookupIdentity { + private static final String TAG = "SDL3Bridge"; + + // Generic Desktop / Gamepad collection with 64-byte vendor in/out reports. Used for controllers that have + // no HID descriptor of their own (Xbox controllers speak GIP on a vendor-class interface). + private static final byte[] VENDOR_GAMEPAD_DESCRIPTOR = bytes( + 0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x09, 0x01, 0x15, 0x00, 0x26, 0xff, 0x00, + 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x02, 0x95, 0x40, 0x91, 0x02, 0xc0); + + private HookupIdentity() {} + + /** Report descriptors read before the raw open, by SDL hid path. */ + private static final java.util.Map prefetched = new java.util.concurrent.ConcurrentHashMap<>(); + /** Report descriptors already read once, by "vid:pid:interface" -- reused when the same controller is handed + * over again (hook-up off/on, replug), so the USB device is not asked again while a connection holds it. */ + private static final java.util.Map known = new java.util.concurrent.ConcurrentHashMap<>(); + /** How the last build() got its descriptor ("prefetched", "remembered", "read", "fallback"). */ + static volatile String lastSource = ""; + + private static String key(String path) { + String[] p = path.split("/"); + int n = p.length; + return n >= 3 ? p[n - 3] + ":" + p[n - 2] + ":" + p[n - 1] : path; + } + + static void prefetch(Context context, String path) { + if (known.containsKey(key(path))) return; // nothing to read: it is remembered + try { + UsbManager um = (UsbManager) context.getSystemService(Context.USB_SERVICE); + UsbDevice dev = um == null ? null : um.getDeviceList().get(deviceName(path)); + int index = ifaceIndex(path); + if (dev == null || index < 0 || index >= dev.getInterfaceCount()) return; + UsbInterface f = dev.getInterface(index); + if (f.getInterfaceClass() != UsbConstants.USB_CLASS_HID) return; + byte[] d = readWithTimeout(um, dev, f); + if (d != null) { prefetched.put(path, d); known.put(key(path), d); } + Log.i(TAG, "hookup: report descriptor of " + path + ": " + (d == null ? "unreadable" : d.length + " bytes")); + } catch (RuntimeException e) { + Log.w(TAG, "hookup: descriptor prefetch failed: " + e); + } + } + + private static String deviceName(String path) { + String n = path; + for (int i = 0; i < 3 && n.lastIndexOf('/') > 0; i++) n = n.substring(0, n.lastIndexOf('/')); + return n; + } + + private static int ifaceIndex(String path) { + try { return Integer.parseInt(path.substring(path.lastIndexOf('/') + 1)); } catch (RuntimeException e) { return -1; } + } + + private static byte[] bytes(int... v) { + byte[] b = new byte[v.length]; + for (int i = 0; i < v.length; i++) b[i] = (byte) v[i]; + return b; + } + + /** SDL's Android HID path is "///". */ + static Bundle build(Context context, int slot, String path, int vid, int pid, int ifaceIndex) { + Bundle b = new Bundle(); + b.putInt("slot", slot); + b.putInt("vid", vid); + b.putInt("pid", pid); + b.putInt("bus", 3); + String name = String.format("Controller %04x:%04x", vid, pid); + String manufacturer = "", product = "", serial = ""; + int ifaceNumber = ifaceIndex, ifaceClass = UsbConstants.USB_CLASS_HID, ifaceSub = 0, ifaceProto = 0, version = 0x0100; + byte[] descriptor = null; + try { + UsbManager um = (UsbManager) context.getSystemService(Context.USB_SERVICE); + UsbDevice dev = um == null ? null : um.getDeviceList().get(deviceName(path)); + if (dev != null) { + if (ifaceIndex >= 0 && ifaceIndex < dev.getInterfaceCount()) { + UsbInterface f = dev.getInterface(ifaceIndex); + ifaceNumber = f.getId(); + ifaceClass = f.getInterfaceClass(); + ifaceSub = f.getInterfaceSubclass(); + ifaceProto = f.getInterfaceProtocol(); + } + try { manufacturer = nz(dev.getManufacturerName()); } catch (SecurityException ignored) { } + try { product = nz(dev.getProductName()); } catch (SecurityException ignored) { } + try { serial = nz(dev.getSerialNumber()); } catch (SecurityException ignored) { } + version = parseBcd(dev.getVersion()); + descriptor = prefetched.remove(path); + if (descriptor != null) lastSource = "prefetched"; + if (descriptor == null && (descriptor = known.get(key(path))) != null) lastSource = "remembered"; + if (descriptor == null && ifaceClass == UsbConstants.USB_CLASS_HID && ifaceIndex >= 0 && ifaceIndex < dev.getInterfaceCount()) { + descriptor = readWithTimeout(um, dev, dev.getInterface(ifaceIndex)); + if (descriptor != null) { known.put(key(path), descriptor); lastSource = "read"; } + } + String joined = (manufacturer.isEmpty() || product.startsWith(manufacturer)) ? product : manufacturer + " " + product; + if (!joined.trim().isEmpty()) name = joined.trim(); + } + } catch (RuntimeException e) { + Log.w(TAG, "hookup: identity of " + path + " incomplete: " + e); + } + if (descriptor == null || descriptor.length == 0) { descriptor = VENDOR_GAMEPAD_DESCRIPTOR; lastSource = "fallback"; } + b.putInt("version", version); + b.putInt("iface", ifaceNumber); + b.putInt("ifclass", ifaceClass); + b.putInt("ifsub", ifaceSub); + b.putInt("ifproto", ifaceProto); + b.putString("name", name); + b.putString("manufacturer", manufacturer); + b.putString("product", product.isEmpty() ? name : product); + b.putString("serial", serial); + b.putByteArray("desc", descriptor); + Log.i(TAG, String.format("hookup: slot %d = %04x:%04x \"%s\" interface %d class %d, descriptor %d bytes", + slot, vid, pid, name, ifaceNumber, ifaceClass, descriptor.length)); + return b; + } + + private static String nz(String s) { return s == null ? "" : s.trim(); } + + /** "1.00" / "2.24" -> bcdDevice. */ + private static int parseBcd(String v) { + try { + if (v == null) return 0x0100; + String[] p = v.split("\\."); + int major = Integer.parseInt(p[0], 16), minor = p.length > 1 ? Integer.parseInt(p[1], 16) : 0; + return ((major & 0xff) << 8) | (minor & 0xff); + } catch (RuntimeException e) { + return 0x0100; + } + } + + /** [readReportDescriptor] on its own thread, given up after 1.5 s: a USB request that never comes back must + * not freeze the SDL thread (that would stop every controller until the app is restarted). */ + private static byte[] readWithTimeout(UsbManager um, UsbDevice dev, UsbInterface iface) { + final byte[][] out = new byte[1][]; + Thread t = new Thread(() -> out[0] = readReportDescriptor(um, dev, iface), "hookup-descriptor"); + t.setDaemon(true); + t.start(); + try { t.join(1500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + if (t.isAlive()) { + Log.w(TAG, "hookup: report descriptor read timed out"); + return null; + } + return out[0]; + } + + /** GET_DESCRIPTOR(HID report) on the interface, over a second connection. */ + private static byte[] readReportDescriptor(UsbManager um, UsbDevice dev, UsbInterface iface) { + UsbDeviceConnection c = null; + try { + c = um.openDevice(dev); + if (c == null) return null; + byte[] buf = new byte[4096]; + int ifaceNumber = iface.getId(); + // Interface-recipient request (the standard one). If another connection holds the interface, + // Android refuses it; claim it briefly (nobody holds it right before the raw open), and as a last + // resort ask with device recipient, which most controllers answer the same way. + int n = c.controlTransfer(0x81, 0x06, 0x2200, ifaceNumber, buf, buf.length, 500); + if (n <= 0 && c.claimInterface(iface, false)) { + n = c.controlTransfer(0x81, 0x06, 0x2200, ifaceNumber, buf, buf.length, 500); + c.releaseInterface(iface); + } + if (n <= 0) n = c.controlTransfer(0x80, 0x06, 0x2200, ifaceNumber, buf, buf.length, 500); + if (n <= 0) return null; + byte[] out = new byte[n]; + System.arraycopy(buf, 0, out, 0, n); + return out; + } catch (RuntimeException e) { + return null; + } finally { + if (c != null) c.close(); + } + } +} diff --git a/app/src/main/java/com/example/dsedge/Host.java b/app/src/main/java/com/example/dsedge/Host.java new file mode 100644 index 000000000..4f4e61bb9 --- /dev/null +++ b/app/src/main/java/com/example/dsedge/Host.java @@ -0,0 +1,33 @@ +package com.example.dsedge; + +import android.content.Context; + +/** + * Where this bridge runs: as its own app (SDL3Bridge, package com.example.dsedge), or built into SDL3DroidDeck -- + * the same code, in that app's ":sdl3" process. Built in, its settings are kept under their own names next to + * DroidDeck's, and it updates together with SDL3DroidDeck (no update check of its own). + */ +final class Host { + static final String OWN_PACKAGE = "com.example.dsedge"; + + private Host() {} + + static boolean embedded(Context c) { + return c != null && !OWN_PACKAGE.equals(c.getPackageName()); + } + + /** A preferences file name of the bridge: unchanged as its own app, prefixed when built into another app. */ + static String prefs(Context c, String name) { + return embedded(c) ? "sdl3bridge_" + name : name; + } + + /** The app's name as Android shows it ("SDL3DroidDeck" when built in). */ + static String appName(Context c) { + try { + CharSequence label = c.getPackageManager().getApplicationLabel(c.getApplicationInfo()); + return label != null ? label.toString() : c.getPackageName(); + } catch (RuntimeException e) { + return c.getPackageName(); + } + } +} diff --git a/app/src/main/java/com/example/dsedge/NativeBridge.java b/app/src/main/java/com/example/dsedge/NativeBridge.java new file mode 100644 index 000000000..e5ee0bf30 --- /dev/null +++ b/app/src/main/java/com/example/dsedge/NativeBridge.java @@ -0,0 +1,342 @@ +package com.example.dsedge; + +import java.util.concurrent.CopyOnWriteArrayList; + +/** Glue between the native SDL3 loop (libmain.so) and the Java service. */ +public final class NativeBridge { + public interface Listener { + void onFrame(float[] f, int slot); + void onStatus(String s); + /** The controller in this slot (0..3) was unplugged. */ + default void onPadGone(int slot) { } + /** Capabilities of the controller in this slot: which buttons/axes exist, touchpad count, and kind. */ + default void onPadCaps(int slot, int present, int axes, int touchpads, String kind) { } + /** Steam Controller (2026) raw passthrough: one input report (id + payload without the id byte). */ + default void onTritonState(int reportId, byte[] payload) { } + /** The passed-through Steam Controller (2026) appeared / went away. */ + default void onTritonConnected(boolean connected, String what) { } + /** Answer to a feature read DroidDeck forwarded (null = the controller did not answer). */ + default void onTritonFeature(long request, byte[] report) { } + /** Steam Native Hook-up: a controller was handed to Steam in [slot]; identity = HookupIdentity bundle. */ + default void onHookupAdded(int slot, android.os.Bundle identity) { } + default void onHookupInput(int slot, byte[] report) { } + default void onHookupRemoved(int slot) { } + default void onHookupReply(int slot, long request, byte[] report) { } + /** A line from the bridge's hook-up / passthrough code, for DroidDeck's session log. */ + default void onBridgeLog(String line) { } + } + + /** Layout of the float[] frame sent from native code. */ + public static final int FRAME_LEN = 22; + // 0 mask | 1..4 lx ly rx ry (0..255) | 5,6 l2 r2 (0..255) | 7..9 gyro rad/s | 10..12 accel m/s^2 + // 13..15 finger0 (down,x,y) | 16..18 finger1 | 19 battery % (-1 unknown) | 20 SDL power state | 21 extra-button mask (MISC2..6) + + static final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + static volatile String lastStatus = "Starting..."; + + static final int MAX_PADS = 4; + /** USB ids and names of the controller in each slot, so DroidDeck can ignore the Android duplicate of it. */ + static final int[] vids = new int[MAX_PADS], pids = new int[MAX_PADS]; + static final String[] names = new String[MAX_PADS]; + + private NativeBridge() {} + + /** Called from the native SDL thread. */ + static void onFrame(float[] f, int slot) { + for (Listener l : listeners) l.onFrame(f, slot); + } + + /** Called from the native SDL thread when a controller is opened in a slot. */ + static void onPadInfo(int slot, int vid, int pid, String name) { + if (slot < 0 || slot >= MAX_PADS) return; + vids[slot] = vid; + pids[slot] = pid; + names[slot] = name; + } + + /** Capabilities of each slot's controller (present-button mask, axis flags, touchpad count, kind). */ + static final int[] present = new int[MAX_PADS]; + static final int[] axesMask = new int[MAX_PADS]; + static final int[] touchpadCount = new int[MAX_PADS]; + static final String[] kind = new String[MAX_PADS]; + + /** Called from the native SDL thread when a controller is opened in a slot. */ + static void onPadCaps(int slot, int p, int a, int tp, String k) { + if (slot < 0 || slot >= MAX_PADS) return; + present[slot] = p; + axesMask[slot] = a; + touchpadCount[slot] = tp; + kind[slot] = k; + for (Listener l : listeners) l.onPadCaps(slot, p, a, tp, k); + } + + /** Called from the native SDL thread when a slot's controller goes away. */ + static void onPadGone(int slot) { + if (slot < 0 || slot >= MAX_PADS) return; + vids[slot] = 0; + pids[slot] = 0; + names[slot] = null; + for (Listener l : listeners) l.onPadGone(slot); + } + + // ---------- Steam Controller (2026) raw passthrough (native/triton.h) ---------- + static volatile boolean tritonConnected; + static volatile String tritonWhat = ""; + + /** Called from the native SDL thread for every state report. */ + static void onTritonState(int reportId, byte[] payload) { + for (Listener l : listeners) l.onTritonState(reportId, payload); + } + + /** Called from the native SDL thread. */ + static void onTritonConnected(boolean connected, String what) { + tritonConnected = connected; + tritonWhat = what == null ? "" : what; + for (Listener l : listeners) l.onTritonConnected(connected, tritonWhat); + } + + /** Called from the native SDL thread. */ + static void onTritonFeature(long request, byte[] report) { + for (Listener l : listeners) l.onTritonFeature(request, report); + } + + /** An output report (feature = false) or a feature-report write (feature = true) from Steam, report id first. */ + static void tritonOutput(byte[] report, boolean feature) { + try { nativeTritonOutput(report, feature); } catch (UnsatisfiedLinkError e) { } + } + + static void tritonGetFeature(long request, int reportId) { + try { nativeTritonGetFeature(request, reportId); } catch (UnsatisfiedLinkError e) { } + } + + /** Game rumble for the passed-through controller, 0..65535 per motor. */ + static void tritonRumble(int left, int right) { + try { nativeTritonRumble(left, right); } catch (UnsatisfiedLinkError e) { } + } + + static native void nativeTritonOutput(byte[] report, boolean feature); + static native void nativeTritonGetFeature(long request, int reportId); + static native void nativeTritonRumble(int left, int right); + + // ---------- Steam Native Hook-up (native/hookup.h) ---------- + static volatile android.content.Context appContext; + static final android.os.Bundle[] hookupIdentity = new android.os.Bundle[MAX_PADS]; + /** Controllers opened raw by native code: {path, vid, pid, interface}; kept until they are released. */ + static final Object[][] hookupPending = new Object[MAX_PADS][]; + + /** A controller that is open but was never described (that failed): try again, off the SDL thread. */ + static void retryUndescribed() { + for (int i = 0; i < MAX_PADS; i++) { + Object[] p = hookupPending[i]; + if (p == null || hookupIdentity[i] != null) continue; + final int slot = i; + new Thread(() -> { + if (hookupPending[slot] == p && hookupIdentity[slot] == null) + onHookupAdded(slot, (String) p[0], (Integer) p[1], (Integer) p[2], (Integer) p[3]); + }, "hookup-retry").start(); + } + } + + static void onHookupAdded(int slot, String path, int vid, int pid, int ifaceIndex) { + if (slot < 0 || slot >= MAX_PADS) return; + hookupPending[slot] = new Object[] { path, vid, pid, ifaceIndex }; + android.content.Context c = appContext; + if (c == null) { + onBridgeLog("hookup: slot " + slot + " cannot be described (the app is not fully started); it is retried"); + return; + } + long t0 = android.os.SystemClock.uptimeMillis(); + android.os.Bundle id; + try { + id = HookupIdentity.build(c, slot, path, vid, pid, ifaceIndex); + } catch (Throwable e) { + onBridgeLog("hookup: slot " + slot + " could not be described: " + e); + return; + } + hookupIdentity[slot] = id; + byte[] d = id.getByteArray("desc"); + onBridgeLog(String.format(java.util.Locale.US, "hookup: slot %d described in %d ms (descriptor %d bytes, %s); sent to %d DroidDeck connection(s)", + slot, android.os.SystemClock.uptimeMillis() - t0, d == null ? 0 : d.length, HookupIdentity.lastSource, clientCount())); + for (Listener l : listeners) { + try { l.onHookupAdded(slot, id); } catch (RuntimeException e) { android.util.Log.w("SDL3Bridge", "hookup: listener failed: " + e); } + } + } + + /** DroidDeck connections, for the log (set by BridgeService). */ + static volatile java.util.function.IntSupplier clients; + private static int clientCount() { + java.util.function.IntSupplier s = clients; + return s == null ? -1 : s.getAsInt(); + } + + /** Recent bridge log lines (newest last), replayed to DroidDeck when it connects. */ + static final java.util.ArrayDeque bridgeLog = new java.util.ArrayDeque<>(); + + /** Called from the native SDL thread. */ + static void onBridgeLog(String line) { + String stamped = new java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(new java.util.Date()) + " " + line; + synchronized (bridgeLog) { + bridgeLog.addLast(stamped); + while (bridgeLog.size() > 200) bridgeLog.removeFirst(); + } + for (Listener l : listeners) l.onBridgeLog(stamped); + } + + /** Called from the native SDL thread just before a controller is opened raw. */ + static void prefetchHookupDescriptor(String path) { + android.content.Context c = appContext; + if (c != null) HookupIdentity.prefetch(c, path); + } + + static void onHookupInput(int slot, byte[] report) { + for (Listener l : listeners) l.onHookupInput(slot, report); + } + + static void onHookupRemoved(int slot) { + if (slot < 0 || slot >= MAX_PADS) return; + hookupPending[slot] = null; + hookupIdentity[slot] = null; + for (Listener l : listeners) l.onHookupRemoved(slot); + } + + static void onHookupReply(int slot, long request, byte[] report) { + for (Listener l : listeners) l.onHookupReply(slot, request, report); + } + + static void applyHookup() { + try { nativeSetHookup(hookupMode()); } catch (UnsatisfiedLinkError e) { } + } + + // ---- DroidDeck's desktop: Steam Native Hook-up waits for Steam ---- + // On DroidDeck's desktop Steam is one program among others and may not be running. DroidDeck says so with each + // watchdog sync (every 1.5 s); while it does, hook-up waits: controllers stay ordinary gamepads every program + // can use, and are handed to Steam once its client runs. Lapses by itself if DroidDeck stops saying it. + private static volatile long steamAwayUntilMs; + private static volatile int hookupApplied = -1; + + /** 0 off, 1 on, 2 on but waiting for Steam (DroidDeck's desktop without Steam running). */ + static int hookupMode() { + if (!Settings.isHookup()) return 0; + return android.os.SystemClock.uptimeMillis() < steamAwayUntilMs ? 2 : 1; + } + + static boolean hookupWaitsForSteam() { return hookupMode() == 2; } + + /** From DroidDeck's sync: is Steam away (its desktop without the Steam client running)? */ + static void setSteamAway(boolean away) { + steamAwayUntilMs = away ? android.os.SystemClock.uptimeMillis() + 10000 : 0; + recheckHookup(); + } + + /** Applies hook-up again if waiting for Steam started or ended (also when the wait lapsed). */ + static void recheckHookup() { + int mode = hookupMode(); + if (mode == hookupApplied) return; + int was = hookupApplied; + hookupApplied = mode; + if (mode == 2) onBridgeLog("hookup: DroidDeck's desktop has no Steam running: controllers stay ordinary gamepads until it starts"); + else if (was == 2 && mode == 1) onBridgeLog("hookup: Steam runs on DroidDeck's desktop: controllers are handed to Steam"); + applyHookup(); + } + + static void hookupOutput(int slot, int kind, byte[] report) { + try { nativeHookupOutput(slot, kind, report); } catch (UnsatisfiedLinkError e) { } + } + + static void hookupGet(int slot, long request, int reportId, boolean input, int length) { + try { nativeHookupGet(slot, request, reportId, input, length); } catch (UnsatisfiedLinkError e) { } + } + + static native void nativeSetHookup(int on); + static native int nativeHookupMapShm(int fd); + static native long nativeHookupShmInode(); + + /** DroidDeck's shared hook-up file (fast path: reports written straight into it). Takes ownership of pfd. */ + static void hookupMapShm(android.os.ParcelFileDescriptor pfd) { + if (pfd == null) return; + try { nativeHookupMapShm(pfd.detachFd()); } + catch (UnsatisfiedLinkError e) { try { pfd.close(); } catch (java.io.IOException ignored) { } } + } + + /** Inode of the mapped (still live) shared file, 0 if none. */ + static long hookupShmInode() { + try { return nativeHookupShmInode(); } catch (UnsatisfiedLinkError e) { return 0; } + } + static native void nativeHookupOutput(int slot, int kind, byte[] report); + static native void nativeHookupGet(int slot, long request, int reportId, boolean input, int length); + + /** Called from the native SDL thread. */ + static void onStatus(String s) { + SdlEngine.onNativeStatus(); + lastStatus = s; + for (Listener l : listeners) l.onStatus(s); + } + + /** Rumble for any player slot (0..3). */ + static void setRumble(int slot, int strong, int weak) { + if (slot < 0 || slot >= MAX_PADS) return; + try { nativeSetRumble(slot, strong, weak); } catch (UnsatisfiedLinkError e) { } + } + + static native void nativeSetRumble(int slot, int rumbleStrong, int rumbleWeak); + + /** Pushes each player's lightbar (scaled by brightness) and player LED to its controller. */ + static void applyOutput() { + try { + for (int p = 0; p < MAX_PADS; p++) { + int b = Settings.brightness[p]; + nativeSetLed(p, Settings.lightR[p] * b / 100, Settings.lightG[p] * b / 100, Settings.lightB[p] * b / 100); + nativeSetPlayerLed(p, Settings.playerLed[p] - 1); // -1 = player LEDs off + } + } catch (UnsatisfiedLinkError e) { + // The bridge screen has not been opened yet, so the native library is not loaded. + } + } + + /** Apply just one player's light (on a settings change). */ + static void applyOutput(int p) { + if (p < 0 || p >= MAX_PADS) return; + try { + int b = Settings.brightness[p]; + nativeSetLed(p, Settings.lightR[p] * b / 100, Settings.lightG[p] * b / 100, Settings.lightB[p] * b / 100); + nativeSetPlayerLed(p, Settings.playerLed[p] - 1); + } catch (UnsatisfiedLinkError e) { } + } + + /** Forced mode: ignore Android's basic input and only use SDL's own USB driver. */ + static void applyForce() { + try { nativeSetForce(Settings.forceUsb ? 1 : 0); } catch (UnsatisfiedLinkError e) { } + } + + static void rescan() { + try { nativeRescan(); } catch (UnsatisfiedLinkError e) { } + } + + static native void nativeSetForce(int on); + + static native void nativeRescan(); + + /** Hard on/off for the SDL3 driver: on = forced SDL3 USB, off = release the controller to Android. */ + static void applyDriver() { + try { + nativeSetDriver(Settings.driverEnabled ? 1 : 0); + nativeSetHookup(hookupMode()); + if (Settings.driverEnabled) nativeSetForce(Settings.forceUsb ? 1 : 0); + } catch (UnsatisfiedLinkError e) { } + } + + static native void nativeSetDriver(int on); + + /** Controller poll rate (loop period in ms). */ + static void applyRate() { + try { nativeSetPollMs(Settings.pollMs); } catch (UnsatisfiedLinkError e) { } + } + + static native void nativeSetPollMs(int ms); + + /** Per-slot lightbar rgb 0..255. */ + static native void nativeSetLed(int slot, int r, int g, int b); + + /** Per-slot player LEDs: -1 = off, 0..3 = player 1..4. */ + static native void nativeSetPlayerLed(int slot, int index); +} diff --git a/app/src/main/java/com/example/dsedge/SdlEngine.java b/app/src/main/java/com/example/dsedge/SdlEngine.java new file mode 100644 index 000000000..71dc14eda --- /dev/null +++ b/app/src/main/java/com/example/dsedge/SdlEngine.java @@ -0,0 +1,271 @@ +package com.example.dsedge; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.util.Log; + +import org.libsdl.app.HIDDeviceManager; +import org.libsdl.app.SDL; +import org.libsdl.app.SDLActivity; + +/** + * Where SDL3 runs. + * + * Normally headless, inside the bridge's background service -- no screen has to open for the bridge to work. It + * does what SDL's own SDLActivity does at start-up, minus the window: load the libraries, set up SDL's Java side, + * take USB/HID access (HIDDeviceManager), then run SDL_main (main.c) on its own thread. The bridge only uses SDL's + * gamepad / joystick / hidapi parts, which need no window. + * + * SDL's Java side does expect "the app's screen" as its context (SDL.getContext()): SDL3 reads the app's files + * and assets through it while it starts (gamepad mappings), and crashes the whole app without one. Headless there + * is no screen, so AppContext stands in for it -- an Activity object that is never started or shown, whose calls + * all go to the app. The mappings file is also handed to SDL3 directly, so it never needs the assets for it. + * + * Self-protecting: a marker is saved before every background start and cleared when SDL3's loop reports its first + * status. If SDL3 stays silent for 10 s (the process is then restarted), or the app died during the attempt, this + * build uses the fallback instead: SDL3 runs inside the bridge's own screen, started when you open SDL3Bridge + * (nothing opens by itself). Every step, and the previous run's crash report if there was one, is sent to + * DroidDeck's session log ("sdl3bridge: engine: ..."). + */ +final class SdlEngine { + private static final String TAG = "SDL3Bridge"; + private static final long CONFIRM_MS = 10_000; + private static volatile boolean started, confirmed, fallback, failed; + private static volatile HIDDeviceManager hid; + private static long hostStartMs; + /** Why SDL3 is not running (shown in the status, the notification and DroidDeck's tab), or null. */ + static volatile String error; + /** How SDL3 runs now, for the status / DroidDeck's tab. */ + static volatile String mode = "not started"; + private static String fallbackWhy = ""; + + private SdlEngine() {} + + static boolean isRunning() { return started && (fallback ? SdlHostActivity.running : true); } + + private static void log(String line) { + Log.i(TAG, line); + NativeBridge.onBridgeLog("engine: " + line); + } + + private static SharedPreferences prefs(Context c) { return c.getSharedPreferences(Host.prefs(c, "engine"), Context.MODE_PRIVATE); } + + /** Which build this is: the version code, or -- built into SDL3DroidDeck, whose version code stays the same from + * build to build -- when it was installed, so a new build tries the background again. */ + private static long version(Context c) { + try { + android.content.pm.PackageInfo pi = c.getPackageManager().getPackageInfo(c.getPackageName(), 0); + if (Host.embedded(c)) return pi.lastUpdateTime; + return android.os.Build.VERSION.SDK_INT >= 28 ? pi.getLongVersionCode() : pi.versionCode; + } catch (Exception e) { return 0; } + } + + /** Stands in for "the app's screen" in SDL's Java side (see the class comment). Never started or shown. */ + static final class AppContext extends Activity { + AppContext(Context base) { attachBaseContext(base); } + } + + private static Activity standIn(Context app) { + try { + Context base; + // its own copy of the app's context, so nothing set on the stand-in reaches the app's real one + try { base = app.createConfigurationContext(app.getResources().getConfiguration()); } + catch (RuntimeException e) { base = app; } + return new AppContext(base); + } catch (Throwable e) { + log("could not make SDL3's stand-in context (" + e + "); starting without one"); + return null; + } + } + + /** SDL3's gamepad mappings file (empty unless you add mappings), as an absolute path; null if unavailable. */ + static String mappingFile(Context c) { + try { + java.io.File f = new java.io.File(c.getFilesDir(), "gamepad_map.txt"); + if (!f.exists() && !f.createNewFile()) return null; + return f.getAbsolutePath(); + } catch (Exception e) { + return null; + } + } + + /** Starts SDL once per process (safe to call again, from any thread). */ + static void start(Context context) { + if (Looper.myLooper() != Looper.getMainLooper()) { // the stand-in context must be made on the main thread + new Handler(Looper.getMainLooper()).post(() -> start(context)); + return; + } + startOnMain(context); + } + + private static synchronized void startOnMain(Context context) { + final Context app = context.getApplicationContext(); + NativeBridge.appContext = app; + Settings.init(app); + if (started) { + if (fallback && !SdlHostActivity.running && context instanceof Activity) startHost((Activity) context); + return; + } + started = true; + SharedPreferences p = prefs(app); + long ver = version(app); + String last = p.getString("state", ""); + boolean sameBuild = p.getLong("version", -1) == ver; + if (sameBuild && (last.equals("starting") || last.startsWith("failed"))) { + boolean crashed = last.equals("starting"); + fallbackWhy = crashed ? "the app stopped while SDL3 was starting in the background" : last.substring(last.indexOf(':') + 1).trim(); + if (crashed) p.edit().putString("state", "failed: " + fallbackWhy).commit(); + fallback = true; + mode = "the SDL3Bridge screen (fallback)"; + error = "Open SDL3Bridge once to start SDL3 (it could not run in the background on this phone: " + fallbackWhy + ")"; + log("SDL3 can't run in the background service with this build (" + fallbackWhy + "). Fallback: SDL3 runs " + + "inside the SDL3Bridge screen -- open SDL3Bridge once (nothing opens by itself). A new build tries " + + "the background again."); + if (crashed) forwardPreviousLog(); + if (context instanceof Activity) startHost((Activity) context); + return; + } + p.edit().putString("state", "starting").putLong("version", ver).commit(); // commit: must survive a crash + log("starting SDL3 in the background service (build " + ver + ")"); + try { + System.loadLibrary("SDL3"); + System.loadLibrary("main"); + log("libraries loaded"); + SDL.setupJNI(); + SDL.initialize(); // (this also clears SDL's context) + Activity ctx = standIn(app); + if (ctx != null) SDL.setContext(ctx); + log("SDL3's Java side ready" + (ctx != null ? " (stand-in context)" : " (no context)")); + hid = HIDDeviceManager.acquire(app); + hid.setFrozen(!Settings.driverEnabled); + log("USB/HID access ready"); + } catch (Throwable e) { + fail(app, "SDL3 could not start: " + e); + restartForFallback("SDL3 could not start in the background service"); + return; + } + final String main = app.getApplicationInfo().nativeLibraryDir + "/libmain.so"; + final String map = mappingFile(app); + final String[] args = map != null ? new String[] { map } : new String[0]; + Thread t = new Thread(() -> { + try { android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_DISPLAY); } catch (RuntimeException ignored) { } + try { + SDLActivity.nativeInitMainThread(); + int rc = SDLActivity.nativeRunMain(main, "SDL_main", args); + SDLActivity.nativeCleanupMainThread(); + fail(app, "SDL3's loop ended (" + rc + ")"); + } catch (Throwable e) { + fail(app, "SDL3 stopped: " + e); + } + restartForFallback("SDL3's loop is not running"); + }, "SDLThread"); + error = null; + mode = "background service"; + t.start(); + log("SDL3 thread started; waiting for its first status"); + // Settings the native loop reads (driver, hook-up, rate, lights), as the bridge screen used to apply. + NativeBridge.applyDriver(); + NativeBridge.applyRate(); + NativeBridge.applyOutput(); + new Handler(Looper.getMainLooper()).postDelayed(() -> { + if (confirmed || fallback || failed) return; // working, or already failed (and restarting) + fail(app, "SDL3 did not answer within " + (CONFIRM_MS / 1000) + " s in the background service"); + restartForFallback("SDL3 did not answer"); + }, CONFIRM_MS); + } + + /** SDL3's loop reported a status: it runs. */ + static void onNativeStatus() { + if (!started) return; + if (fallback) { + if (error != null && SdlHostActivity.running) { + error = null; + log("SDL3 is running in the SDL3Bridge screen (fallback); you can leave the screen, it keeps running"); + } + return; + } + if (confirmed) return; + confirmed = true; + Context c = NativeBridge.appContext; + if (c != null) prefs(c).edit().putString("state", "ok").apply(); + log("SDL3 is running in the background service"); + } + + /** + * The fallback screen was closed for good (its task was swiped away): SDL3 can't be restarted cleanly in the + * same process, so the process restarts (DroidDeck's connection brings the service back) and the bridge waits + * to be opened again. + */ + static void onHostGone() { + if (!fallback) return; + error = "Open SDL3Bridge to start SDL3 again (fallback: " + fallbackWhy + ")"; + log("the SDL3Bridge screen that ran SDL3 was closed; restarting SDL3Bridge -- open it again to start SDL3"); + new Handler(Looper.getMainLooper()).postDelayed( + () -> android.os.Process.killProcess(android.os.Process.myPid()), 1500); + } + + /** The process restarts (DroidDeck's connection brings the service back) and the next start uses the fallback. */ + private static void restartForFallback(String why) { + log(why + "; restarting SDL3Bridge in 2 s so the fallback takes over"); + new Handler(Looper.getMainLooper()).postDelayed( + () -> android.os.Process.killProcess(android.os.Process.myPid()), 2000); // lets the log reach DroidDeck + } + + private static void fail(Context app, String why) { + failed = true; + error = why; + Log.e(TAG, why); + NativeBridge.onBridgeLog("engine: " + why); + prefs(app).edit().putString("state", "failed: " + why).commit(); + } + + /** Fallback only, and only from the bridge's own screen while you look at it: SDL3's host screen on top of it. */ + private static void startHost(Activity from) { + long now = SystemClock.uptimeMillis(); + if (hostStartMs != 0 && now - hostStartMs < 5000) return; // never in a loop + hostStartMs = now; + try { + from.startActivity(new Intent(from, SdlHostActivity.class).addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)); + log("starting SDL3 in the SDL3Bridge screen (fallback)"); + } catch (RuntimeException e) { + log("could not start SDL3 in the SDL3Bridge screen: " + e); + } + } + + /** The previous run's own log lines about SDL / the bridge / a crash, into DroidDeck's session log. */ + private static void forwardPreviousLog() { + new Thread(() -> { + java.util.ArrayDeque keep = new java.util.ArrayDeque<>(); + try { + java.lang.Process p = Runtime.getRuntime().exec(new String[] { "logcat", "-d", "-v", "time", "-t", "3000" }); + try (java.io.BufferedReader r = new java.io.BufferedReader(new java.io.InputStreamReader(p.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) { + if (line.contains("AndroidRuntime") || line.contains(" F/") || line.contains("/SDL") + || line.contains("SDL3Bridge") || line.contains("libc") || line.contains("DEBUG") + || line.contains("/hidapi") || line.contains("JNI DETECTED")) { + keep.addLast(line); + while (keep.size() > 80) keep.removeFirst(); + } + } + } + } catch (Exception e) { + keep.addLast("(could not read the log: " + e + ")"); + } + NativeBridge.onBridgeLog("engine: ---- the bridge's own log (last " + keep.size() + " relevant lines) ----"); + for (String l : keep) NativeBridge.onBridgeLog("engine: | " + l); + }, "engine-log").start(); + } + + /** Driver off = release the USB interface so Android can use the controller; on = claim it again. */ + static void setHidFrozen(boolean frozen) { + HIDDeviceManager h = hid; + if (h != null) h.setFrozen(frozen); + else SdlHostActivity.setHidFrozen(frozen); + } +} diff --git a/app/src/main/java/com/example/dsedge/SdlHostActivity.java b/app/src/main/java/com/example/dsedge/SdlHostActivity.java new file mode 100644 index 000000000..dcd23c882 --- /dev/null +++ b/app/src/main/java/com/example/dsedge/SdlHostActivity.java @@ -0,0 +1,104 @@ +package com.example.dsedge; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.ViewGroup; + +import org.libsdl.app.SDLActivity; + +/** + * Fallback host for SDL3, used only when SDL3 could not run in the background service with this build + * (SdlEngine decides, and says why). SDL's own screen class starts SDL3 the classic way; it is opened only from the + * bridge's screen while you look at it, never by itself. + * + * It stays out of sight: its window is transparent and SDL's drawing surface is shrunk to one pixel (the bridge + * draws nothing). SDL3 starts once this screen is in front, then the bridge's screen is brought back on top of it + * in the same task; SDL3 keeps running behind it, and when you leave SDL3Bridge (Home, Back, switching apps). + */ +public class SdlHostActivity extends SDLActivity { + static volatile boolean running; + private final Handler ui = new Handler(Looper.getMainLooper()); + private int waits; + + @Override protected String[] getLibraries() { + return new String[] { "SDL3", "main" }; + } + + @Override protected String[] getArguments() { + String map = SdlEngine.mappingFile(this); + return map != null ? new String[] { map } : new String[0]; + } + + @Override protected void onCreate(Bundle state) { + running = true; + NativeBridge.appContext = getApplicationContext(); + Settings.init(this); + super.onCreate(state); + if (mSurface != null) { // SDL's drawing surface: the bridge draws nothing, keep it out of sight + ViewGroup.LayoutParams lp = mSurface.getLayoutParams(); + if (lp != null) { + lp.width = 1; + lp.height = 1; + mSurface.setLayoutParams(lp); + } + } + } + + @Override protected void onResume() { + super.onResume(); + keepControllerClaimed(); + waits = 0; + ui.removeCallbacks(handBack); + ui.post(handBack); + } + + /** As soon as SDL3's thread runs: the bridge's screen back on top (or, if it is gone, out of the way). */ + private final Runnable handBack = new Runnable() { + @Override public void run() { + if (isFinishing()) return; + if (mSDLThread == null && ++waits < 60) { // SDL starts once its surface is ready (~100 ms) + ui.postDelayed(this, 50); + return; + } + if (BridgeActivity.running) { + startActivity(new Intent(SdlHostActivity.this, BridgeActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_NO_ANIMATION)); + } else { + moveTaskToBack(true); + } + } + }; + + @Override public void onBackPressed() { + moveTaskToBack(true); // closing this screen would stop SDL3 + } + + @Override protected void onPause() { + super.onPause(); + ui.removeCallbacks(handBack); + keepControllerClaimed(); // SDL releases the USB controller on pause; keep it while DroidDeck is in front + } + + @Override public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + keepControllerClaimed(); + } + + @Override protected void onDestroy() { + ui.removeCallbacks(handBack); + running = false; + boolean forGood = !isChangingConfigurations(); + super.onDestroy(); + if (forGood) SdlEngine.onHostGone(); + } + + private static void keepControllerClaimed() { + if (mHIDDeviceManager != null) mHIDDeviceManager.setFrozen(!Settings.driverEnabled); + } + + static void setHidFrozen(boolean frozen) { + if (mHIDDeviceManager != null) mHIDDeviceManager.setFrozen(frozen); + } +} diff --git a/app/src/main/java/com/example/dsedge/Settings.java b/app/src/main/java/com/example/dsedge/Settings.java new file mode 100644 index 000000000..2a70e5b04 --- /dev/null +++ b/app/src/main/java/com/example/dsedge/Settings.java @@ -0,0 +1,184 @@ +package com.example.dsedge; + +import android.content.Context; +import android.content.SharedPreferences; + +/** + * User settings, shared by the screen and the background service (same process). + * Gyro / touchpad / back-button remap / lightbar are per player (0..3); the rest are global. + */ +final class Settings { + static final int PLAYERS = 4; + + static final int TOUCH_SPLIT = 0, TOUCH_SINGLE = 1, TOUCH_OFF = 2; + static final String[] TOUCH_NAMES = { + "Split (decoupled): left half = left trackpad, right half = right trackpad", + "Single: whole touchpad = right trackpad", + "Off" + }; + + /** Remap targets: 4 Deck grips + the two trackpads' touch / click. */ + static final String[] REMAP_TARGETS = { + "L4", "R4", "L5", "R5", + "Left trackpad click", "Right trackpad click", + "Left trackpad touch", "Right trackpad touch" + }; + static final int REMAP_COUNT = 8; + static final int[] TARGET_BITS = { 19, 20, 21, 22 }; // grip targets (first 4) -> mask bits + + /** Any controller button can be a source. sel: -1 none, 0 = main mask, 1 = extra mask (MISC2..6). */ + static final String[] SRC_NAMES = { + "None", + "A / Cross", "B / Circle", "X / Square", "Y / Triangle", + "L1", "R1", "L2", "R2", + "Create / Back / Share", "Options / Start / Menu", "L3", "R3", + "Guide / PS", "Touchpad click", "Mute / Misc1", + "D-pad Up", "D-pad Down", "D-pad Left", "D-pad Right", + "Left paddle (L4)", "Right paddle (R4)", "Left Fn (L5)", "Right Fn (R5)", + "Extra C / Misc2", "Extra Z / Misc3", "Misc4", "Misc5", "Misc6" + }; + static final int[] SRC_SEL = { -1, 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0, 0,0,0,0, 0,0,0,0, 1,1,1,1,1 }; + static final int[] SRC_BIT = { -1, 0,1,2,3, 4,5,6,7, 8,9,10,11, 12,13,14, 15,16,17,18, 19,20,21,22, 0,1,2,3,4 }; + static final String[] PLAYER_NAMES = { "Off", "Player 1", "Player 2", "Player 3", "Player 4" }; + static final String[] AXIS_NAMES = { "Controller X (pitch)", "Controller Y (yaw)", "Controller Z (roll)" }; + static final String[] GYRO_INV_NAMES = { "None", "X", "Y", "Z", "X + Y", "X + Z", "Y + Z", "X + Y + Z" }; + + // ---- per-player settings (index 0..3) ---- + static final int[] touchMode = fill(TOUCH_SPLIT); + static final int[][] remap = new int[PLAYERS][REMAP_COUNT]; + static final boolean[] touchInvX = new boolean[PLAYERS]; + static final boolean[] touchInvY = new boolean[PLAYERS]; + static final boolean[] touchSwap = new boolean[PLAYERS]; + static final int[] gyroGain = fill(100); + static final int[] gyroInvert = new int[PLAYERS]; + static final int[] gyroDeadzone = new int[PLAYERS]; + static final float[][] gyroBias = new float[PLAYERS][3]; + static final int[][] gyroMap = new int[PLAYERS][3]; + static final boolean[] accelFollows = fill(true); + static final int[] lightR = new int[PLAYERS]; + static final int[] lightG = new int[PLAYERS]; + static final int[] lightB = fill(255); + static final int[] brightness = fill(100); + static final int[] playerLed = new int[PLAYERS]; + + // ---- global settings ---- + // Auto by default: any SDL3-supported controller is used -- through SDL3's own USB driver when possible + // (full features), otherwise through Android's input (e.g. Bluetooth). An Android duplicate of a controller + // SDL3 already drives over USB is still ignored, so nothing is doubled. + static volatile boolean forceUsb = false; + static volatile boolean driverEnabled = true; + static volatile int presentAs = 0; + static final String[] PRESENT_NAMES = { "Steam Deck (default)", "DualSense (native)", "DualSense Edge (native)", "DualShock 4 (native)", + "Steam Controller 2026 (native)", "Steam Controller 2015 (native)", + "Steam Native Hook-up (Steam sees the real controller)" }; + // DroidDeck pad types: 0 Deck, 4 DualSense, 5 DualSense Edge, 3 DualShock 4, 1 Steam Controller 2026 (28de:1302), + // 6 Steam Controller 2015 (28de:1102) + // Hook-up hands USB controllers to Steam as themselves; any controller it can't (Bluetooth) stays a Deck pad. + static final int[] PRESENT_PAD_TYPE = { 0, 4, 5, 3, 1, 6, 0 }; + static final int PRESENT_HOOKUP = 6; + static boolean isHookup() { return presentAs == PRESENT_HOOKUP; } + /** The "Present controller as" choice to go back to when hook-up is switched off from the bubble. */ + static volatile int lastPresentAs = 0; + /** Was the SDL3 driver on before the bubble switched hook-up on? Switching hook-up off goes back to it. */ + static volatile boolean driverBeforeHookup = true; + static final int PAD_TYPE_STEAM_2015 = 6; + static boolean isSonyPadType(int t) { return t >= 3 && t <= 5; } + static volatile int pollMs = 2; + static final String[] RATE_NAMES = { "250 Hz (low CPU)", "500 Hz (recommended)", "1000 Hz (max)" }; + static final int[] RATE_MS = { 4, 2, 1 }; + static volatile boolean bubble = false; + static volatile int bubbleX = 24, bubbleY = 160; + + private static SharedPreferences prefs; + + private Settings() {} + + private static int[] fill(int v) { int[] a = new int[PLAYERS]; java.util.Arrays.fill(a, v); return a; } + private static boolean[] fill(boolean v) { boolean[] a = new boolean[PLAYERS]; java.util.Arrays.fill(a, v); return a; } + + static synchronized void init(Context c) { + if (prefs != null) return; + prefs = c.getApplicationContext().getSharedPreferences(Host.prefs(c, "bridge"), Context.MODE_PRIVATE); + for (int p = 0; p < PLAYERS; p++) { + String s = "_p" + p; + touchMode[p] = clamp(prefs.getInt("touchMode" + s, TOUCH_SPLIT), 0, 2); + for (int i = 0; i < REMAP_COUNT; i++) remap[p][i] = clamp(prefs.getInt("remap" + i + s, 0), 0, SRC_NAMES.length - 1); + touchInvX[p] = prefs.getBoolean("touchInvX" + s, false); + touchInvY[p] = prefs.getBoolean("touchInvY" + s, false); + touchSwap[p] = prefs.getBoolean("touchSwap" + s, false); + gyroGain[p] = clamp(prefs.getInt("gyroGain" + s, 100), 1, 800); + gyroInvert[p] = clamp(prefs.getInt("gyroInvert" + s, 0), 0, 7); + gyroDeadzone[p] = clamp(prefs.getInt("gyroDeadzone" + s, 0), 0, 50); + gyroBias[p][0] = prefs.getFloat("gyroBiasX" + s, 0f); + gyroBias[p][1] = prefs.getFloat("gyroBiasY" + s, 0f); + gyroBias[p][2] = prefs.getFloat("gyroBiasZ" + s, 0f); + for (int i = 0; i < 3; i++) gyroMap[p][i] = clamp(prefs.getInt("gyroMap" + i + s, i), 0, 2); + accelFollows[p] = prefs.getBoolean("accelFollows" + s, true); + lightR[p] = clamp(prefs.getInt("lightR" + s, 0), 0, 255); + lightG[p] = clamp(prefs.getInt("lightG" + s, 0), 0, 255); + lightB[p] = clamp(prefs.getInt("lightB" + s, 255), 0, 255); + brightness[p] = clamp(prefs.getInt("brightness" + s, 100), 0, 100); + playerLed[p] = clamp(prefs.getInt("playerLed" + s, 0), 0, 4); + } + forceUsb = prefs.getBoolean("forceUsbV2", false); + driverEnabled = prefs.getBoolean("driverEnabled", true); + presentAs = clamp(prefs.getInt("presentAs", 0), 0, PRESENT_NAMES.length - 1); + lastPresentAs = clamp(prefs.getInt("lastPresentAs", 0), 0, PRESENT_NAMES.length - 1); + if (lastPresentAs == PRESENT_HOOKUP) lastPresentAs = 0; + driverBeforeHookup = prefs.getBoolean("driverBeforeHookup", true); + pollMs = clamp(prefs.getInt("pollMs", 2), 1, 16); + bubble = prefs.getBoolean("bubble", false); + bubbleX = prefs.getInt("bubbleX", 24); + bubbleY = prefs.getInt("bubbleY", 160); + } + + static void save() { + SharedPreferences pr = prefs; + if (pr == null) return; + SharedPreferences.Editor e = pr.edit(); + for (int p = 0; p < PLAYERS; p++) { + String s = "_p" + p; + e.putInt("touchMode" + s, touchMode[p]); + for (int i = 0; i < REMAP_COUNT; i++) e.putInt("remap" + i + s, remap[p][i]); + e.putBoolean("touchInvX" + s, touchInvX[p]).putBoolean("touchInvY" + s, touchInvY[p]).putBoolean("touchSwap" + s, touchSwap[p]); + e.putInt("gyroGain" + s, gyroGain[p]).putInt("gyroInvert" + s, gyroInvert[p]).putInt("gyroDeadzone" + s, gyroDeadzone[p]); + e.putFloat("gyroBiasX" + s, gyroBias[p][0]).putFloat("gyroBiasY" + s, gyroBias[p][1]).putFloat("gyroBiasZ" + s, gyroBias[p][2]); + for (int i = 0; i < 3; i++) e.putInt("gyroMap" + i + s, gyroMap[p][i]); + e.putBoolean("accelFollows" + s, accelFollows[p]); + e.putInt("lightR" + s, lightR[p]).putInt("lightG" + s, lightG[p]).putInt("lightB" + s, lightB[p]); + e.putInt("brightness" + s, brightness[p]).putInt("playerLed" + s, playerLed[p]); + } + e.putBoolean("forceUsbV2", forceUsb); + e.putBoolean("driverEnabled", driverEnabled); + e.putInt("presentAs", presentAs); + e.putInt("lastPresentAs", lastPresentAs); + e.putBoolean("driverBeforeHookup", driverBeforeHookup); + e.putInt("pollMs", pollMs); + e.putBoolean("bubble", bubble).putInt("bubbleX", bubbleX).putInt("bubbleY", bubbleY); + e.apply(); + } + + private static int clamp(int v, int lo, int hi) { return Math.max(lo, Math.min(hi, v)); } + + /** Is the source button [srcIndex] currently pressed, given the two button masks? */ + static boolean srcDown(int mask, int mask2, int srcIndex) { + if (srcIndex <= 0 || srcIndex >= SRC_NAMES.length) return false; + int m = SRC_SEL[srcIndex] == 1 ? mask2 : mask; + return ((m >> SRC_BIT[srcIndex]) & 1) != 0; + } + + /** Applies the 4 grip remaps (targets 0..3) to the mask for player [p]; a mapped source stops acting as itself. */ + static int applyGripRemap(int mask, int mask2, int p) { + if (p < 0 || p >= PLAYERS) p = 0; + int[] rm = remap[p]; + int add = 0, consumed = 0; + for (int t = 0; t < 4; t++) { + int srcIndex = rm[t]; + if (srcDown(mask, mask2, srcIndex)) { + add |= 1 << TARGET_BITS[t]; + if (SRC_SEL[srcIndex] == 0) consumed |= 1 << SRC_BIT[srcIndex]; + } + } + return (mask | add) & ~consumed; + } +} diff --git a/app/src/main/java/com/example/dsedge/UpdateChecker.java b/app/src/main/java/com/example/dsedge/UpdateChecker.java new file mode 100644 index 000000000..c4203e57b --- /dev/null +++ b/app/src/main/java/com/example/dsedge/UpdateChecker.java @@ -0,0 +1,116 @@ +package com.example.dsedge; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; + +/** + * Checks the GitHub Releases of the repo this APK was built in (CI writes assets/build.txt). + * The repo must be public for the unauthenticated GitHub API to see its releases. + */ +final class UpdateChecker { + interface Callback { + /** url is non-null only when a newer build exists. Called on the main thread. */ + void onResult(String message, String url); + } + + static final class BuildInfo { + String repo = "UNKNOWN", tag = "dev", sdl = "unknown"; + } + + private UpdateChecker() {} + + static BuildInfo readBuildInfo(Context ctx) { + BuildInfo b = new BuildInfo(); + // Built into another app (SDL3DroidDeck), the bridge's build info has its own name next to that app's assets. + try (InputStream in = ctx.getAssets().open(Host.embedded(ctx) ? "sdl3bridge-build.txt" : "build.txt"); + BufferedReader r = new BufferedReader(new InputStreamReader(in))) { + String line; + while ((line = r.readLine()) != null) { + int eq = line.indexOf('='); + if (eq <= 0) continue; + String k = line.substring(0, eq).trim(), v = line.substring(eq + 1).trim(); + if (k.equals("repo")) b.repo = v; + else if (k.equals("tag")) b.tag = v; + else if (k.equals("sdl")) b.sdl = v; + } + } catch (Exception ignored) { } + return b; + } + + static void check(final Context ctx, final Callback cb) { + final Handler main = new Handler(Looper.getMainLooper()); + final BuildInfo cur = readBuildInfo(ctx); + new Thread(() -> { + String msg; + String url = null; + if (Host.embedded(ctx)) { + msg = "Built into " + Host.appName(ctx) + ": it updates together with it."; + } else if (cur.repo.equals("UNKNOWN")) { + msg = "Updates are off: this APK was not built by the GitHub workflow."; + } else { + try { + HttpURLConnection c = (HttpURLConnection) new URL( + "https://api.github.com/repos/" + cur.repo + "/releases/latest").openConnection(); + c.setConnectTimeout(8000); + c.setReadTimeout(8000); + c.setRequestProperty("Accept", "application/vnd.github+json"); + int code = c.getResponseCode(); + if (code == 404) { + msg = "No release found. The repo must be public and have a finished build."; + } else if (code != 200) { + msg = "Update check failed (HTTP " + code + ")."; + } else { + StringBuilder sb = new StringBuilder(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(c.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) sb.append(line); + } + JSONObject j = new JSONObject(sb.toString()); + String latest = j.optString("tag_name", ""); + String notes = j.optString("body", ""); + String sdl = ""; + int i = notes.indexOf("sdl="); + if (i >= 0) sdl = notes.substring(i + 4).split("\\s")[0]; + String apk = null; + JSONArray assets = j.optJSONArray("assets"); + for (int k = 0; assets != null && k < assets.length(); k++) { + JSONObject a = assets.getJSONObject(k); + if (a.optString("name").endsWith(".apk")) { apk = a.optString("browser_download_url"); break; } + } + if (apk != null && newer(latest, cur.tag)) { + msg = "Update available: " + latest + (sdl.isEmpty() ? "" : " (SDL " + sdl + ")") + ". Tap to download."; + url = apk; + } else { + msg = "Up to date (" + cur.tag + ", SDL " + cur.sdl + ")."; + } + } + } catch (Exception e) { + msg = "Update check failed: " + e.getClass().getSimpleName(); + } + } + final String m = msg; + final String u = url; + main.post(() -> cb.onResult(m, u)); + }, "update-check").start(); + } + + /** Tags look like "build-12". Anything unparsable on our side ("dev") counts as older. */ + private static boolean newer(String latest, String current) { + long a = number(latest), b = number(current); + return a > b; + } + + private static long number(String tag) { + try { return Long.parseLong(tag.replaceAll("[^0-9]", "")); } catch (NumberFormatException e) { return -1; } + } +} diff --git a/app/src/main/java/com/example/dsedge/UsbAttachActivity.java b/app/src/main/java/com/example/dsedge/UsbAttachActivity.java new file mode 100644 index 000000000..396fb2575 --- /dev/null +++ b/app/src/main/java/com/example/dsedge/UsbAttachActivity.java @@ -0,0 +1,22 @@ +package com.example.dsedge; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +/** + * Receives "controller plugged in" from Android. Accepting Android's prompt gives the bridge USB access to the + * controller; nothing needs to be shown for that. The bridge's background service (with SDL3 in it) is started + * if it isn't running yet, and picks the controller up; no screen opens. + */ +public class UsbAttachActivity extends Activity { + @Override protected void onCreate(Bundle state) { + super.onCreate(state); + // The bridge runs in its background service (SDL3 included): start that, nothing comes to the front. + try { + startForegroundService(new Intent(this, BridgeService.class)); + } catch (RuntimeException ignored) { } + finish(); + overridePendingTransition(0, 0); + } +} diff --git a/app/src/main/java/org/libsdl/app/HIDDevice.java b/app/src/main/java/org/libsdl/app/HIDDevice.java new file mode 100644 index 000000000..f96095324 --- /dev/null +++ b/app/src/main/java/org/libsdl/app/HIDDevice.java @@ -0,0 +1,21 @@ +package org.libsdl.app; + +import android.hardware.usb.UsbDevice; + +interface HIDDevice +{ + public int getId(); + public int getVendorId(); + public int getProductId(); + public String getSerialNumber(); + public int getVersion(); + public String getManufacturerName(); + public String getProductName(); + public UsbDevice getDevice(); + public boolean open(); + public int writeReport(byte[] report, boolean feature); + public boolean readReport(byte[] report, boolean feature); + public void setFrozen(boolean frozen); + public void close(); + public void shutdown(); +} diff --git a/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java new file mode 100644 index 000000000..bcd8806c4 --- /dev/null +++ b/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java @@ -0,0 +1,829 @@ +package org.libsdl.app; + +import android.content.Context; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothGattService; +import android.hardware.usb.UsbDevice; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.os.*; + +//import com.android.internal.util.HexDump; + +import java.lang.Runnable; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.UUID; + +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { + + private static final String TAG = "hidapi"; + private HIDDeviceManager mManager; + private BluetoothDevice mDevice; + private int mDeviceId; + private BluetoothGatt mGatt; + private boolean mIsRegistered = false; + private boolean mIsConnected = false; + private boolean mIsChromebook = false; + private boolean mIsReconnecting = false; + private boolean mHasEnabledNotifications = false; + private boolean mHasSeenInputUpdate = false; + private boolean mFrozen = false; + private LinkedList mOperations; + GattOperation mCurrentOperation = null; + private Handler mHandler; + private int mProductId = -1; + private int mReportId = 0; + private UUID mInputCharacteristic; + + private static final int D0G_BLE2_PID = 0x1106; + private static final int TRITON_BLE_PID = 0x1303; + + + private static final int TRANSPORT_AUTO = 0; + private static final int TRANSPORT_BREDR = 1; + private static final int TRANSPORT_LE = 2; + + private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; + + static final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); + static final UUID inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static final UUID inputCharacteristicTriton_0x45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3"); + static final UUID inputCharacteristicTriton_0x47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3"); + static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); + static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; + + private HashMap mOutputReportChars = new HashMap(); + + static class GattOperation { + private enum Operation { + CHR_READ, + CHR_WRITE, + ENABLE_NOTIFICATION + } + + Operation mOp; + UUID mUuid; + byte[] mValue; + BluetoothGatt mGatt; + boolean mResult = true; + int mDelayMs = 0; + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, int delayMs) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mDelayMs = delayMs; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value, int delayMs) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + mDelayMs = delayMs; + } + + public void run() { + // This is executed in main thread + BluetoothGattCharacteristic chr; + + switch (mOp) { + case CHR_READ: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Reading characteristic " + chr.getUuid()); + if (!mGatt.readCharacteristic(chr)) { + Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case CHR_WRITE: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); + chr.setValue(mValue); + if (!mGatt.writeCharacteristic(chr)) { + Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case ENABLE_NOTIFICATION: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing descriptor of " + chr.getUuid()); + if (chr != null) { + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + int properties = chr.getProperties(); + byte[] value; + if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) { + value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { + value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + Log.e(TAG, "Unable to start notifications on input characteristic"); + mResult = false; + return; + } + + mGatt.setCharacteristicNotification(chr, true); + cccd.setValue(value); + if (!mGatt.writeDescriptor(cccd)) { + Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); + mResult = false; + return; + } + mResult = true; + } + } + } + } + + public boolean finish() { + return mResult; + } + + public int getDelayMs() { return mDelayMs; } + + private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { + BluetoothGattService valveService = mGatt.getService(steamControllerService); + if (valveService == null) + return null; + return valveService.getCharacteristic(uuid); + } + + static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.CHR_READ, uuid); + } + + static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) { + return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value); + } + + static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); + } + + static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid, int delayMs) { + return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid, delayMs); + } + } + + HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { + mManager = manager; + mDevice = device; + mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); + mIsRegistered = false; + mIsChromebook = SDLActivity.isChromebook(); + mOperations = new LinkedList(); + mHandler = new Handler(Looper.getMainLooper()); + + mGatt = connectGatt(); + mHasEnabledNotifications = false; + mHasSeenInputUpdate = false; + // final HIDDeviceBLESteamController finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // void run() { + // finalThis.checkConnectionForChromebookIssue(); + // } + // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + String getIdentifier() { + return String.format("SteamController.%s", mDevice.getAddress()); + } + + BluetoothGatt getGatt() { + return mGatt; + } + + // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead + // of TRANSPORT_LE. Let's force ourselves to connect low energy. + private BluetoothGatt connectGatt(boolean managed) { + if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { + try { + return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); + } catch (Exception e) { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } else { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } + + private BluetoothGatt connectGatt() { + return connectGatt(false); + } + + protected int getConnectionState() { + + Context context = mManager.getContext(); + if (context == null) { + // We are lacking any context to get our Bluetooth information. We'll just assume disconnected. + return BluetoothProfile.STATE_DISCONNECTED; + } + + BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); + if (btManager == null) { + // This device doesn't support Bluetooth. We should never be here, because how did + // we instantiate a device to start with? + return BluetoothProfile.STATE_DISCONNECTED; + } + + return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); + } + + void reconnect() { + + if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { + mGatt.disconnect(); + mGatt = connectGatt(); + } + + } + + protected void checkConnectionForChromebookIssue() { + if (!mIsChromebook) { + // We only do this on Chromebooks, because otherwise it's really annoying to just attempt + // over and over. + return; + } + + int connectionState = getConnectionState(); + + switch (connectionState) { + case BluetoothProfile.STATE_CONNECTED: + if (!mIsConnected) { + // We are in the Bad Chromebook Place. We can force a disconnect + // to try to recover. + Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + else if (!isRegistered()) { + if (mGatt.getServices().size() > 0) { + Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover."); + probeService(this); + } + else { + Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + } + else { + Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!"); + return; + } + break; + + case BluetoothProfile.STATE_DISCONNECTED: + Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); + + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + + case BluetoothProfile.STATE_CONNECTING: + Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer."); + break; + } + + final HIDDeviceBLESteamController finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.checkConnectionForChromebookIssue(); + } + }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + private boolean isRegistered() { + return mIsRegistered; + } + + private void setRegistered() { + mIsRegistered = true; + } + + private boolean probeService(HIDDeviceBLESteamController controller) { + + if (isRegistered()) { + return true; + } + + if (!mIsConnected) { + return false; + } + + Log.v(TAG, "probeService controller=" + controller); + + for (BluetoothGattService service : mGatt.getServices()) { + if (service.getUuid().equals(steamControllerService)) { + Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + if (chr.getUuid().equals(inputCharacteristicTriton_0x45)) { + Log.v(TAG, "Found Triton input characteristic 0x45"); + mProductId = TRITON_BLE_PID; + mReportId = 0x45; + mInputCharacteristic = chr.getUuid(); + } else if (chr.getUuid().equals(inputCharacteristicTriton_0x47)) { + Log.v(TAG, "Found Triton input characteristic 0x47"); + mProductId = TRITON_BLE_PID; + mReportId = 0x47; + mInputCharacteristic = chr.getUuid(); + } else if (chr.getUuid().equals(inputCharacteristicD0G)) { + Log.v(TAG, "Found D0G input characteristic"); + mProductId = D0G_BLE2_PID; + mReportId = 0x03; + mInputCharacteristic = chr.getUuid(); + } else { + Pattern reportPattern = Pattern.compile("100F6C([0-9A-Z]{2})", Pattern.CASE_INSENSITIVE); + Matcher matcher = reportPattern.matcher(chr.getUuid().toString()); + + if (matcher.find()) { + try { + int reportId = Integer.parseInt(matcher.group(1), 16); + + reportId -= 0x35; + if (reportId >= 0x80) { + // This is a Triton output report characteristic that we need to care about. + Log.v(TAG, "Found Triton output report 0x" + Integer.toString(reportId, 16)); + mOutputReportChars.put(reportId, chr); + } + } + catch (NumberFormatException nfe) { + Log.w(TAG, "Could not parse report characteristic " + chr.getUuid().toString() + ": " + nfe.toString()); + } + } + } + } + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + if (chr.getUuid().equals(mInputCharacteristic)) { + // Start notifications + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + enableNotification(chr.getUuid()); + } + } + } + return true; + } + } + + if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) { + Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); + mIsConnected = false; + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + } + + return false; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private void finishCurrentGattOperation() { + GattOperation op = null; + synchronized (mOperations) { + if (mCurrentOperation != null) { + op = mCurrentOperation; + mCurrentOperation = null; + } + } + if (op != null) { + boolean result = op.finish(); // TODO: Maybe in main thread as well? + + // Our operation failed, let's add it back to the beginning of our queue. + if (!result) { + mOperations.addFirst(op); + } + } + executeNextGattOperation(); + } + + private void executeNextGattOperation() { + synchronized (mOperations) { + if (mCurrentOperation != null) + return; + + if (mOperations.isEmpty()) + return; + + mCurrentOperation = mOperations.removeFirst(); + } + + Runnable gattOperationRunnable = new Runnable() { + @Override + public void run() { + synchronized (mOperations) { + if (mCurrentOperation == null) { + Log.e(TAG, "Current operation null in executor?"); + return; + } + + mCurrentOperation.run(); + // now wait for the GATT callback and when it comes, finish this operation + } + } + }; + + if (mCurrentOperation.getDelayMs() == 0) { + // Run in main thread + mHandler.post(gattOperationRunnable); + } + else { + // If we have a delay on this operation, wait before we post it. + mHandler.postDelayed(gattOperationRunnable, mCurrentOperation.getDelayMs()); + } + + } + + private void queueGattOperation(GattOperation op) { + synchronized (mOperations) { + mOperations.add(op); + } + executeNextGattOperation(); + } + + private void enableNotification(UUID chrUuid) { + // Add a 500ms delay to notification write for Amazon Fire TV devices, as otherwise if we do this too quickly after connecting + // it will return success and then silently drop the operation on the floor. + GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid, 500); + queueGattOperation(op); + + // Amazon Fire devices can also silently timeout on writeDescriptor, so + // set up a little delayed check that will attempt to write a second time. + // + // While this only seems to be needed on Amazon Fire TV devices at present, it + // doesn't hurt to have a retry on other devices as well. + // + final HIDDeviceBLESteamController finalThis = this; + final UUID finalUuid = chrUuid; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + if (!finalThis.mHasEnabledNotifications) { + + if (finalThis.mHasSeenInputUpdate) { + // Amazon Five devices may have enabled notifications on the input characteristic and not given us a callback. If we've seen + // input reports, though, somewhat by definition notifications are enabled. + Log.w(TAG, "WriteDescriptor has never returned, but we've seen input reports. Moving on with controller initialization."); + finalThis.mHasEnabledNotifications = true; + finalThis.enableValveMode(); + return; + } + + // Give one more try. + GattOperation retry = HIDDeviceBLESteamController.GattOperation.enableNotification(finalThis.mGatt, finalUuid, 500); + finalThis.queueGattOperation(retry); + } + } + }, 1000); + } + + void writeCharacteristic(UUID uuid, byte[] value) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); + queueGattOperation(op); + } + + void readCharacteristic(UUID uuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); + queueGattOperation(op); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////// BluetoothGattCallback overridden methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { + //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); + mIsReconnecting = false; + if (newState == 2) { + mIsConnected = true; + // Run directly, without GattOperation + if (!isRegistered()) { + mHandler.post(new Runnable() { + @Override + public void run() { + mGatt.discoverServices(); + } + }); + } + } + else if (newState == 0) { + mIsConnected = false; + } + + // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onServicesDiscovered status=" + status); + if (status == 0) { + if (gatt.getServices().size() == 0) { + Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); + mIsReconnecting = true; + mIsConnected = false; + gatt.disconnect(); + mGatt = connectGatt(false); + } else { + if (getProductId() == TRITON_BLE_PID) { + // Android will not properly play well with Data Length Extensions without manually requesting a large MTU, + // and Triton controllers require DLE support. + // + // 517 is basically a "magic number" as far as Android's bluetooth code is concerned, so do not change + // this value. It is functionally "please enable data length extensions" on some Android builds. + mGatt.requestMtu(517); + } + + probeService(this); + } + } + } + + @Override + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) { + mManager.HIDDeviceReportResponse(getId(), characteristic.getValue()); + } + + finishCurrentGattOperation(); + } + + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic)) { + // Only register controller with the native side once it has been fully configured + if (!isRegistered()) { + Log.v(TAG, "Registering Steam Controller with ID: " + getId()); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId); + setRegistered(); + } + } + + finishCurrentGattOperation(); + } + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + // Enable this for verbose logging of controller input reports + //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); + + if (characteristic.getUuid().equals(mInputCharacteristic) && !mFrozen) { + mHasSeenInputUpdate = true; + mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); + } + } + + @Override + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + //Log.v(TAG, "onDescriptorRead status=" + status); + } + + private void enableValveMode() + { + BluetoothGattService valveService = mGatt.getService(steamControllerService); + if (valveService == null) + return; + + BluetoothGattCharacteristic reportChr = valveService.getCharacteristic(reportCharacteristic); + if (reportChr != null) { + if (getProductId() == TRITON_BLE_PID) { + // For Triton we just mark things registered. + Log.v(TAG, "Registering Triton Steam Controller with ID: " + getId()); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId); + setRegistered(); + } else { + // For the original controller, we need to manually enter Valve mode. + Log.v(TAG, "Writing report characteristic to enter valve mode"); + reportChr.setValue(enterValveMode); + mGatt.writeCharacteristic(reportChr); + } + } + } + + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); + //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); + + if (chr.getUuid().equals(mInputCharacteristic)) { + mHasEnabledNotifications = true; + enableValveMode(); + } + + finishCurrentGattOperation(); + } + + @Override + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onReliableWriteCompleted status=" + status); + } + + @Override + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + //Log.v(TAG, "onReadRemoteRssi status=" + status); + } + + @Override + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + //Log.v(TAG, "onMtuChanged status=" + status); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + //////// Public API + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + // Valve Corporation + final int VALVE_USB_VID = 0x28DE; + return VALVE_USB_VID; + } + + @Override + public int getProductId() { + if (mProductId > 0) { + // We've already set a product ID. + return mProductId; + } + + if (mDevice.getName().startsWith("Steam Ctrl")) { + // We're a newer Triton device + mProductId = TRITON_BLE_PID; + } else { + // We're an OG Steam Controller + mProductId = D0G_BLE2_PID; + } + + return mProductId; + } + + @Override + public String getSerialNumber() { + // This will be read later via feature report by Steam + return "12345"; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + return "Valve Corporation"; + } + + @Override + public String getProductName() { + return "Steam Controller"; + } + + @Override + public UsbDevice getDevice() { + return null; + } + + @Override + public boolean open() { + return true; + } + + @Override + public int writeReport(byte[] report, boolean feature) { + if (!isRegistered()) { + Log.e(TAG, "Attempted writeReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + if (feature) { + // We need to skip the first byte, as that doesn't go over the air + byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); + //Log.v(TAG, "writeFeatureReport " + HexDump.dumpHexString(actual_report)); + writeCharacteristic(reportCharacteristic, actual_report); + return report.length; + } else { + // If we're an original-recipe Steam Controller we just write to the characteristic directly. + if (getProductId() == D0G_BLE2_PID) { + //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report)); + writeCharacteristic(reportCharacteristic, report); + return report.length; + } + + // If we're a Triton, we need to find the correct report characteristic. + if (report.length > 0) { + int reportId = report[0] & 0xFF; + BluetoothGattCharacteristic targetedReportCharacteristic = mOutputReportChars.get(reportId); + if (targetedReportCharacteristic != null) { + byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); + //Log.v(TAG, "writeOutputReport 0x" + Integer.toString(reportId, 16) + " " + HexDump.dumpHexString(report)); + writeCharacteristic(targetedReportCharacteristic.getUuid(), actual_report); + return report.length; + } else { + Log.w(TAG, "Got report write request for unknown report type 0x" + Integer.toString(reportId, 16)); + } + } + } + + return -1; + } + + @Override + public boolean readReport(byte[] report, boolean feature) { + if (!isRegistered()) { + Log.e(TAG, "Attempted readReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return false; + } + + if (feature) { + readCharacteristic(reportCharacteristic); + return true; + } else { + // Not implemented + return false; + } + } + + @Override + public void close() { + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + @Override + public void shutdown() { + close(); + + BluetoothGatt g = mGatt; + if (g != null) { + g.disconnect(); + g.close(); + mGatt = null; + } + mManager = null; + mIsRegistered = false; + mIsConnected = false; + mOperations.clear(); + } + +} + diff --git a/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/app/src/main/java/org/libsdl/app/HIDDeviceManager.java new file mode 100644 index 000000000..5fd82e906 --- /dev/null +++ b/app/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -0,0 +1,711 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.os.Build; +import android.util.Log; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.hardware.usb.*; +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; + +public class HIDDeviceManager { + private static final String TAG = "hidapi"; + private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION"; + + private static HIDDeviceManager sManager; + private static int sManagerRefCount = 0; + + static public HIDDeviceManager acquire(Context context) { + if (sManagerRefCount == 0) { + sManager = new HIDDeviceManager(context); + } + ++sManagerRefCount; + return sManager; + } + + static public void release(HIDDeviceManager manager) { + if (manager == sManager) { + --sManagerRefCount; + if (sManagerRefCount == 0) { + sManager.close(); + sManager = null; + } + } + } + + private Context mContext; + private HashMap mDevicesById = new HashMap(); + private HashMap mBluetoothDevices = new HashMap(); + private int mNextDeviceId = 0; + private SharedPreferences mSharedPreferences = null; + private boolean mIsChromebook = false; + private UsbManager mUsbManager; + private Handler mHandler; + private BluetoothManager mBluetoothManager; + private List mLastBluetoothDevices; + + private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceAttached(usbDevice); + } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceDetached(usbDevice); + } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)); + } + } + }; + + private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + // Bluetooth device was connected. If it was a Steam Controller, handle it + if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device connected: " + device); + + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + } + + // Bluetooth device was disconnected, remove from controller manager (if any) + if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device disconnected: " + device); + + disconnectBluetoothDevice(device); + } + } + }; + + private HIDDeviceManager(final Context context) { + mContext = context; + + HIDDeviceRegisterCallback(); + + mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE); + mIsChromebook = SDLActivity.isChromebook(); + +// if (shouldClear) { +// SharedPreferences.Editor spedit = mSharedPreferences.edit(); +// spedit.clear(); +// spedit.apply(); +// } +// else + { + mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0); + } + } + + Context getContext() { + return mContext; + } + + int getDeviceIDForIdentifier(String identifier) { + SharedPreferences.Editor spedit = mSharedPreferences.edit(); + + int result = mSharedPreferences.getInt(identifier, 0); + if (result == 0) { + result = mNextDeviceId++; + spedit.putInt("next_device_id", mNextDeviceId); + } + + spedit.putInt(identifier, result); + spedit.apply(); + return result; + } + + private void initializeUSB() { + mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE); + if (mUsbManager == null) { + return; + } + + /* + // Logging + for (UsbDevice device : mUsbManager.getDeviceList().values()) { + Log.i(TAG,"Path: " + device.getDeviceName()); + Log.i(TAG,"Manufacturer: " + device.getManufacturerName()); + Log.i(TAG,"Product: " + device.getProductName()); + Log.i(TAG,"ID: " + device.getDeviceId()); + Log.i(TAG,"Class: " + device.getDeviceClass()); + Log.i(TAG,"Protocol: " + device.getDeviceProtocol()); + Log.i(TAG,"Vendor ID " + device.getVendorId()); + Log.i(TAG,"Product ID: " + device.getProductId()); + Log.i(TAG,"Interface count: " + device.getInterfaceCount()); + Log.i(TAG,"---------------------------------------"); + + // Get interface details + for (int index = 0; index < device.getInterfaceCount(); index++) { + UsbInterface mUsbInterface = device.getInterface(index); + Log.i(TAG," ***** *****"); + Log.i(TAG," Interface index: " + index); + Log.i(TAG," Interface ID: " + mUsbInterface.getId()); + Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass()); + Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass()); + Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol()); + Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount()); + + // Get endpoint details + for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++) + { + UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi); + Log.i(TAG," ++++ ++++ ++++"); + Log.i(TAG," Endpoint index: " + epi); + Log.i(TAG," Attributes: " + mEndpoint.getAttributes()); + Log.i(TAG," Direction: " + mEndpoint.getDirection()); + Log.i(TAG," Number: " + mEndpoint.getEndpointNumber()); + Log.i(TAG," Interval: " + mEndpoint.getInterval()); + Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize()); + Log.i(TAG," Type: " + mEndpoint.getType()); + } + } + } + Log.i(TAG," No more devices connected."); + */ + + // Register for USB broadcasts and permission completions + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION); + if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */ + mContext.registerReceiver(mUsbBroadcast, filter, Context.RECEIVER_EXPORTED); + } else { + mContext.registerReceiver(mUsbBroadcast, filter); + } + + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + handleUsbDeviceAttached(usbDevice); + } + } + + UsbManager getUSBManager() { + return mUsbManager; + } + + private void shutdownUSB() { + try { + mContext.unregisterReceiver(mUsbBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) { + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) { + return true; + } + if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) { + return true; + } + return false; + } + + private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB360_IFACE_SUBCLASS = 93; + final int XB360_IFACE_PROTOCOL = 1; // Wired + final int XB360W_IFACE_PROTOCOL = 129; // Wireless + final int[] SUPPORTED_VENDORS = { + 0x0079, // GPD Win 2 + 0x0351, // CRKD + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x046d, // Logitech + 0x056e, // Elecom + 0x06a3, // Saitek + 0x0738, // Mad Catz + 0x07ff, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x1038, // SteelSeries + 0x10f5, // Turtle Beach + 0x11c9, // Nacon + 0x1209, // Generic + 0x12ab, // Unknown + 0x1430, // RedOctane + 0x146b, // BigBen + 0x1532, // Razer Sabertooth + 0x15e4, // Numark + 0x162e, // Joytech + 0x1689, // Razer Onza + 0x1949, // Lab126, Inc. + 0x1bad, // Harmonix + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2c22, // Qanba + 0x2dc8, // 8BitDo + 0x3537, // GameSir + 0x3651, // CRKD + 0x37d7, // Flydigi + 0x3958, // Red Octane Games + 0x9886, // ASTRO Gaming + }; + + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS && + (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL || + usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB1_IFACE_SUBCLASS = 71; + final int XB1_IFACE_PROTOCOL = 208; + final int[] SUPPORTED_VENDORS = { + 0x0351, // CRKD + 0x03f0, // HP + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x0738, // Mad Catz + 0x0b05, // ASUS + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x10f5, // Turtle Beach + 0x1209, // Generic + 0x1532, // Razer Wildcat + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x294b, // Snakebyte + 0x2dc8, // 8BitDo + 0x2e24, // Hyperkin + 0x2e95, // SCUF + 0x3285, // Nacon + 0x3537, // GameSir + 0x3651, // CRKD + 0x366c, // ByoWave + 0x3958, // Red Octane Games + }; + + if (usbInterface.getId() == 0 && + usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS && + usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private void handleUsbDeviceAttached(UsbDevice usbDevice) { + connectHIDDeviceUSB(usbDevice); + } + + private void handleUsbDeviceDetached(UsbDevice usbDevice) { + List devices = new ArrayList(); + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + devices.add(device.getId()); + } + } + for (int id : devices) { + HIDDevice device = mDevicesById.get(id); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) { + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + boolean opened = false; + if (permission_granted) { + opened = device.open(); + } + HIDDeviceOpenResult(device.getId(), opened); + } + } + } + + private void connectHIDDeviceUSB(UsbDevice usbDevice) { + synchronized (this) { + int interface_mask = 0; + for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) { + UsbInterface usbInterface = usbDevice.getInterface(interface_index); + if (isHIDDeviceInterface(usbDevice, usbInterface)) { + // Check to see if we've already added this interface + // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive + int interface_id = usbInterface.getId(); + if ((interface_mask & (1 << interface_id)) != 0) { + continue; + } + interface_mask |= (1 << interface_id); + + HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index); + int id = device.getId(); + mDevicesById.put(id, device); + HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol(), false, 0); + } + } + } + } + + private void initializeBluetooth() { + if (true) { // sdl3droiddeck: Bluetooth controllers are DroidDeck's own (SteamControllerBle) + Log.d(TAG, "Bluetooth left to the app"); + return; + } + Log.d(TAG, "Initializing Bluetooth"); + + if (Build.VERSION.SDK_INT >= 31 /* Android 12 */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH_CONNECT"); + return; + } + + if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH"); + return; + } + + if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { + Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE"); + return; + } + + // Find bonded bluetooth controllers and create SteamControllers for them + mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE); + if (mBluetoothManager == null) { + // This device doesn't support Bluetooth. + return; + } + + BluetoothAdapter btAdapter = mBluetoothManager.getAdapter(); + if (btAdapter == null) { + // This device has Bluetooth support in the codebase, but has no available adapters. + return; + } + + // Get our bonded devices. + for (BluetoothDevice device : btAdapter.getBondedDevices()) { + + Log.d(TAG, "Bluetooth device available: " + device); + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + + } + + // NOTE: These don't work on Chromebooks, to my undying dismay. + IntentFilter filter = new IntentFilter(); + filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED); + filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED); + if (Build.VERSION.SDK_INT >= 33) { /* Android 13.0 (TIRAMISU) */ + mContext.registerReceiver(mBluetoothBroadcast, filter, Context.RECEIVER_EXPORTED); + } else { + mContext.registerReceiver(mBluetoothBroadcast, filter); + } + + if (mIsChromebook) { + mHandler = new Handler(Looper.getMainLooper()); + mLastBluetoothDevices = new ArrayList(); + + // final HIDDeviceManager finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.chromebookConnectionHandler(); + // } + // }, 5000); + } + } + + private void shutdownBluetooth() { + try { + mContext.unregisterReceiver(mBluetoothBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly. + // This function provides a sort of dummy version of that, watching for changes in the + // connected devices and attempting to add controllers as things change. + void chromebookConnectionHandler() { + if (!mIsChromebook) { + return; + } + + ArrayList disconnected = new ArrayList(); + ArrayList connected = new ArrayList(); + + List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + + for (BluetoothDevice bluetoothDevice : currentConnected) { + if (!mLastBluetoothDevices.contains(bluetoothDevice)) { + connected.add(bluetoothDevice); + } + } + for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) { + if (!currentConnected.contains(bluetoothDevice)) { + disconnected.add(bluetoothDevice); + } + } + + mLastBluetoothDevices = currentConnected; + + for (BluetoothDevice bluetoothDevice : disconnected) { + disconnectBluetoothDevice(bluetoothDevice); + } + for (BluetoothDevice bluetoothDevice : connected) { + connectBluetoothDevice(bluetoothDevice); + } + + final HIDDeviceManager finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.chromebookConnectionHandler(); + } + }, 10000); + } + + boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) { + Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice); + synchronized (this) { + if (mBluetoothDevices.containsKey(bluetoothDevice)) { + Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect"); + + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + device.reconnect(); + + return false; + } + HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice); + int id = device.getId(); + mBluetoothDevices.put(bluetoothDevice, device); + mDevicesById.put(id, device); + + // The Steam Controller will mark itself connected once initialization is complete + } + return true; + } + + void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) { + synchronized (this) { + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + if (device == null) + return; + + int id = device.getId(); + mBluetoothDevices.remove(bluetoothDevice); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + boolean isSteamController(BluetoothDevice bluetoothDevice) { + // Sanity check. If you pass in a null device, by definition it is never a Steam Controller. + if (bluetoothDevice == null) { + return false; + } + + // If the device has no local name, we really don't want to try an equality check against it. + if (bluetoothDevice.getName() == null) { + return false; + } + + // Steam Controllers will always support Bluetooth Low Energy + if ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) == 0) { + return false; + } + + // Match on the name either the original Steam Controller or the new second-generation one advertise with. + return bluetoothDevice.getName().equals("SteamController") || bluetoothDevice.getName().startsWith("Steam Ctrl"); + } + + private void close() { + shutdownUSB(); + shutdownBluetooth(); + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.shutdown(); + } + mDevicesById.clear(); + mBluetoothDevices.clear(); + HIDDeviceReleaseCallback(); + } + } + + public void setFrozen(boolean frozen) { + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.setFrozen(frozen); + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private HIDDevice getDevice(int id) { + synchronized (this) { + HIDDevice result = mDevicesById.get(id); + if (result == null) { + Log.v(TAG, "No device for id: " + id); + Log.v(TAG, "Available devices: " + mDevicesById.keySet()); + } + return result; + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////// JNI interface functions + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + boolean initialize(boolean usb, boolean bluetooth) { + Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")"); + + if (usb) { + initializeUSB(); + } + if (bluetooth) { + initializeBluetooth(); + } + return true; + } + + boolean openDevice(int deviceID) { + Log.v(TAG, "openDevice deviceID=" + deviceID); + HIDDevice device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + // Look to see if this is a USB device and we have permission to access it + UsbDevice usbDevice = device.getDevice(); + if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) { + HIDDeviceOpenPending(deviceID); + try { + final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31 + int flags; + if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { + flags = FLAG_MUTABLE; + } else { + flags = 0; + } + + Intent intent = new Intent(HIDDeviceManager.ACTION_USB_PERMISSION); + intent.setPackage(mContext.getPackageName()); + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, intent, flags)); + } catch (Exception e) { + Log.v(TAG, "Couldn't request permission for USB device " + usbDevice); + HIDDeviceOpenResult(deviceID, false); + } + return false; + } + + try { + return device.open(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + int writeReport(int deviceID, byte[] report, boolean feature) { + try { + //Log.v(TAG, "writeReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.writeReport(report, feature); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + boolean readReport(int deviceID, byte[] report, boolean feature) { + try { + //Log.v(TAG, "readReport deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + return device.readReport(report, feature); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + void closeDevice(int deviceID) { + try { + Log.v(TAG, "closeDevice deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return; + } + + device.close(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////// Native methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private native void HIDDeviceRegisterCallback(); + private native void HIDDeviceReleaseCallback(); + + native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol, boolean bBluetooth, int reportID); + native void HIDDeviceOpenPending(int deviceID); + native void HIDDeviceOpenResult(int deviceID, boolean opened); + native void HIDDeviceDisconnected(int deviceID); + + native void HIDDeviceInputReport(int deviceID, byte[] report); + native void HIDDeviceReportResponse(int deviceID, byte[] report); +} diff --git a/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java new file mode 100644 index 000000000..08875d2bb --- /dev/null +++ b/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java @@ -0,0 +1,354 @@ +package org.libsdl.app; + +import android.hardware.usb.*; +import android.os.Build; +import android.util.Log; +import java.util.Arrays; +import java.util.Locale; + +class HIDDeviceUSB implements HIDDevice { + + private static final String TAG = "hidapi"; + + protected HIDDeviceManager mManager; + protected UsbDevice mDevice; + protected int mInterfaceIndex; + protected int mInterface; + protected int mDeviceId; + protected UsbDeviceConnection mConnection; + protected UsbEndpoint mInputEndpoint; + protected UsbEndpoint mOutputEndpoint; + protected InputThread mInputThread; + protected boolean mRunning; + protected boolean mFrozen; + protected boolean mClaimed; + + public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) { + mManager = manager; + mDevice = usbDevice; + mInterfaceIndex = interface_index; + mInterface = mDevice.getInterface(mInterfaceIndex).getId(); + mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier()); + mRunning = false; + mClaimed = false; + } + + String getIdentifier() { + return String.format(Locale.ENGLISH, "%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); + } + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + return mDevice.getVendorId(); + } + + @Override + public int getProductId() { + return mDevice.getProductId(); + } + + @Override + public String getSerialNumber() { + String result = null; + try { + result = mDevice.getSerialNumber(); + } + catch (Exception exception) { + //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage()); + } + if (result == null) { + result = ""; + } + return result; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + String result; + result = mDevice.getManufacturerName(); + if (result == null) { + result = String.format("%x", getVendorId()); + } + return result; + } + + @Override + public String getProductName() { + String result; + result = mDevice.getProductName(); + if (result == null) { + result = String.format("%x", getProductId()); + } + return result; + } + + @Override + public UsbDevice getDevice() { + return mDevice; + } + + String getDeviceName() { + return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")"; + } + + @Override + public boolean open() { + mConnection = mManager.getUSBManager().openDevice(mDevice); + if (mConnection == null) { + Log.w(TAG, "Unable to open USB device " + getDeviceName()); + return false; + } + + // Force claim our interface + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + if (!mConnection.claimInterface(iface, true)) { + Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName()); + close(); + return false; + } + mClaimed = true; + + // Find the endpoints + for (int j = 0; j < iface.getEndpointCount(); j++) { + UsbEndpoint endpt = iface.getEndpoint(j); + switch (endpt.getDirection()) { + case UsbConstants.USB_DIR_IN: + if (mInputEndpoint == null) { + mInputEndpoint = endpt; + } + break; + case UsbConstants.USB_DIR_OUT: + if (mOutputEndpoint == null) { + mOutputEndpoint = endpt; + } + break; + } + } + + // Make sure the required endpoints were present. The original Steam Controller and the wireless dongle for it do NOT + // actually have -- or require -- output endpoints, so we need to accept only an input one for them or else we'll fall + // back to the Android system gamepad functionality (and lose our paddles et al). + if (mInputEndpoint == null) { + Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); + mConnection.releaseInterface(iface); + close(); + return false; + } + + // Start listening for input + mRunning = true; + mInputThread = new InputThread(); + mInputThread.start(); + + return true; + } + + @Override + public int writeReport(byte[] report, boolean feature) { + if (mConnection == null) { + Log.w(TAG, "writeReport() called with no device connection"); + return -1; + } + + if (!mClaimed) { + Log.w(TAG, "writeReport() called but some other process currently owns the USB device"); + return -1; + } + + if (feature) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT, + 0x09/*HID set_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "writeFeatureReport() returned " + res + " on device " + getDeviceName()); + return -1; + } + + if (skipped_report_id) { + ++length; + } + return length; + } else { + if (mOutputEndpoint == null) + { + Log.e(TAG, "Tried to write an output report to an interface with no output endpoint!"); + return -1; + } + int res = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000); + if (res != report.length) { + Log.w(TAG, "writeOutputReport() returned " + res + " on device " + getDeviceName()); + } + return res; + } + } + + @Override + public boolean readReport(byte[] report, boolean feature) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (mConnection == null) { + Log.w(TAG, "readReport() called with no device connection"); + return false; + } + if (!mClaimed) { + if (feature) { + return false; + } + return true; + } + + if (report_number == 0x0) { + /* Offset the return buffer by 1, so that the report ID + will remain in byte 0. */ + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN, + 0x01/*HID get_report*/, + ((feature ? 3/*HID feature*/ : 1/*HID Input*/) << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName()); + return false; + } + + if (skipped_report_id) { + ++res; + ++length; + } + + byte[] data; + if (res == length) { + data = report; + } else { + data = Arrays.copyOfRange(report, 0, res); + } + mManager.HIDDeviceReportResponse(mDeviceId, data); + + return true; + } + + @Override + public void close() { + mRunning = false; + if (mInputThread != null) { + while (mInputThread.isAlive()) { + mInputThread.interrupt(); + try { + mInputThread.join(); + } catch (InterruptedException e) { + // Keep trying until we're done + } + } + mInputThread = null; + } + if (mConnection != null) { + if (mClaimed) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + mConnection.releaseInterface(iface); + } + mConnection.close(); + mConnection = null; + mClaimed = false; + } + } + + @Override + public void shutdown() { + close(); + mManager = null; + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + + /* If we have a valid device connection and the claim state doesn't match what we want, try to correct that. */ + if (mConnection != null && mClaimed == mFrozen) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + if (frozen) { + mClaimed = !mConnection.releaseInterface(iface); + if (mClaimed) { + Log.e(TAG, "Tried to release claim on USB device, but failed!"); + } + } else { + mClaimed = mConnection.claimInterface(iface, true); + if (!mClaimed) { + Log.e(TAG, "Tried to regain claim on USB device, but failed!"); + } + } + } + } + + protected class InputThread extends Thread { + @Override + public void run() { + int packetSize = mInputEndpoint.getMaxPacketSize(); + byte[] packet = new byte[packetSize]; + while (mRunning) { + int r; + try + { + r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000); + } + catch (Exception e) + { + Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e); + break; + } + if (r < 0) { + // Could be a timeout or an I/O error + } + if (r > 0) { + byte[] data; + if (r == packetSize) { + data = packet; + } else { + data = Arrays.copyOfRange(packet, 0, r); + } + + if (!mFrozen) { + mManager.HIDDeviceInputReport(mDeviceId, data); + } + } + } + } + } +} diff --git a/app/src/main/java/org/libsdl/app/SDL.java b/app/src/main/java/org/libsdl/app/SDL.java new file mode 100644 index 000000000..d9650a72e --- /dev/null +++ b/app/src/main/java/org/libsdl/app/SDL.java @@ -0,0 +1,90 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.content.Context; + +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + static public void setupJNI() { + SDLActivity.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + static public void initialize() { + setContext(null); + + SDLActivity.initialize(); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + static public void setContext(Activity context) { + SDLAudioManager.setContext(context); + mContext = context; + } + + static public Activity getContext() { + return mContext; + } + + static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + loadLibrary(libraryName, mContext); + } + + static void loadLibrary(String libraryName, Context context) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + + if (libraryName == null) { + throw new NullPointerException("No library name provided."); + } + + try { + // Let's see if we have ReLinker available in the project. This is necessary for + // some projects that have huge numbers of local libraries bundled, and thus may + // trip a bug in Android's native library loader which ReLinker works around. (If + // loadLibrary works properly, ReLinker will simply use the normal Android method + // internally.) + // + // To use ReLinker, just add it as a dependency. For more information, see + // https://github.com/KeepSafe/ReLinker for ReLinker's repository. + // + Class relinkClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); + Class relinkListenerClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); + Class contextClass = context.getClassLoader().loadClass("android.content.Context"); + Class stringClass = context.getClassLoader().loadClass("java.lang.String"); + + // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if + // they've changed during updates. + Method forceMethod = relinkClass.getDeclaredMethod("force"); + Object relinkInstance = forceMethod.invoke(null); + Class relinkInstanceClass = relinkInstance.getClass(); + + // Actually load the library! + Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); + loadMethod.invoke(relinkInstance, context, libraryName, null, null); + } + catch (final Throwable e) { + // Fall back + try { + System.loadLibrary(libraryName); + } + catch (final UnsatisfiedLinkError ule) { + throw ule; + } + catch (final SecurityException se) { + throw se; + } + } + } + + protected static Activity mContext; +} diff --git a/app/src/main/java/org/libsdl/app/SDLActivity.java b/app/src/main/java/org/libsdl/app/SDLActivity.java new file mode 100644 index 000000000..9b3e99a5b --- /dev/null +++ b/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -0,0 +1,2247 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.UiModeManager; +import android.content.ActivityNotFoundException; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.LocaleList; +import android.os.Message; +import android.os.ParcelFileDescriptor; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.view.Display; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.webkit.MimeTypeMap; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.Locale; + + +/** + SDL Activity +*/ +public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener { + private static final String TAG = "SDL"; + private static final int SDL_MAJOR_VERSION = 3; + private static final int SDL_MINOR_VERSION = 4; + private static final int SDL_MICRO_VERSION = 18; +/* + // Display InputType.SOURCE/CLASS of events and devices + // + // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]"); + // SDLActivity.debugSource(event.getSource(), "event"); + public static void debugSource(int sources, String prefix) { + int s = sources; + int s_copy = sources; + String cls = ""; + String src = ""; + int tst = 0; + int FLAG_TAINTED = 0x80000000; + + if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON"; + if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK"; + if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER"; + if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION"; + if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL"; + + + int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits + s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON + | InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL); + + if (s2 != 0) cls += "Some_Unknown"; + + s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class; + + if (Build.VERSION.SDK_INT >= 23) { + tst = InputDevice.SOURCE_BLUETOOTH_STYLUS; + if ((s & tst) == tst) src += " BLUETOOTH_STYLUS"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_DPAD; + if ((s & tst) == tst) src += " DPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_GAMEPAD; + if ((s & tst) == tst) src += " GAMEPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_HDMI; + if ((s & tst) == tst) src += " HDMI"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_JOYSTICK; + if ((s & tst) == tst) src += " JOYSTICK"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_KEYBOARD; + if ((s & tst) == tst) src += " KEYBOARD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_MOUSE; + if ((s & tst) == tst) src += " MOUSE"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 26) { + tst = InputDevice.SOURCE_MOUSE_RELATIVE; + if ((s & tst) == tst) src += " MOUSE_RELATIVE"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ROTARY_ENCODER; + if ((s & tst) == tst) src += " ROTARY_ENCODER"; + s2 &= ~tst; + } + tst = InputDevice.SOURCE_STYLUS; + if ((s & tst) == tst) src += " STYLUS"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHPAD; + if ((s & tst) == tst) src += " TOUCHPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHSCREEN; + if ((s & tst) == tst) src += " TOUCHSCREEN"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCH_NAVIGATION; + if ((s & tst) == tst) src += " TOUCH_NAVIGATION"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TRACKBALL; + if ((s & tst) == tst) src += " TRACKBALL"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ANY; + if ((s & tst) == tst) src += " ANY"; + s2 &= ~tst; + + if (s == FLAG_TAINTED) src += " FLAG_TAINTED"; + s2 &= ~FLAG_TAINTED; + + if (s2 != 0) src += " Some_Unknown"; + + Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src); + } +*/ + + public static boolean mIsResumedCalled, mHasFocus; + public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */); + + // Cursor types + // private static final int SDL_SYSTEM_CURSOR_NONE = -1; + private static final int SDL_SYSTEM_CURSOR_ARROW = 0; + private static final int SDL_SYSTEM_CURSOR_IBEAM = 1; + private static final int SDL_SYSTEM_CURSOR_WAIT = 2; + private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3; + private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4; + private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; + private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; + private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; + private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; + private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; + private static final int SDL_SYSTEM_CURSOR_NO = 10; + private static final int SDL_SYSTEM_CURSOR_HAND = 11; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPLEFT = 12; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOP = 13; + private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPRIGHT = 14; + private static final int SDL_SYSTEM_CURSOR_WINDOW_RIGHT = 15; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMRIGHT = 16; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOM = 17; + private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMLEFT = 18; + private static final int SDL_SYSTEM_CURSOR_WINDOW_LEFT = 19; + + protected static final int SDL_ORIENTATION_UNKNOWN = 0; + protected static final int SDL_ORIENTATION_LANDSCAPE = 1; + protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2; + protected static final int SDL_ORIENTATION_PORTRAIT = 3; + protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4; + + protected static int mCurrentRotation; + protected static Locale mCurrentLocale; + + // Handle the state of the native layer + public enum NativeState { + INIT, RESUMED, PAUSED + } + + public static NativeState mNextNativeState; + public static NativeState mCurrentNativeState; + + /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ + public static boolean mBrokenLibraries = true; + + // Main components + protected static SDLActivity mSingleton; + protected static SDLSurface mSurface; + protected static SDLDummyEdit mTextEdit; + protected static ViewGroup mLayout; + protected static SDLClipboardHandler mClipboardHandler; + protected static Hashtable mCursors; + protected static int mLastCursorID; + protected static SDLGenericMotionListener_API14 mMotionListener; + protected static HIDDeviceManager mHIDDeviceManager; + + // This is what SDL runs in. It invokes SDL_main(), eventually + protected static Thread mSDLThread; + protected static boolean mSDLMainFinished = false; + protected static boolean mActivityCreated = false; + private static SDLFileDialogState mFileDialogState = null; + protected static boolean mDispatchingKeyEvent = false; + + public static SDLGenericMotionListener_API14 getMotionListener() { + if (mMotionListener == null) { + if (Build.VERSION.SDK_INT >= 29 /* Android 10 (Q) */) { + mMotionListener = new SDLGenericMotionListener_API29(); + } else if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) { + mMotionListener = new SDLGenericMotionListener_API26(); + } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + mMotionListener = new SDLGenericMotionListener_API24(); + } else { + mMotionListener = new SDLGenericMotionListener_API14(); + } + } + + return mMotionListener; + } + + /** + * The application entry point, called on a dedicated thread (SDLThread). + * The default implementation uses the getMainSharedObject() and getMainFunction() methods + * to invoke native code from the specified shared library. + * It can be overridden by derived classes. + */ + protected void main() { + String library = SDLActivity.mSingleton.getMainSharedObject(); + String function = SDLActivity.mSingleton.getMainFunction(); + String[] arguments = SDLActivity.mSingleton.getArguments(); + + Log.v("SDL", "Running main function " + function + " from library " + library); + SDLActivity.nativeRunMain(library, function, arguments); + Log.v("SDL", "Finished main function"); + } + + /** + * This method returns the name of the shared object with the application entry point + * It can be overridden by derived classes. + */ + protected String getMainSharedObject() { + String library; + String[] libraries = SDLActivity.mSingleton.getLibraries(); + if (libraries.length > 0) { + library = "lib" + libraries[libraries.length - 1] + ".so"; + } else { + library = "libmain.so"; + } + return getContext().getApplicationInfo().nativeLibraryDir + "/" + library; + } + + /** + * This method returns the name of the application entry point + * It can be overridden by derived classes. + */ + protected String getMainFunction() { + return "SDL_main"; + } + + /** + * This method is called by SDL before loading the native shared libraries. + * It can be overridden to provide names of shared libraries to be loaded. + * The default implementation returns the defaults. It never returns null. + * An array returned by a new implementation must at least contain "SDL3". + * Also keep in mind that the order the libraries are loaded may matter. + * @return names of shared libraries to be loaded (e.g. "SDL3", "main"). + */ + protected String[] getLibraries() { + return new String[] { + "SDL3", + // "SDL3_image", + // "SDL3_mixer", + // "SDL3_net", + // "SDL3_ttf", + "main" + }; + } + + // Load the .so + public void loadLibraries() { + for (String lib : getLibraries()) { + SDL.loadLibrary(lib, this); + } + } + + /** + * This method is called by SDL before starting the native application thread. + * It can be overridden to provide the arguments after the application name. + * The default implementation returns an empty array. It never returns null. + * @return arguments for the native application. + */ + protected String[] getArguments() { + return new String[0]; + } + + public static void initialize() { + // The static nature of the singleton and Android quirkyness force us to initialize everything here + // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values + mSingleton = null; + mSurface = null; + mTextEdit = null; + mLayout = null; + mClipboardHandler = null; + mCursors = new Hashtable(); + mLastCursorID = 0; + mSDLThread = null; + mIsResumedCalled = false; + mHasFocus = true; + mNextNativeState = NativeState.INIT; + mCurrentNativeState = NativeState.INIT; + } + + protected SDLSurface createSDLSurface(Context context) { + return new SDLSurface(context); + } + + // Setup + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.v(TAG, "Manufacturer: " + Build.MANUFACTURER); + Log.v(TAG, "Device: " + Build.DEVICE); + Log.v(TAG, "Model: " + Build.MODEL); + Log.v(TAG, "onCreate()"); + super.onCreate(savedInstanceState); + + + /* Control activity re-creation */ + if (mSDLMainFinished || mActivityCreated) { + boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity(); + if (mSDLMainFinished) { + Log.v(TAG, "SDL main() finished"); + } + if (allow_recreate) { + Log.v(TAG, "activity re-created"); + } else { + Log.v(TAG, "activity finished"); + System.exit(0); + return; + } + } + + mActivityCreated = true; + + try { + Thread.currentThread().setName("SDLActivity"); + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + + // Load shared libraries + String errorMsgBrokenLib = ""; + try { + loadLibraries(); + mBrokenLibraries = false; /* success */ + } catch(UnsatisfiedLinkError e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } catch(Exception e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } + + if (!mBrokenLibraries) { + String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." + + String.valueOf(SDL_MINOR_VERSION) + "." + + String.valueOf(SDL_MICRO_VERSION); + String version = nativeGetVersion(); + if (!version.equals(expected_version)) { + mBrokenLibraries = true; + errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")"; + } + } + + if (mBrokenLibraries) { + mSingleton = this; + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); + dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall." + + System.getProperty("line.separator") + + System.getProperty("line.separator") + + "Error: " + errorMsgBrokenLib); + dlgAlert.setTitle("SDL Error"); + dlgAlert.setPositiveButton("Exit", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + // if this button is clicked, close current activity + SDLActivity.mSingleton.finish(); + } + }); + dlgAlert.setCancelable(false); + dlgAlert.create().show(); + + return; + } + + + /* Control activity re-creation */ + /* Robustness: check that the native code is run for the first time. + * (Maybe Activity was reset, but not the native code.) */ + { + int run_count = SDLActivity.nativeCheckSDLThreadCounter(); /* get and increment a native counter */ + if (run_count != 0) { + boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity(); + if (allow_recreate) { + Log.v(TAG, "activity re-created // run_count: " + run_count); + } else { + Log.v(TAG, "activity finished // run_count: " + run_count); + System.exit(0); + return; + } + } + } + + // Set up JNI + SDL.setupJNI(); + + // Initialize state + SDL.initialize(); + + // So we can call stuff from static callbacks + mSingleton = this; + SDL.setContext(this); + + mClipboardHandler = new SDLClipboardHandler(); + + mHIDDeviceManager = HIDDeviceManager.acquire(this); + + // Set up the surface + mSurface = createSDLSurface(this); + + mLayout = new RelativeLayout(this); + mLayout.addView(mSurface); + + // Get our current screen orientation and pass it down. + SDLActivity.nativeSetNaturalOrientation(SDLActivity.getNaturalOrientation()); + mCurrentRotation = SDLActivity.getCurrentRotation(); + SDLActivity.onNativeRotationChanged(mCurrentRotation); + + try { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + mCurrentLocale = getContext().getResources().getConfiguration().locale; + } else { + mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0); + } + } catch(Exception ignored) { + } + + switch (getContext().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) { + case Configuration.UI_MODE_NIGHT_NO: + SDLActivity.onNativeDarkModeChanged(false); + break; + case Configuration.UI_MODE_NIGHT_YES: + SDLActivity.onNativeDarkModeChanged(true); + break; + } + + setContentView(mLayout); + + setWindowStyle(false); + + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Get filename from "Open with" of another application + Intent intent = getIntent(); + if (intent != null && intent.getData() != null) { + String filename = intent.getData().getPath(); + if (filename != null) { + Log.v(TAG, "Got filename: " + filename); + SDLActivity.onNativeDropFile(filename); + } + } + } + + protected void pauseNativeThread() { + mNextNativeState = NativeState.PAUSED; + mIsResumedCalled = false; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + protected void resumeNativeThread() { + mNextNativeState = NativeState.RESUMED; + mIsResumedCalled = true; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + // Events + @Override + protected void onPause() { + Log.v(TAG, "onPause()"); + super.onPause(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(true); + } + + if (!mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onResume() { + Log.v(TAG, "onResume()"); + super.onResume(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(false); + } + + if (!mHasMultiWindow) { + resumeNativeThread(); + } + } + + @Override + protected void onStop() { + Log.v(TAG, "onStop()"); + super.onStop(); + if (mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onStart() { + Log.v(TAG, "onStart()"); + super.onStart(); + if (mHasMultiWindow) { + resumeNativeThread(); + } + } + + public static int getNaturalOrientation() { + int result = SDL_ORIENTATION_UNKNOWN; + + Activity activity = getContext(); + if (activity != null) { + Configuration config = activity.getResources().getConfiguration(); + Display display = activity.getWindowManager().getDefaultDisplay(); + int rotation = display.getRotation(); + if (((rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) && + config.orientation == Configuration.ORIENTATION_LANDSCAPE) || + ((rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) && + config.orientation == Configuration.ORIENTATION_PORTRAIT)) { + result = SDL_ORIENTATION_LANDSCAPE; + } else { + result = SDL_ORIENTATION_PORTRAIT; + } + } + return result; + } + + public static int getCurrentRotation() { + int result = 0; + + Activity activity = getContext(); + if (activity != null) { + Display display = activity.getWindowManager().getDefaultDisplay(); + switch (display.getRotation()) { + case Surface.ROTATION_0: + result = 0; + break; + case Surface.ROTATION_90: + result = 90; + break; + case Surface.ROTATION_180: + result = 180; + break; + case Surface.ROTATION_270: + result = 270; + break; + } + } + return result; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); + + // If we are gaining focus, we can always try to restore our USB devices. If we are losing focus, + // only try to relinquish them if we don't have background events allowed (for multi-window Android setups). + if (hasFocus || !SDLActivity.nativeGetHintBoolean("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS", false)) { + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(!hasFocus); + } + } + + if (SDLActivity.mBrokenLibraries) { + return; + } + + mHasFocus = hasFocus; + if (hasFocus) { + mNextNativeState = NativeState.RESUMED; + SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded(); + + SDLActivity.handleNativeState(); + nativeFocusChanged(true); + + } else { + nativeFocusChanged(false); + if (!mHasMultiWindow) { + mNextNativeState = NativeState.PAUSED; + SDLActivity.handleNativeState(); + } + } + } + + @Override + public void onTrimMemory(int level) { + Log.v(TAG, "onTrimMemory()"); + super.onTrimMemory(level); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.nativeLowMemory(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.v(TAG, "onConfigurationChanged()"); + super.onConfigurationChanged(newConfig); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { + mCurrentLocale = newConfig.locale; + SDLActivity.onNativeLocaleChanged(); + } + + switch (newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) { + case Configuration.UI_MODE_NIGHT_NO: + SDLActivity.onNativeDarkModeChanged(false); + break; + case Configuration.UI_MODE_NIGHT_YES: + SDLActivity.onNativeDarkModeChanged(true); + break; + } + } + + @Override + protected void onDestroy() { + Log.v(TAG, "onDestroy()"); + + if (mHIDDeviceManager != null) { + HIDDeviceManager.release(mHIDDeviceManager); + mHIDDeviceManager = null; + } + + SDLAudioManager.release(this); + + if (SDLActivity.mBrokenLibraries) { + super.onDestroy(); + return; + } + + if (SDLActivity.mSDLThread != null) { + + // Send Quit event to "SDLThread" thread + SDLActivity.nativeSendQuit(); + + // Wait for "SDLThread" thread to end + try { + // Use a timeout because: + // C SDLmain() thread might have started (mSDLThread.start() called) + // while the SDL_Init() might not have been called yet, + // and so the previous QUIT event will be discarded by SDL_Init() and app is running, not exiting. + SDLActivity.mSDLThread.join(1000); + } catch(Exception e) { + Log.v(TAG, "Problem stopping SDLThread: " + e); + } + } + + SDLActivity.nativeQuit(); + + super.onDestroy(); + } + + @Override + public void onBackPressed() { + // Check if we want to block the back button in case of mouse right click. + // + // If we do, the normal hardware back button will no longer work and people have to use home, + // but the mouse right click will work. + // + boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false); + if (trapBack) { + // Exit and let the mouse handler handle this button (if appropriate) + return; + } + + // Default system back button behavior. + if (!isFinishing()) { + super.onBackPressed(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (mFileDialogState != null && mFileDialogState.requestCode == requestCode) { + /* This is our file dialog */ + String[] filelist = null; + + if (data != null) { + Uri singleFileUri = data.getData(); + + if (singleFileUri == null) { + /* Use Intent.getClipData to get multiple choices */ + ClipData clipData = data.getClipData(); + assert clipData != null; + + filelist = new String[clipData.getItemCount()]; + + for (int i = 0; i < filelist.length; i++) { + String uri = clipData.getItemAt(i).getUri().toString(); + filelist[i] = uri; + } + } else { + /* Only one file is selected. */ + filelist = new String[]{singleFileUri.toString()}; + } + } else { + /* User cancelled the request. */ + filelist = new String[0]; + } + + // TODO: Detect the file MIME type and pass the filter value accordingly. + SDLActivity.onNativeFileDialog(requestCode, filelist, -1); + mFileDialogState = null; + } + } + + // Called by JNI from SDL. + public static void manualBackButton() { + mSingleton.pressBackButton(); + } + + // Used to get us onto the activity's main thread + public void pressBackButton() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (!SDLActivity.this.isFinishing()) { + SDLActivity.this.superOnBackPressed(); + } + } + }); + } + + // Used to access the system back behavior. + public void superOnBackPressed() { + super.onBackPressed(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + if (SDLActivity.mBrokenLibraries) { + return false; + } + + int keyCode = event.getKeyCode(); + // Ignore certain special keys so they're handled by Android + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_CAMERA || + keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */ + keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */ + ) { + return false; + } + mDispatchingKeyEvent = true; + boolean result = super.dispatchKeyEvent(event); + mDispatchingKeyEvent = false; + return result; + } + + public static boolean dispatchingKeyEvent() { + return mDispatchingKeyEvent; + } + + /* Transition to next state */ + public static void handleNativeState() { + + if (mNextNativeState == mCurrentNativeState) { + // Already in same state, discard. + return; + } + + // Try a transition to init state + if (mNextNativeState == NativeState.INIT) { + + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to paused state + if (mNextNativeState == NativeState.PAUSED) { + if (mSDLThread != null) { + nativePause(); + } + if (mSurface != null) { + mSurface.handlePause(); + } + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to resumed state + if (mNextNativeState == NativeState.RESUMED) { + if (mSurface.mIsSurfaceReady && (mHasFocus || mHasMultiWindow) && mIsResumedCalled) { + if (mSDLThread == null) { + // This is the entry point to the C app. + // Start up the C app thread and enable sensor input for the first time + // FIXME: Why aren't we enabling sensor input at start? + + mSDLThread = new Thread(new SDLMain(), "SDLThread"); + mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true); + mSDLThread.start(); + + // No nativeResume(), don't signal Android_ResumeSem + } else { + nativeResume(); + } + mSurface.handleResume(); + + mCurrentNativeState = mNextNativeState; + } + } + } + + // Messages from the SDLMain thread + protected static final int COMMAND_CHANGE_TITLE = 1; + protected static final int COMMAND_CHANGE_WINDOW_STYLE = 2; + protected static final int COMMAND_TEXTEDIT_HIDE = 3; + protected static final int COMMAND_SET_KEEP_SCREEN_ON = 5; + protected static final int COMMAND_USER = 0x8000; + + protected static boolean mFullscreenModeActive; + + /** + * This method is called by SDL if SDL did not handle a message itself. + * This happens if a received message contains an unsupported command. + * Method can be overwritten to handle Messages in a different class. + * @param command the command of the message. + * @param param the parameter of the message. May be null. + * @return if the message was handled in overridden method. + */ + protected boolean onUnhandledMessage(int command, Object param) { + return false; + } + + /** + * A Handler class for Messages from native SDL applications. + * It uses current Activities as target (e.g. for the title). + * static to prevent implicit references to enclosing object. + */ + protected static class SDLCommandHandler extends Handler { + @Override + public void handleMessage(Message msg) { + Context context = getContext(); + if (context == null) { + Log.e(TAG, "error handling message, getContext() returned null"); + return; + } + switch (msg.arg1) { + case COMMAND_CHANGE_TITLE: + if (context instanceof Activity) { + ((Activity) context).setTitle((String)msg.obj); + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_CHANGE_WINDOW_STYLE: + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + SDLActivity.mFullscreenModeActive = true; + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + SDLActivity.mFullscreenModeActive = false; + } + if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */) { + window.getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + } + if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */ && + Build.VERSION.SDK_INT < 35 /* Android 15 */) { + SDLActivity.onNativeInsetsChanged(0, 0, 0, 0); + } + } + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_TEXTEDIT_HIDE: + if (mTextEdit != null) { + // Note: On some devices setting view to GONE creates a flicker in landscape. + // Setting the View's sizes to 0 is similar to GONE but without the flicker. + // The sizes will be set to useful values when the keyboard is shown again. + mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0)); + + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); + + onNativeScreenKeyboardHidden(); + + mSurface.requestFocus(); + } + break; + case COMMAND_SET_KEEP_SCREEN_ON: + { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + break; + } + default: + if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) { + Log.e(TAG, "error handling message, command is " + msg.arg1); + } + } + } + } + + // Handler for the messages + Handler commandHandler = new SDLCommandHandler(); + + // Send a message from the SDLMain thread + protected boolean sendCommand(int command, Object data) { + Message msg = commandHandler.obtainMessage(); + msg.arg1 = command; + msg.obj = data; + boolean result = commandHandler.sendMessage(msg); + + if (command == COMMAND_CHANGE_WINDOW_STYLE) { + // Ensure we don't return until the resize has actually happened, + // or 500ms have passed. + + boolean bShouldWait = false; + + if (data instanceof Integer) { + // Let's figure out if we're already laid out fullscreen or not. + Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + DisplayMetrics realMetrics = new DisplayMetrics(); + display.getRealMetrics(realMetrics); + + boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) && + (realMetrics.heightPixels == mSurface.getHeight())); + + if ((Integer) data == 1) { + // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going + // to change size and should wait for surfaceChanged() before we return, so the size + // is right back in native code. If we're already laid out fullscreen, though, we're + // not going to change size even if we change decor modes, so we shouldn't wait for + // surfaceChanged() -- which may not even happen -- and should return immediately. + bShouldWait = !bFullscreenLayout; + } else { + // If we're laid out fullscreen (even if the status bar and nav bar are present), + // or are actively in fullscreen, we're going to change size and should wait for + // surfaceChanged before we return, so the size is right back in native code. + bShouldWait = bFullscreenLayout; + } + } + + if (bShouldWait && (getContext() != null)) { + // We'll wait for the surfaceChanged() method, which will notify us + // when called. That way, we know our current size is really the + // size we need, instead of grabbing a size that's still got + // the navigation and/or status bars before they're hidden. + // + // We'll wait for up to half a second, because some devices + // take a surprisingly long time for the surface resize, but + // then we'll just give up and return. + // + synchronized (getContext()) { + try { + getContext().wait(500); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + } + } + + return result; + } + + // C functions we call + public static native String nativeGetVersion(); + public static native void nativeSetupJNI(); + public static native void nativeInitMainThread(); + public static native void nativeCleanupMainThread(); + public static native int nativeRunMain(String library, String function, Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float density, float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, + int action, float x, + float y, float p); + public static native void onNativePen(int penId, int device_type, int button, int action, float x, float y, float p); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native void onNativeScreenKeyboardShown(); + public static native void onNativeScreenKeyboardHidden(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, boolean default_value); + public static native void nativeSetenv(String name, String value); + public static native void nativeSetNaturalOrientation(int orientation); + public static native void onNativeRotationChanged(int rotation); + public static native void onNativeInsetsChanged(int left, int right, int top, int bottom); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, boolean result); + public static native void onNativeLocaleChanged(); + public static native void onNativeDarkModeChanged(boolean enabled); + public static native boolean nativeAllowRecreateActivity(); + public static native int nativeCheckSDLThreadCounter(); + public static native void onNativeFileDialog(int requestCode, String[] filelist, int filter); + public static native void onNativePinchStart(); + public static native void onNativePinchUpdate(float scale); + public static native void onNativePinchEnd(); + + /** + * This method is called by SDL using JNI. + */ + public static boolean setActivityTitle(String title) { + // Called from SDLMain() thread and can't directly affect the view + return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title); + } + + /** + * This method is called by SDL using JNI. + */ + public static void setWindowStyle(boolean fullscreen) { + // Called from SDLMain() thread and can't directly affect the view + mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0); + } + + /** + * This method is called by SDL using JNI. + * This is a static method for JNI convenience, it calls a non-static method + * so that is can be overridden + */ + public static void setOrientation(int w, int h, boolean resizable, String hint) + { + if (mSingleton != null) { + mSingleton.setOrientationBis(w, h, resizable, hint); + } + } + + /** + * This can be overridden + */ + public void setOrientationBis(int w, int h, boolean resizable, String hint) + { + int orientation_landscape = -1; + int orientation_portrait = -1; + + if (w <= 1 || h <= 1) { + // Invalid width/height, ignore this request + return; + } + + /* If set, hint "explicitly controls which UI orientations are allowed". */ + if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE; + } else if (hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (hint.contains("LandscapeRight")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } + + /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */ + boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait"); + + if (contains_Portrait && hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT; + } else if (contains_Portrait) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } + + boolean is_landscape_allowed = (orientation_landscape != -1); + boolean is_portrait_allowed = (orientation_portrait != -1); + int req; /* Requested orientation */ + + /* No valid hint, nothing is explicitly allowed */ + if (!is_portrait_allowed && !is_landscape_allowed) { + if (resizable) { + /* All orientations are allowed, respecting user orientation lock setting */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER; + } else { + /* Fixed window and nothing specified. Get orientation from w/h of created window */ + req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + } else { + /* At least one orientation is allowed */ + if (resizable) { + if (is_portrait_allowed && is_landscape_allowed) { + /* hint allows both landscape and portrait, promote to full user */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER; + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } else { + /* Fixed window and both orientations are allowed. Choose one. */ + if (is_portrait_allowed && is_landscape_allowed) { + req = (w > h ? orientation_landscape : orientation_portrait); + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } + } + + Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint); + mSingleton.setRequestedOrientation(req); + } + + /** + * This method is called by SDL using JNI. + */ + public static void minimizeWindow() { + + if (mSingleton == null) { + return; + } + + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(startMain); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean shouldMinimizeOnFocusLoss() { + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean supportsRelativeMouse() + { + // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under + // Android 7 APIs, and simply returns no data under Android 8 APIs. + // + // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and + // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result, + // we should stick to relative mode. + // + if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) { + return false; + } + + return SDLActivity.getMotionListener().supportsRelativeMouse(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean setRelativeMouseEnabled(boolean enabled) + { + if (enabled && !supportsRelativeMouse()) { + return false; + } + + return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean sendMessage(int command, int param) { + if (mSingleton == null) { + return false; + } + return mSingleton.sendCommand(command, param); + } + + /** + * This method is called by SDL using JNI. + */ + public static Activity getContext() { + return SDL.getContext(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isAndroidTV() { + UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && + (Build.MODEL.startsWith("TV") || + Build.MODEL.equals("X96-W") || + Build.MODEL.equals("A95X-R1"))) { + return true; + } + return false; + } + + public static boolean isVRHeadset() { + if (Build.MANUFACTURER.equals("Oculus") && Build.MODEL.startsWith("Quest")) { + return true; + } + if (Build.MANUFACTURER.equals("Pico")) { + return true; + } + return false; + } + + public static double getDiagonal() + { + DisplayMetrics metrics = new DisplayMetrics(); + Activity activity = getContext(); + if (activity == null) { + return 0.0; + } + activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); + + double dWidthInches = metrics.widthPixels / (double)metrics.xdpi; + double dHeightInches = metrics.heightPixels / (double)metrics.ydpi; + + return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches)); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isTablet() { + // If our diagonal size is seven inches or greater, we consider ourselves a tablet. + return (getDiagonal() >= 7.0); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isChromebook() { + // https://stackoverflow.com/questions/39784415/how-to-detect-programmatically-if-android-app-is-running-in-chrome-book-or-in + if (getContext() != null) { + if (getContext().getPackageManager().hasSystemFeature("org.chromium.arc") + || getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) { + return true; + } + } + + // Running on AVD emulator + boolean isChromebookEmulator = (Build.MODEL != null && Build.MODEL.startsWith("sdk_gpc_")); + return isChromebookEmulator; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isDeXMode() { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + return false; + } + try { + final Configuration config = getContext().getResources().getConfiguration(); + final Class configClass = config.getClass(); + return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) + == configClass.getField("semDesktopModeEnabled").getInt(config); + } catch(Exception ignored) { + return false; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean getManifestEnvironmentVariables() { + try { + if (getContext() == null) { + return false; + } + + ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); + Bundle bundle = applicationInfo.metaData; + if (bundle == null) { + return false; + } + String prefix = "SDL_ENV."; + final int trimLength = prefix.length(); + for (String key : bundle.keySet()) { + if (key.startsWith(prefix)) { + String name = key.substring(trimLength); + String value = bundle.get(key).toString(); + nativeSetenv(name, value); + } + } + /* environment variables set! */ + return true; + } catch (Exception e) { + Log.v(TAG, "exception " + e.toString()); + } + return false; + } + + // This method is called by SDLControllerManager's API 26 Generic Motion Handler. + public static View getContentView() { + return mLayout; + } + + static class ShowTextInputTask implements Runnable { + /* + * This is used to regulate the pan&scan method to have some offset from + * the bottom edge of the input region and the top edge of an input + * method (soft keyboard) + */ + static final int HEIGHT_PADDING = 15; + + public int input_type; + public int x, y, w, h; + + public ShowTextInputTask(int input_type, int x, int y, int w, int h) { + this.input_type = input_type; + this.x = x; + this.y = y; + this.w = w; + this.h = h; + + /* Minimum size of 1 pixel, so it takes focus. */ + if (this.w <= 0) { + this.w = 1; + } + if (this.h + HEIGHT_PADDING <= 0) { + this.h = 1 - HEIGHT_PADDING; + } + } + + @Override + public void run() { + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING); + params.leftMargin = x; + params.topMargin = y; + + if (mTextEdit == null) { + mTextEdit = new SDLDummyEdit(getContext()); + + mLayout.addView(mTextEdit, params); + } else { + mTextEdit.setLayoutParams(params); + } + mTextEdit.setInputType(input_type); + + mTextEdit.setVisibility(View.VISIBLE); + mTextEdit.requestFocus(); + + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mTextEdit, 0); + + if (imm.isAcceptingText()) { + onNativeScreenKeyboardShown(); + } + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean showTextInput(int input_type, int x, int y, int w, int h) { + // Transfer the task to the main thread as a Runnable + return mSingleton.commandHandler.post(new ShowTextInputTask(input_type, x, y, w, h)); + } + + public static boolean isTextInputEvent(KeyEvent event) { + + // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT + if (event.isCtrlPressed()) { + return false; + } + + return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE; + } + + public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) { + int deviceId = event.getDeviceId(); + int source = event.getSource(); + + if ((event.getFlags() & KeyEvent.FLAG_FALLBACK) != 0) { + // If this is a fallback event -- e.g., the mouse is being turned into dpad navigational keys + // just eat it, since that's almost never what we actually want to have happen. This will + // happen with Logitech mice on Amazon Fire TV devices and some Samsung devices. + return true; + } + + if (source == InputDevice.SOURCE_UNKNOWN) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + source = device.getSources(); + } + } + +// if (event.getAction() == KeyEvent.ACTION_DOWN) { +// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } else if (event.getAction() == KeyEvent.ACTION_UP) { +// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } + + // Dispatch the different events depending on where they come from + // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD + // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD + // + // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and + // SOURCE_JOYSTICK, while its key events arrive from the keyboard source + // So, retrieve the device itself and check all of its sources + if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { + // Note that we process events with specific key codes here + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode, event.getScanCode())) { + return true; + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode, event.getScanCode())) { + return true; + } + } + } + + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { + if (SDLActivity.isVRHeadset()) { + // The Oculus Quest controller back button comes in as source mouse, so accept that + } else { + // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses + // they are ignored here because sending them as mouse input to SDL is messy + if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + case KeyEvent.ACTION_UP: + // mark the event as handled or it will be handled by system + // handling KEYCODE_BACK by system will call onBackPressed() + return true; + } + } + } + } + + if (event.getAction() == KeyEvent.ACTION_DOWN) { + onNativeKeyDown(keyCode); + + if (isTextInputEvent(event)) { + if (ic != null) { + ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); + } else { + SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); + } + } + return true; + } else if (event.getAction() == KeyEvent.ACTION_UP) { + onNativeKeyUp(keyCode); + return true; + } + + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static Surface getNativeSurface() { + if (SDLActivity.mSurface == null) { + return null; + } + return SDLActivity.mSurface.getNativeSurface(); + } + + // Input + + /** + * This method is called by SDL using JNI. + */ + public static void initTouch() { + int[] ids = InputDevice.getDeviceIds(); + + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */ + if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN + || device.isVirtual())) { + + nativeAddTouch(device.getId(), device.getName()); + } + } + } + + // Messagebox + + /** Result of current messagebox. Also used for blocking the calling thread. */ + protected final int[] messageboxSelection = new int[1]; + + /** + * This method is called by SDL using JNI. + * Shows the messagebox from UI thread and block calling thread. + * buttonFlags, buttonIds and buttonTexts must have same length. + * @param buttonFlags array containing flags for every button. + * @param buttonIds array containing id for every button. + * @param buttonTexts array containing text for every button. + * @param colors null for default or array of length 5 containing colors. + * @return button id or -1. + */ + public int messageboxShowMessageBox( + final int flags, + final String title, + final String message, + final int[] buttonFlags, + final int[] buttonIds, + final String[] buttonTexts, + final int[] colors) { + + messageboxSelection[0] = -1; + + // sanity checks + + if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) { + return -1; // implementation broken + } + + // collect arguments for Dialog + + final Bundle args = new Bundle(); + args.putInt("flags", flags); + args.putString("title", title); + args.putString("message", message); + args.putIntArray("buttonFlags", buttonFlags); + args.putIntArray("buttonIds", buttonIds); + args.putStringArray("buttonTexts", buttonTexts); + args.putIntArray("colors", colors); + + // trigger Dialog creation on UI thread + + runOnUiThread(new Runnable() { + @Override + public void run() { + messageboxCreateAndShow(args); + } + }); + + // block the calling thread + + synchronized (messageboxSelection) { + try { + messageboxSelection.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + return -1; + } + } + + // return selected value + + return messageboxSelection[0]; + } + + protected void messageboxCreateAndShow(Bundle args) { + + // TODO set values from "flags" to messagebox dialog + + // get colors + + int[] colors = args.getIntArray("colors"); + int backgroundColor; + int textColor; + int buttonBorderColor; + int buttonBackgroundColor; + int buttonSelectedColor; + if (colors != null) { + int i = -1; + backgroundColor = colors[++i]; + textColor = colors[++i]; + buttonBorderColor = colors[++i]; + buttonBackgroundColor = colors[++i]; + buttonSelectedColor = colors[++i]; + } else { + backgroundColor = Color.TRANSPARENT; + textColor = Color.TRANSPARENT; + buttonBorderColor = Color.TRANSPARENT; + buttonBackgroundColor = Color.TRANSPARENT; + buttonSelectedColor = Color.TRANSPARENT; + } + + // create dialog with title and a listener to wake up calling thread + + final AlertDialog dialog = new AlertDialog.Builder(this).create(); + dialog.setTitle(args.getString("title")); + dialog.setCancelable(false); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface unused) { + synchronized (messageboxSelection) { + messageboxSelection.notify(); + } + } + }); + + // create text + + TextView message = new TextView(this); + message.setGravity(Gravity.CENTER); + message.setText(args.getString("message")); + if (textColor != Color.TRANSPARENT) { + message.setTextColor(textColor); + } + + // create buttons + + int[] buttonFlags = args.getIntArray("buttonFlags"); + int[] buttonIds = args.getIntArray("buttonIds"); + String[] buttonTexts = args.getStringArray("buttonTexts"); + + final SparseArray