test(android): make the pad-audio self test reachable without a host

The self test shipped in the previous commit was gated behind a capture, which
needs a stream, which needs a host — so it depended on precisely the thing it
exists to rule out. It could not have been run in the situation that motivated
it.

It is now a "Test haptics" button on the DualSense passthrough card in
Settings → Controllers → Connected controllers, which is reachable with no
session at all. It opens its OWN connection to the pad — the same rule the
renderer follows, and the rule whose violation caused the fault this test looks
for — runs the tone on a worker thread, and reports a plain-language result:
which of open / write / no-data failed, or how many frames reached the pad.

The debug-property trigger stays for the in-session case; this is the one that
answers "can this phone drive this pad at all" before a host is even involved.
This commit is contained in:
enricobuehler
2026-08-03 09:38:46 +02:00
parent e32bd30c85
commit 4fd240deab
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
Text("Grant USB access")
}
}
else -> Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> {
Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
// answer "can this phone drive this pad's audio endpoint at all", and gating
// that behind a live session would make it depend on the very thing one wants
// to rule out when a session misbehaves. DualSense only — the DS4 has no
// 4-channel haptics device.
if (model != DsDevice.Model.DUALSHOCK4) {
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
result?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
enabled = !testing,
onClick = {
testing = true
result = null
Thread({
// Its OWN connection: the renderer's descriptor must never be
// shared with another transfer engine, and that applies to
// this test as much as to the real path.
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
val fd = conn?.fileDescriptor ?: -1
val r = if (fd >= 0) {
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
} else {
-1
}
conn?.close()
val msg = when {
r > 0 -> "Haptics test passed — $r frames to the pad."
r == -1 -> "Could not open the pad's audio interface. " +
"Some kernels refuse it; the pad still works normally."
r == -2 -> "The audio stream stopped part-way."
else -> "The stream opened but no audio reached the pad."
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
result = msg
testing = false
}
}, "pf-pad-selftest-ui").start()
},
) {
Text(if (testing) "Testing…" else "Test haptics")
}
}
}
}
}
}