feat(android): gamepad forwarding — buttons + sticks/triggers/dpad → send_input
apple / swift (push) Successful in 54s
android / android (push) Failing after 21s
ci / web (push) Failing after 12s
ci / docs-site (push) Failing after 0s
ci / bench (push) Failing after 1s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 1s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 1s
docker / deploy-docs (push) Has been skipped
flatpak / build-publish (push) Failing after 0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 0s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1s
ci / rust (push) Failing after 2m35s

M4 Android stage 1 (gamepad). One controller forwarded as pad 0; mirrors the
Linux/Apple gamepad mapping (byte-identical GamepadButton/GamepadAxis events).

- crates/punktfunk-android: 2 JNI fns (nativeSendGamepadButton/Axis) building the
  GamepadButton/GamepadAxis InputEvents (flags = pad index 0).
- clients/android: Gamepad.kt — BTN_*/AXIS_* wire constants, KEYCODE_*->BTN_* map, and
  an AxisMapper (joystick MotionEvent -> sticks +-32767 +y-up / triggers 0..255 /
  HAT->BTN_DPAD_* with on-change gating + release-all reset). MainActivity routes
  gamepad-source KeyEvents in dispatchKeyEvent (DPAD only when from a gamepad, so
  keyboard arrows still map to VK) and adds dispatchGenericMotionEvent for joystick axes.

Verified live (emulator -> gamescope host, `adb input gamepad keyevent`): host created
the virtual X-Box 360 uinput pad (index=0) and received the gamepad datagrams (input=22).
Axes can't be adb-injected (joystick MotionEvents) -- build/clippy + code-review this
increment; live stick/trigger test deferred to a physical controller. Deferred: device
enumeration/selection, controller-type negotiation, DualSense rich input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 10:06:56 +02:00
parent 2bca89c555
commit 1e871854cd
4 changed files with 239 additions and 0 deletions
@@ -1,7 +1,9 @@
package io.unom.punktfunk
import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
@@ -40,6 +42,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.abs
@@ -55,6 +58,9 @@ class MainActivity : ComponentActivity() {
*/
var streamHandle: Long = 0L
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
var axisMapper: Gamepad.AxisMapper? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -68,6 +74,20 @@ class MainActivity : ComponentActivity() {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle
if (handle != 0L) {
// Gamepad buttons (incl. DPAD only when truly from a gamepad — else KEYCODE_DPAD_* are
// keyboard arrows and belong to the VK path below).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.buttonBit(event.keyCode)
if (bit != 0) {
when (event.action) {
// repeatCount guard: don't re-send a held button as auto-repeat.
KeyEvent.ACTION_DOWN ->
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
KeyEvent.ACTION_UP -> NativeBridge.nativeSendGamepadButton(handle, bit, false)
}
return true // consumed
}
}
when (event.keyCode) {
// Leave these to the system even while streaming.
KeyEvent.KEYCODE_BACK, // → BackHandler leaves the stream
@@ -91,6 +111,11 @@ class MainActivity : ComponentActivity() {
}
return super.dispatchKeyEvent(event)
}
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (streamHandle != 0L && axisMapper?.onMotion(event) == true) return true
return super.dispatchGenericMotionEvent(event)
}
}
/** Scaffold mode requested from the host (WxH@Hz). TODO: derive from the display. */
@@ -179,7 +204,10 @@ private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) {
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
onDispose {
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
activity?.axisMapper = null
activity?.streamHandle = 0L
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Leaving the stream: stop the audio + decode threads and tear down the session.