mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
Fix controller rings on linked data dirs, route rumble to the controller, capture the mouse for pointer locks (#242)
- #166: export the fake-input ring paths with the spelling the session tree is bound under (Context.getFilesDir(), /data/user/0/...) instead of the canonical path. Where /data/user/0 resolves to /data/data (ColorOS 16) the guest has nothing bound at the canonical prefix, so libfakeinput could not open the rings and every pad, physical and on-screen, was dead. The ring path logic moves to FakeInputRingPaths so it can be unit-tested without the native library. - #219: play a game's rumble on the physical controller that last drove the pad, when Android exposes its motors (strong and weak separately on Android 12+), and fall back to the device's vibrator for the on-screen pad or a controller without rumble. PadBridge records the active controller; the on-screen pad taking over moves rumble back to the phone. - #153: when a program takes a pointer lock (a mouse-look game through gamescope), capture a physical mouse and send its raw deltas as relative motion, so the camera no longer stops where Android's pointer meets the screen edge. Capture is released for the drawer, the PC keyboard, picture in picture, focus loss and pause, and when the lock ends.
This commit is contained in:
1 parent
eaa4a7a047
commit
472bb28c88
8 files changed
+383
-50
No files matched your search
@@ -161,6 +161,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS)
|
||||
private var cursorSerial = 0
|
||||
private var clientHidesCursor = false
|
||||
/**
|
||||
* A program holds a pointer lock (zwp_locked_pointer_v1: a mouse-look game, through gamescope).
|
||||
* While it does, a physical mouse is captured and its raw deltas go to the program as relative
|
||||
* motion. Without capture the deltas are worked out from Android's pointer position, which stops
|
||||
* at the screen edge, and so did the camera (#153).
|
||||
*/
|
||||
private var pointerLocked = false
|
||||
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
|
||||
uiHandler.post {
|
||||
if (pointerLocked == locked) return@post
|
||||
pointerLocked = locked
|
||||
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
|
||||
updatePointerCapture()
|
||||
}
|
||||
}
|
||||
private var cursorImage by mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
|
||||
private var cursorHotX by mutableStateOf(0)
|
||||
private var cursorHotY by mutableStateOf(0)
|
||||
@@ -270,10 +285,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
|
||||
}
|
||||
@@ -324,7 +341,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// below) and the session starts when it is in.
|
||||
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
|
||||
|
||||
val root = FrameLayout(this)
|
||||
// Captured mouse events go down the focus chain, and focus sits somewhere in the overlay:
|
||||
// the root sees every one of them whichever view that is.
|
||||
val root = object : FrameLayout(this) {
|
||||
override fun dispatchCapturedPointerEvent(event: MotionEvent): Boolean =
|
||||
onCapturedPointer(event) || super.dispatchCapturedPointerEvent(event)
|
||||
override fun dispatchPointerCaptureChanged(hasCapture: Boolean) {
|
||||
super.dispatchPointerCaptureChanged(hasCapture)
|
||||
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
|
||||
}
|
||||
}
|
||||
surfaceView = SurfaceView(this)
|
||||
surfaceView.holder.addCallback(this)
|
||||
root.addView(surfaceView)
|
||||
@@ -387,6 +413,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
updateOnScreenControls()
|
||||
WaylandCompositor.setFirstFrameListener(firstFrameListener)
|
||||
WaylandCompositor.setPointerLockListener(pointerLockListener)
|
||||
SessionEvents.markReadyIfPossible()
|
||||
SessionState.endListener = endListener
|
||||
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
|
||||
@@ -417,6 +444,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// this full-screen host and remain as a white veil after the drawer closes.
|
||||
defaultFocusHighlightEnabled = false
|
||||
setContent {
|
||||
// The drawer, the PC keyboard and picture in picture take the mouse back.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
|
||||
DroidDeckTheme {
|
||||
if (pipUi) {
|
||||
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
|
||||
@@ -1327,6 +1356,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
/** With no view focused, captured mouse events skip the root and arrive here as trackball events. */
|
||||
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
|
||||
onCapturedPointer(event) || super.dispatchTrackballEvent(event)
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (pipUi) return true
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
|
||||
@@ -1677,6 +1710,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
|
||||
/** A physical mouse, not a touchscreen, stylus or the system's own virtual devices. */
|
||||
private fun mouseAttached(): Boolean = InputDevice.getDeviceIds().any { id ->
|
||||
val d = InputDevice.getDevice(id)
|
||||
d != null && !d.isVirtual && d.supportsSource(InputDevice.SOURCE_MOUSE)
|
||||
}
|
||||
|
||||
/** Capture the mouse exactly while a program holds a lock and the session has the mouse. */
|
||||
private fun updatePointerCapture() {
|
||||
if (!::surfaceView.isInitialized) return
|
||||
val view = window.decorView
|
||||
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
|
||||
if (want == view.hasPointerCapture()) return
|
||||
if (want) view.requestPointerCapture() else view.releasePointerCapture()
|
||||
}
|
||||
|
||||
/**
|
||||
* A captured mouse: raw motion as relative pointer deltas, buttons and the wheel as on the
|
||||
* uncaptured path. Anything else (a captured touchpad's absolute fingers) is left alone.
|
||||
*/
|
||||
private fun onCapturedPointer(event: MotionEvent): Boolean {
|
||||
if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
var dx = event.x
|
||||
var dy = event.y
|
||||
for (i in 0 until event.historySize) {
|
||||
dx += event.getHistoricalX(i)
|
||||
dy += event.getHistoricalY(i)
|
||||
}
|
||||
WaylandCompositor.sendPointerDelta(dx, dy)
|
||||
}
|
||||
MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> {
|
||||
val button = when (event.actionButton) {
|
||||
MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT
|
||||
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
|
||||
else -> PointerGestures.BTN_LEFT
|
||||
}
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
|
||||
}
|
||||
MotionEvent.ACTION_SCROLL -> {
|
||||
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0)
|
||||
}
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} // the BUTTON_* events carry these
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
|
||||
/** Which controller the session has, so a pad report says what was plugged in and when. */
|
||||
private fun logInputDevice(what: String, deviceId: Int) {
|
||||
@@ -1848,6 +1930,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
updateOnScreenControls()
|
||||
resumed = true
|
||||
updatePointerCapture()
|
||||
if (!pipUi) sessionClipboard.start()
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
@@ -1867,6 +1950,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// A button held when the app goes away would stay held in the ring for the whole session.
|
||||
onScreenControls?.releaseAll()
|
||||
resumed = false
|
||||
updatePointerCapture()
|
||||
updatePadMotion()
|
||||
keyboard?.takeIf { it.shown }?.hide()
|
||||
super.onPause()
|
||||
@@ -1887,6 +1971,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
goFullscreen()
|
||||
refreshClipboard()
|
||||
}
|
||||
// Android drops a capture with the focus; a lock still held takes it again on return.
|
||||
updatePointerCapture()
|
||||
}
|
||||
|
||||
/** The keyboard/trackpad presentation can own focus instead of the main display. */
|
||||
@@ -1937,6 +2023,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
|
||||
WaylandCompositor.clearPointerLockListener(pointerLockListener)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.droiddeck.launcher.input;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Where the fake input rings live and how the guest is told about them. Kept apart from
|
||||
* {@link FakeInputWriter}, which loads native code, so it can be tested on the JVM.
|
||||
*/
|
||||
final class FakeInputRingPaths {
|
||||
static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
|
||||
private FakeInputRingPaths() {}
|
||||
|
||||
/** The rings sit beside the fake evdev nodes: session/dev/input -> session/dev/fakeinput-rings. */
|
||||
static File ringDir(File fakeInputDir) {
|
||||
if (fakeInputDir == null) {
|
||||
return null;
|
||||
}
|
||||
File inputDir = fakeInputDir.getAbsoluteFile();
|
||||
File parent = inputDir.getParentFile();
|
||||
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
|
||||
}
|
||||
|
||||
static File ringFile(File fakeInputDir, int slot) {
|
||||
File ringDir = ringDir(fakeInputDir);
|
||||
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring's path as the guest opens it. The session tree is bound into the guest at its own
|
||||
* host path, spelled the way {@code Context.getFilesDir()} spells it (LinuxRuntime.binds), so
|
||||
* the export has to use that same spelling. A canonical path is not that: where
|
||||
* /data/user/0 is a link, or a separate mount, onto /data/data (ColorOS 16, #166) it resolves
|
||||
* to a prefix nothing is bound at, and every pad, physical and on-screen, goes dead.
|
||||
*/
|
||||
static String exportPath(File ringFile) {
|
||||
return ringFile.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,6 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
private static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
@@ -198,26 +197,8 @@ public class FakeInputWriter {
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static File getRingDir(File fakeInputDir) {
|
||||
if (fakeInputDir == null) {
|
||||
return null;
|
||||
}
|
||||
File inputDir = fakeInputDir.getAbsoluteFile();
|
||||
File parent = inputDir.getParentFile();
|
||||
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
|
||||
}
|
||||
|
||||
private static File getRingFile(File fakeInputDir, int slot) {
|
||||
File ringDir = getRingDir(fakeInputDir);
|
||||
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
|
||||
}
|
||||
|
||||
private static String getCanonicalOrAbsolutePath(File file) {
|
||||
try {
|
||||
return file.getCanonicalPath();
|
||||
} catch (IOException e) {
|
||||
return file.getAbsolutePath();
|
||||
}
|
||||
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
|
||||
}
|
||||
|
||||
private static void initializeRingHeader(ByteBuffer data) {
|
||||
@@ -325,7 +306,7 @@ public class FakeInputWriter {
|
||||
ringSlot.ringFile = ringFile.getAbsoluteFile();
|
||||
ringSlot.ringRaf = raf;
|
||||
ringSlot.ringChannel = channel;
|
||||
ringSlot.exportPath = getCanonicalOrAbsolutePath(ringFile);
|
||||
ringSlot.exportPath = FakeInputRingPaths.exportPath(ringFile);
|
||||
Log.i(TAG, "Created fake input file ring for slot " + slot + ": " + ringSlot.exportPath);
|
||||
return ringSlot;
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -76,6 +76,17 @@ public final class PadBridge {
|
||||
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
|
||||
private int lastDeviceId = Integer.MIN_VALUE;
|
||||
|
||||
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
|
||||
public static final int NO_CONTROLLER = -1;
|
||||
/**
|
||||
* The Android input device id of the physical controller that last drove the pad, or
|
||||
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
|
||||
* on that slot finds the motors in the player's hands (RumbleComponent).
|
||||
*/
|
||||
private static volatile int activeControllerId = NO_CONTROLLER;
|
||||
|
||||
public static int activeControllerId() { return activeControllerId; }
|
||||
|
||||
public PadBridge(File fakeInputDir) {
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
|
||||
}
|
||||
@@ -90,6 +101,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
@@ -218,6 +230,7 @@ public final class PadBridge {
|
||||
*/
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
@@ -299,6 +312,7 @@ public final class PadBridge {
|
||||
|
||||
/** Per event, so the common case - the same pad as last time - is a single compare. */
|
||||
private void noteDevice(InputDevice device) {
|
||||
activeControllerId = device.getId();
|
||||
if (device.getId() == lastDeviceId) return;
|
||||
lastDeviceId = device.getId();
|
||||
statDevice = device.getName();
|
||||
|
||||
@@ -3,43 +3,58 @@ package com.droiddeck.launcher.session
|
||||
import android.net.LocalServerSocket
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import java.io.DataInputStream
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Rumble for the on-screen pad, on the phone itself.
|
||||
* Rumble for the pad, on the motors in the player's hands.
|
||||
*
|
||||
* When a game plays a force-feedback effect on the fake pad, the fake evdev layer inside the guest
|
||||
* connects to the abstract socket [NAME] and sends one packet: strong, weak, duration in ms and
|
||||
* the pad slot, four little-endian 16-bit values. This listens for those and drives the device's
|
||||
* vibrator with the stronger motor's strength for the effect's duration. Best effort both ways:
|
||||
* a missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
|
||||
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
|
||||
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
|
||||
* values. This listens for those and plays the effect on the physical controller that last drove
|
||||
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
|
||||
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
|
||||
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
|
||||
* missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
*/
|
||||
class RumbleComponent : SessionPart() {
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
private var vibrator: Vibrator? = null
|
||||
private var phone: Motors? = null
|
||||
/** Where the last effect went, so the next one (or a stop) can end it there. */
|
||||
private var playing: Motors? = null
|
||||
private var controllerId = PadBridge.NO_CONTROLLER
|
||||
private var controller: Motors? = null
|
||||
private var preferences: SharedPreferences? = null
|
||||
private var enabled = false
|
||||
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == null || key == "rumble") refreshEnabled()
|
||||
}
|
||||
|
||||
/** The motors of the controller with this input device id, or null when it has none. Tests swap it. */
|
||||
internal var controllerMotors: (Int) -> Motors? = ::lookupController
|
||||
|
||||
@Synchronized private fun refreshEnabled() {
|
||||
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
|
||||
if (!enabled) vibrator?.cancel()
|
||||
if (!enabled) { playing?.cancel(); playing = null }
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val ctx = app() ?: return
|
||||
val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return }
|
||||
vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
|
||||
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
|
||||
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
|
||||
refreshEnabled()
|
||||
server = s
|
||||
@@ -49,8 +64,7 @@ class RumbleComponent : SessionPart() {
|
||||
try {
|
||||
val bytes = ByteArray(8)
|
||||
DataInputStream(client.inputStream).readFully(bytes)
|
||||
val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4)
|
||||
buzz(max(strong, weak), ms)
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
|
||||
} catch (e: Exception) {
|
||||
// A partial packet or a closed peer: nothing to play.
|
||||
} finally {
|
||||
@@ -65,8 +79,11 @@ class RumbleComponent : SessionPart() {
|
||||
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
|
||||
preferences = null
|
||||
enabled = false
|
||||
vibrator?.cancel()
|
||||
vibrator = null
|
||||
playing?.cancel()
|
||||
playing = null
|
||||
phone = null
|
||||
controller = null
|
||||
controllerId = PadBridge.NO_CONTROLLER
|
||||
val s = server
|
||||
server = null
|
||||
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
|
||||
@@ -74,24 +91,91 @@ class RumbleComponent : SessionPart() {
|
||||
|
||||
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
@Synchronized private fun buzz(strength: Int, ms: Int) {
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
|
||||
if (!enabled) return
|
||||
val vibrator = vibrator ?: return
|
||||
if (!vibrator.hasVibrator()) return
|
||||
if (strength == 0 || ms == 0) { vibrator.cancel(); return }
|
||||
val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
val duration = ms.toLong().coerceIn(1L, 5000L)
|
||||
try {
|
||||
if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
|
||||
else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: $e")
|
||||
val target = target()
|
||||
if (target !== playing) {
|
||||
playing?.cancel()
|
||||
playing = target
|
||||
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
|
||||
}
|
||||
if (target == null) return
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
|
||||
try {
|
||||
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: ${target.name}: $e")
|
||||
}
|
||||
}
|
||||
|
||||
/** The active controller's motors when it has any, else the phone's. */
|
||||
private fun target(): Motors? {
|
||||
val id = PadBridge.activeControllerId()
|
||||
if (id != controllerId) {
|
||||
controllerId = id
|
||||
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
|
||||
}
|
||||
return controller ?: phone
|
||||
}
|
||||
|
||||
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
|
||||
internal interface Motors {
|
||||
val name: String
|
||||
fun play(strong: Int, weak: Int, ms: Long)
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
private class PhoneMotors(private val vibrator: Vibrator) : Motors {
|
||||
override val name = "the device's vibrator"
|
||||
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
|
||||
override fun cancel() = vibrator.cancel()
|
||||
}
|
||||
|
||||
/** Android 12+: each of the pad's motors on its own, the lower id being the strong one. */
|
||||
@RequiresApi(31)
|
||||
private class PadMotors(override val name: String, private val manager: VibratorManager, private val ids: IntArray) : Motors {
|
||||
override fun play(strong: Int, weak: Int, ms: Long) {
|
||||
val combined = CombinedVibration.startParallel()
|
||||
var any = false
|
||||
fun add(id: Int, strength: Int) {
|
||||
if (strength == 0) return
|
||||
combined.addVibrator(id, oneShot(manager.getVibrator(id), strength, ms))
|
||||
any = true
|
||||
}
|
||||
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
|
||||
if (any) manager.vibrate(combined.combine()) else manager.cancel()
|
||||
}
|
||||
override fun cancel() = manager.cancel()
|
||||
}
|
||||
|
||||
/** Before Android 12 a pad has one vibrator. */
|
||||
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
|
||||
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
|
||||
override fun cancel() = vibrator.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
|
||||
const val NAME = "droiddeck-rumble"
|
||||
|
||||
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
|
||||
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
else VibrationEffect.DEFAULT_AMPLITUDE
|
||||
return VibrationEffect.createOneShot(ms, amplitude)
|
||||
}
|
||||
|
||||
private fun lookupController(id: Int): Motors? {
|
||||
val device = InputDevice.getDevice(id) ?: return null
|
||||
val name = "\"${device.name}\""
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val manager = device.vibratorManager
|
||||
val ids = manager.vibratorIds.sortedArray()
|
||||
return if (ids.isEmpty()) null else PadMotors(name, manager, ids)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val vibrator = device.vibrator
|
||||
return if (vibrator.hasVibrator()) LegacyPadMotors(name, vibrator) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,11 @@ public final class WaylandCompositor {
|
||||
|
||||
public static void setPointerLockListener(PointerLockListener l) { pointerLockListener = l; }
|
||||
|
||||
/** Unhooks {@code l} only if it is still the listener, so a finishing activity never unhooks its successor's. */
|
||||
public static void clearPointerLockListener(PointerLockListener l) {
|
||||
if (pointerLockListener == l) pointerLockListener = null;
|
||||
}
|
||||
|
||||
/** Invoked from native (droiddeck_on_pointer_lock). */
|
||||
@SuppressWarnings("unused")
|
||||
static void onPointerLock(boolean locked, int x, int y) {
|
||||
@@ -500,4 +505,11 @@ public final class WaylandCompositor {
|
||||
if (dx == 0 && dy == 0) return;
|
||||
nativeSendSceneInput(6, dx * 256, dy * 256);
|
||||
}
|
||||
|
||||
/** As {@link #sendPointerDelta(int, int)}, keeping a captured mouse's fractions of a pixel. */
|
||||
public static void sendPointerDelta(float dx, float dy) {
|
||||
int x = Math.round(dx * 256f), y = Math.round(dy * 256f);
|
||||
if (x == 0 && y == 0) return;
|
||||
nativeSendSceneInput(6, x, y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The ring paths handed to the guest (FAKE_EVDEV_MEMFD_PATHS) must keep the spelling the session
|
||||
* tree is bound under. On ColorOS 16 /data/user/0 resolves to /data/data, the guest only has the
|
||||
* first bound, and a canonical export left every pad dead (#166). A symlinked directory stands in
|
||||
* for that here.
|
||||
*/
|
||||
class FakeInputRingPathTest {
|
||||
private lateinit var base: File
|
||||
private lateinit var real: File
|
||||
private lateinit var link: File
|
||||
|
||||
@Before fun setUp() {
|
||||
base = Files.createTempDirectory("ring-path").toFile()
|
||||
real = File(base, "data-data").apply { mkdirs() }
|
||||
link = File(base, "data-user-0")
|
||||
Files.createSymbolicLink(link.toPath(), real.toPath())
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
base.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test fun ringsSitBesideTheFakeInputNodes() {
|
||||
val fakeInputDir = File(link, "files/session/dev/input")
|
||||
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring2"), FakeInputRingPaths.ringFile(fakeInputDir, 2))
|
||||
}
|
||||
|
||||
@Test fun theExportKeepsTheBoundSpellingThroughALinkedDataDirectory() {
|
||||
val fakeInputDir = File(link, "files/session/dev/input").apply { mkdirs() }
|
||||
val ring = FakeInputRingPaths.ringFile(fakeInputDir, 0)!!.apply { parentFile!!.mkdirs(); writeText("") }
|
||||
// The link really does resolve elsewhere, as /data/user/0 does on the affected devices.
|
||||
assertTrue(ring.canonicalPath.startsWith(real.canonicalPath + "/"))
|
||||
val exported = FakeInputRingPaths.exportPath(ring)
|
||||
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring0").path, exported)
|
||||
assertFalse("$exported resolved through the link", exported.startsWith(real.path + "/"))
|
||||
assertTrue("$exported does not open", File(exported).isFile)
|
||||
}
|
||||
|
||||
@Test fun aRelativeInputDirIsExportedAbsolute() {
|
||||
val exported = FakeInputRingPaths.exportPath(FakeInputRingPaths.ringFile(File("session/dev/input"), 1)!!)
|
||||
assertTrue(exported, File(exported).isAbsolute)
|
||||
assertTrue(exported, exported.endsWith("/session/dev/fakeinput-rings/ring1"))
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.os.Looper
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import java.io.IOException
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
@@ -68,12 +69,74 @@ class RumbleComponentTest {
|
||||
assertTrue(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
private fun effect() {
|
||||
// Inject a decoded force-feedback packet; transport is outside this preference test.
|
||||
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
.apply { isAccessible = true }.invoke(rumble, 65535, 5000)
|
||||
@Test fun aControllerWithMotorsGetsTheEffectInsteadOfThePhone() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { id -> if (id == PAD_ID) pad else null }
|
||||
activeController(PAD_ID)
|
||||
effect(strong = 40000, weak = 1000)
|
||||
assertEquals(listOf(Triple(40000, 1000, 5000L)), pad.played)
|
||||
assertFalse("the phone buzzed as well", shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun aControllerWithoutMotorsFallsBackToThePhone() {
|
||||
rumble.controllerMotors = { null }
|
||||
activeController(PAD_ID)
|
||||
effect()
|
||||
assertTrue(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun theOnScreenPadTakingOverMovesTheEffectBackToThePhone() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { pad }
|
||||
activeController(PAD_ID)
|
||||
effect()
|
||||
assertTrue(pad.vibrating)
|
||||
activeController(PadBridge.NO_CONTROLLER)
|
||||
effect()
|
||||
assertFalse("the controller kept rumbling after the switch", pad.vibrating)
|
||||
assertTrue(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun aStopPacketDisablingAndStoppingAllEndTheControllersEffect() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { pad }
|
||||
activeController(PAD_ID)
|
||||
effect()
|
||||
effect(strong = 0, weak = 0)
|
||||
assertFalse(pad.vibrating)
|
||||
effect()
|
||||
ControllerPrefs.setRumble(context, false)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertFalse(pad.vibrating)
|
||||
ControllerPrefs.setRumble(context, true)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
effect()
|
||||
rumble.stop()
|
||||
assertFalse(pad.vibrating)
|
||||
}
|
||||
|
||||
@After fun clearActiveController() { activeController(PadBridge.NO_CONTROLLER) }
|
||||
|
||||
private fun activeController(id: Int) {
|
||||
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
|
||||
}
|
||||
|
||||
private fun effect(strong: Int = 65535, weak: Int = 65535) {
|
||||
// Inject a decoded force-feedback packet; transport is outside these tests.
|
||||
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000)
|
||||
}
|
||||
|
||||
private class FakeMotors : RumbleComponent.Motors {
|
||||
override val name = "fake pad"
|
||||
val played = mutableListOf<Triple<Int, Int, Long>>()
|
||||
var vibrating = false
|
||||
override fun play(strong: Int, weak: Int, ms: Long) { played += Triple(strong, weak, ms); vibrating = true }
|
||||
override fun cancel() { vibrating = false }
|
||||
}
|
||||
|
||||
private companion object { const val PAD_ID = 7 }
|
||||
|
||||
@Implements(LocalServerSocket::class)
|
||||
class NoPacketsSocket {
|
||||
@Implementation fun __constructor__(name: String) = Unit
|
||||
|
||||
Reference in new issue
Block a user