mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
feat: log controller, touch-mode and pad-ring evidence for input reports
This commit is contained in:
1 parent
728bfa2043
commit
01674b7c6f
5 files changed
+139
-18
No files matched your search
@@ -98,7 +98,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
|
||||
/** Between onResume and onPause. */
|
||||
private var resumed = false
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing() } }
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } }
|
||||
|
||||
/**
|
||||
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
|
||||
@@ -108,8 +108,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* mode, so its interface took a finger as a mouse. Opened as soon as the session settles on a
|
||||
* Deck instead.
|
||||
*/
|
||||
private fun openDeckRing() {
|
||||
if (SessionState.deckPad) padBridge?.start()
|
||||
private fun openDeckRing(why: String) {
|
||||
val deck = SessionState.deckPad
|
||||
val opened = if (deck) padBridge?.start() else null
|
||||
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
|
||||
null -> ", left for first input"
|
||||
true -> ", ring open"
|
||||
false -> ", ring NOT open"
|
||||
})
|
||||
}
|
||||
|
||||
/** Motion is read while the session shows and only if the pad is a Deck controller - which
|
||||
@@ -231,8 +237,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* press, and a user without one must not be left with no way to answer Big Picture.
|
||||
*/
|
||||
private val deviceListener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) = updateOnScreenControls()
|
||||
override fun onInputDeviceRemoved(deviceId: Int) = updateOnScreenControls()
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
updateOnScreenControls()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
updateOnScreenControls()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
|
||||
}
|
||||
|
||||
@@ -284,7 +296,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
}
|
||||
SessionState.deckPadListener = deckPadListener
|
||||
openDeckRing()
|
||||
openDeckRing("activity created")
|
||||
// A player on the pad or the on-screen controls has no use for the mouse arrow; the next
|
||||
// touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment.
|
||||
bridge.setOnPlayerInput {
|
||||
@@ -1544,6 +1556,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
val d = InputDevice.getDevice(deviceId) ?: return
|
||||
if (!PadBridge.isFromController(d)) return
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "controller %s: \"%s\" (%04x:%04x, id %d, sources 0x%x)",
|
||||
what, d.name, d.vendorId, d.productId, d.id, d.sources))
|
||||
}
|
||||
|
||||
private fun updateOnScreenControls() {
|
||||
val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc")
|
||||
.takeIf { it.isFile }
|
||||
|
||||
@@ -66,6 +66,15 @@ public final class PadBridge {
|
||||
private volatile Runnable onPlayerInput;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
// What reached the pad over the last stats window, so a report of "the pad does nothing" or
|
||||
// "touch and the pad don't work together" shows in the app log whether input arrived, from
|
||||
// which device, and whether it got to the ring. Logged only for a window that had input.
|
||||
private static final long STATS_WINDOW_MS = 10_000;
|
||||
private int statButtons, statAxes, statOnScreen, statDropped;
|
||||
private String statDevice;
|
||||
private boolean statsScheduled;
|
||||
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
|
||||
|
||||
public PadBridge(File fakeInputDir) {
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
|
||||
}
|
||||
@@ -134,6 +143,7 @@ public final class PadBridge {
|
||||
/** @return true when the event was a pad button and has been consumed. */
|
||||
public synchronized boolean onKeyEvent(KeyEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
noteDevice(event.getDevice());
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break;
|
||||
@@ -161,6 +171,8 @@ public final class PadBridge {
|
||||
default: return false;
|
||||
}
|
||||
if (pressed) notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
return true;
|
||||
}
|
||||
@@ -169,6 +181,9 @@ public final class PadBridge {
|
||||
public synchronized boolean onMotionEvent(MotionEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
noteDevice(event.getDevice());
|
||||
statAxes++;
|
||||
scheduleStats();
|
||||
state.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
// Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the
|
||||
// pad reports as "down" is what the client is told.
|
||||
@@ -203,6 +218,8 @@ public final class PadBridge {
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
}
|
||||
|
||||
@@ -279,8 +296,37 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
private void noteDevice(InputDevice device) {
|
||||
statDevice = device.getName();
|
||||
if (seenDevices.add(device.getId())) {
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b",
|
||||
device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(),
|
||||
open ? "open" : "not open yet", SessionState.getDeckPad()));
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleStats() {
|
||||
if (statsScheduled) return;
|
||||
statsScheduled = true;
|
||||
mainHandler.postDelayed(this::logStats, STATS_WINDOW_MS);
|
||||
}
|
||||
|
||||
private synchronized void logStats() {
|
||||
statsScheduled = false;
|
||||
Log.i(TAG, "[input] last 10 s: pad " + statButtons + " buttons, " + statAxes + " axis events"
|
||||
+ (statDevice != null ? " (\"" + statDevice + "\")" : "")
|
||||
+ ", on-screen " + statOnScreen
|
||||
+ (statDropped > 0 ? ", " + statDropped + " NOT delivered (ring closed)" : "")
|
||||
+ "; ring " + (open ? "open" : "closed") + ", deck pad " + SessionState.getDeckPad());
|
||||
statButtons = statAxes = statOnScreen = statDropped = 0;
|
||||
statDevice = null;
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (!open && !start()) return;
|
||||
if (!open && !start()) {
|
||||
statDropped++;
|
||||
return;
|
||||
}
|
||||
boolean guide = systemGuidePressed || qamChordActive;
|
||||
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
|
||||
if (!guide && !qam) {
|
||||
|
||||
@@ -33,21 +33,40 @@ object SessionLogShare {
|
||||
// session pass yet, and the redactor changes nothing in a line that is already clean.
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { z ->
|
||||
files.forEach { f ->
|
||||
z.putNextEntry(ZipEntry(folder.name + "/" + f.relativeTo(folder).path))
|
||||
if (LogRedactor.isText(f)) {
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubTo(f, w)
|
||||
w.flush()
|
||||
} else {
|
||||
f.inputStream().use { it.copyTo(z) }
|
||||
}
|
||||
z.closeEntry()
|
||||
}
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) }
|
||||
liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) }
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
|
||||
z.putNextEntry(ZipEntry(name))
|
||||
if (LogRedactor.isText(f)) {
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubTo(f, w)
|
||||
w.flush()
|
||||
} else {
|
||||
f.inputStream().use { it.copyTo(z) }
|
||||
}
|
||||
z.closeEntry()
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's own logs as they stand, for a session shared while it runs. The session script
|
||||
* copies them into steam/ only as it exits, so a zip made from the drawer had none - and
|
||||
* controller.txt (which pad the client opened, the touch mode it set) is what a controller or
|
||||
* touch report needs most. Only for the running session: an older folder would get this
|
||||
* session's logs. The same files the script copies; nothing holding credentials is in logs/.
|
||||
*/
|
||||
private fun liveSteamLogs(context: Context, folder: File): List<File> {
|
||||
if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
|
||||
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
|
||||
}
|
||||
|
||||
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
|
||||
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
|
||||
|
||||
fun shareIntent(context: Context, zip: File): Intent {
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
|
||||
val send = Intent(Intent.ACTION_SEND)
|
||||
|
||||
@@ -642,6 +642,7 @@ class SessionService : Service() {
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
logControllersAtStart()
|
||||
if (uinput) {
|
||||
// /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a
|
||||
// game becomes a node the game reads, carrying the player's layout, as on a Deck.
|
||||
@@ -1190,6 +1191,18 @@ class SessionService : Service() {
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
|
||||
/** The pads attached as the session starts; the activity logs the ones that come and go after. */
|
||||
private fun logControllersAtStart() {
|
||||
val pads = ArrayList<String>()
|
||||
for (id in android.view.InputDevice.getDeviceIds()) {
|
||||
val d = android.view.InputDevice.getDevice(id) ?: continue
|
||||
if (!com.droiddeck.launcher.input.PadBridge.isFromController(d)) continue
|
||||
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
|
||||
}
|
||||
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** proot's tracer: above everything in the guest, under the compositor thread's -8. */
|
||||
|
||||
@@ -436,6 +436,29 @@ case $mode in
|
||||
prev=$app
|
||||
done
|
||||
) &
|
||||
# Evidence for touch reports: the touch mode the client asks gamescope for (STEAM_TOUCH_CLICK_MODE,
|
||||
# set once it has a controller, per focused app) and which app has focus, logged as they change.
|
||||
# A finger that clicks but won't scroll, or touch that stops once a pad is used, shows here as
|
||||
# the mode in force at that moment. 4 is touch passed through, 1 a left-click mouse.
|
||||
[ -z "${BL_DESKTOP:-}" ] && (
|
||||
tmode= app=
|
||||
xprop -spy -root STEAM_TOUCH_CLICK_MODE GAMESCOPE_FOCUSED_APP 2>/dev/null | while read -r line; do
|
||||
case $line in
|
||||
STEAM_TOUCH_CLICK_MODE*)
|
||||
v=${line##*= }; case $line in *'not found'*) v=unset ;; esac
|
||||
[ "$v" = "$tmode" ] && continue
|
||||
tmode=$v
|
||||
echo "== touch mode: $tmode (focused app ${app:-?}) at $(date +%T)"
|
||||
;;
|
||||
GAMESCOPE_FOCUSED_APP*)
|
||||
v=${line##*= }; case $line in *'not found'*) v=none ;; esac
|
||||
[ "$v" = "$app" ] && continue
|
||||
app=$v
|
||||
echo "== focused app: $app (touch mode ${tmode:-unset}) at $(date +%T)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
) &
|
||||
# The library beat. Both helpers are whole programs under proot (the compat one a python
|
||||
# start and a parse of the client's config), and nearly every beat has nothing new for them.
|
||||
# The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born
|
||||
|
||||
Reference in new issue
Block a user