Repository navigation
fix(sokol-android): initialize gamepad subsystem so connected pads show at startup - #268
Conversation
…ow at startup The sokol-Android gamepad demo showed "No gamepad connected" even with an Xbox controller already paired, because the Android gamepad JNI subsystem was never usable at runtime. Three issues, found via on-device debugging on a real tablet (labelle-engine#261): 1. JNIEnv from the wrong thread (the real bug). The C JNI glue used `ANativeActivity.env`, which belongs to the thread that created the activity (Android main thread). sokol runs init_cb / the frame loop on its own render thread, where that env is invalid, so every InputManager / InputDevice reflection call silently failed and the device enumeration found nothing. Attach the current thread to the JavaVM (GetEnv + AttachCurrentThread) to obtain a valid JNIEnv. 2. Backend never invoked the JNI enumeration. `initAndroidGamepad()` registered the forwarded-event callback but never called `labelle_android_gamepad_init()`, and the generated mobile `init()` never called `initAndroidGamepad()` at all. Wire both up (the engine's gamepad_source also calls the JNI init; the call is idempotent). The template addition is a comptime no-op on non-Android sokol targets. 3. getInputDeviceIds() returns every input device (keyboard, touchscreen, power button, …), not just controllers. Filter the enumeration to gamepad/joystick sources so the HUD lists the pad, not system devices. Demo: the HUD keyed its connected-pad registry by raw Android device id into a 4-slot array, dropping any id >= 4 (the Xbox enumerated as id 9). Key by device id and iterate the registry's real ids instead of a 0..3 slot scan. Verified on a Samsung SM-T505 tablet with an Xbox Wireless Controller: the HUD shows the pad at startup (no button press) instead of the empty state. Refs labelle-engine#261
PR SummaryMedium Risk Overview JNI ( Backend wiring: Demo: Reviewed by Cursor Bugbot for commit 659341f. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Code Review
This pull request addresses Android gamepad detection issues by filtering out non-gamepad input devices, attaching the render thread to the JVM to obtain a valid JNIEnv, and refactoring gamepad tracking to support sparse platform device IDs instead of dense 0..3 slots. The review feedback highlights critical improvements, including adding a one-shot guard to prevent double-initialization of the gamepad subsystem, implementing a thread-local destructor to safely detach the render thread from the JVM on exit, and refactoring the missed nameFor function to use findById to prevent incorrect fallback names for sparse device IDs.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| pub fn initAndroidGamepad() void { | ||
| if (comptime agp.is_android) { | ||
| // Forwarded-event sink first, then the JNI enumeration: the latter | ||
| // walks the InputManager device ids and emits state-added for pads | ||
| // already connected at launch, so the HUD shows them without a button | ||
| // press. `androidGetNativeActivity()` wraps sokol's | ||
| // `sapp_android_get_native_activity()`; it is null before sokol has a | ||
| // running activity, and the C side no-ops on a null pointer. | ||
| android_gp.sapp_android_register_gamepad_callback(&androidGamepadCallback); | ||
| android_gp.labelle_android_gamepad_init(sapp.androidGetNativeActivity()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Currently, initAndroidGamepad() is called twice: once in init() (via mobile.txt) and once in newFrame() on the first frame because android_gp_registered is not set to true inside initAndroidGamepad(). This double-initialization causes duplicate JNI initializations, leaks JNI global references, and triggers duplicate device connection events. Adding a one-shot guard inside initAndroidGamepad() completely prevents this.
pub fn initAndroidGamepad() void {
if (comptime agp.is_android) {
if (android_gp_registered) return;
android_gp_registered = true;
// Forwarded-event sink first, then the JNI enumeration: the latter
// walks the InputManager device ids and emits state-added for pads
// already connected at launch, so the HUD shows them without a button
// press. `androidGetNativeActivity()` wraps sokol's
// `sapp_android_get_native_activity()`; it is null before sokol has a
// running activity, and the C side no-ops on a null pointer.
android_gp.sapp_android_register_gamepad_callback(&androidGamepadCallback);
android_gp.labelle_android_gamepad_init(sapp.androidGetNativeActivity());
}
}
| static int is_gamepad_sources(jint sources) { | ||
| return ((sources & LBL_SOURCE_GAMEPAD) == LBL_SOURCE_GAMEPAD) || | ||
| ((sources & LBL_SOURCE_JOYSTICK) == LBL_SOURCE_JOYSTICK); | ||
| } |
There was a problem hiding this comment.
To prevent JNI thread leaks and potential JVM crashes when the sokol render thread exits (e.g., during activity recreation or screen rotation), we should automatically detach the thread from the Java VM. We can achieve this robustly by creating a thread-local key with a destructor that calls DetachCurrentThread when the thread exits.
static int is_gamepad_sources(jint sources) {
return ((sources & LBL_SOURCE_GAMEPAD) == LBL_SOURCE_GAMEPAD) ||
((sources & LBL_SOURCE_JOYSTICK) == LBL_SOURCE_JOYSTICK);
}
#include <pthread.h>
static pthread_key_t g_detach_key;
static pthread_once_t g_detach_key_once = PTHREAD_ONCE_INIT;
static void detach_thread_destructor(void *arg) {
(void)arg;
if (g_vm) {
(*g_vm)->DetachCurrentThread(g_vm);
}
}
static void make_detach_key(void) {
pthread_key_create(&g_detach_key, detach_thread_destructor);
}| JNIEnv *env = NULL; | ||
| if (g_vm != NULL) { | ||
| jint rc = (*g_vm)->GetEnv(g_vm, (void **)&env, JNI_VERSION_1_6); | ||
| if (rc == JNI_EDETACHED) { | ||
| (*g_vm)->AttachCurrentThread(g_vm, &env, NULL); | ||
| } | ||
| } |
There was a problem hiding this comment.
Register the thread-local destructor when attaching the thread to the Java VM so that it is automatically detached upon thread exit.
JNIEnv *env = NULL;
if (g_vm != NULL) {
jint rc = (*g_vm)->GetEnv(g_vm, (void **)&env, JNI_VERSION_1_6);
if (rc == JNI_EDETACHED) {
if ((*g_vm)->AttachCurrentThread(g_vm, &env, NULL) == JNI_OK) {
pthread_once(&g_detach_key_once, make_detach_key);
pthread_setspecific(g_detach_key, (void *)1);
}
}
}| pub fn typeHintFor(id: u32) [:0]const u8 { | ||
| if (id >= MAX_GAMEPADS) return "unknown"; | ||
| return entries[id].type_hint; | ||
| if (findById(id)) |e| return e.type_hint; | ||
| return "unknown"; | ||
| } |
There was a problem hiding this comment.
While typeHintFor was correctly updated to use findById(id), nameFor (defined just above on lines 68-77) was missed during the refactoring. It still checks if (id >= MAX_GAMEPADS) return "Gamepad"; and indexes entries directly with the raw platform ID.
Because Android platform device IDs are sparse and routinely exceed MAX_GAMEPADS (e.g., the Xbox controller with ID 9), nameFor will always return "Gamepad" instead of the actual device name for these controllers.
Please refactor nameFor to use findById(id) as well:
pub fn nameFor(id: u32) [:0]const u8 {
const e = findById(id) orelse return "Gamepad";
if (e.name_len == 0) return "Gamepad";
if (e.name_len < NAME_CAP) e.name[e.name_len] = 0;
return e.name[0..e.name_len :0];
}There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 659341f. Configure here.
| pub fn typeHintFor(id: u32) [:0]const u8 { | ||
| if (id >= MAX_GAMEPADS) return "unknown"; | ||
| return entries[id].type_hint; | ||
| if (findById(id)) |e| return e.type_hint; |
There was a problem hiding this comment.
nameFor ignores slot lookup
Medium Severity
nameFor still treats the platform device id as a dense index into entries (and returns early when id >= MAX_GAMEPADS), while record, forget, and typeHintFor now store and resolve pads by platform id via findById. For Android ids such as 9, stored names from gamepad_connected are never returned, so the HUD keeps the generic Gamepad label even when the registry has the real name.
Reviewed by Cursor Bugbot for commit 659341f. Configure here.


Problem (confirmed on hardware)
On the sokol-Android gamepad demo running on a real tablet (Samsung SM-T505) with an Xbox Wireless Controller paired, the HUD showed "No gamepad connected" even though the pad was connected before launch. Found via on-device debugging — references labelle-engine#261.
Root cause(s)
Three distinct issues, in order of impact:
JNIEnv from the wrong thread (the real bug).
backends/sokol/src/android_gamepad_jni.c'slabelle_android_gamepad_initusedANativeActivity.env, which is the JNIEnv of the thread that created the activity (Android main thread). sokol runsinit_cband the frame loop on its own render thread, where that env is invalid → every InputManager/InputDevice reflection call silently failed → enumeration found nothing → empty HUD. Fixed by attaching the current thread to theJavaVM(GetEnv+AttachCurrentThread).Backend never invoked the JNI enumeration.
initAndroidGamepad()registered the forwarded-event callback but never calledlabelle_android_gamepad_init(), and the generated mobileinit()never calledinitAndroidGamepad()at all. Now wired in both places. The template addition is a comptime no-op on non-Android sokol targets (desktop/wasm/iOS).getInputDeviceIds()returns every input device (keyboard, touchscreen, power button, virtual keyboard, …), not just controllers. Forwarding them all surfaced system devices as "pads". Now filtered to gamepad/joystick sources at the discovery point (mirrorsclassifySourcesin labelle-core).Demo fix:
connected_pads.zigkeyed its registry by raw Android device id into a 4-slot array, dropping any id ≥ 4 — the Xbox enumerated as device id 9. Now keyed by device id, and the HUD iterates the registry's real ids instead of a0..3slot scan.Verification
Built the debug APK (NDK 26.3.11579264) and ran on a Samsung SM-T505 with an Xbox Wireless Controller connected.
Pad 9: Gamepadwith the live button/axis grid at startup, without any button press. No crash.logcat (JNI init, with temporary diagnostics that were removed before commit):
(the 6 non-gamepad devices are filtered out)
zig build testgreen; generated mobilebuild.zigAstGen-compiles.Known follow-up (out of scope)
The HUD label reads
Pad 9: Gamepad [unknown]rather than the device nameXbox Wireless Controller. The name/type-hint propagation through the enginegamepad_connectedevent is a separate concern; detection (the #261 bug) is fixed.Refs labelle-engine#261