Skip to content

Commit

Permalink
replace Mouse device with Touchpad, allowing the OS to handle gesture…
Browse files Browse the repository at this point in the history
…s and such

Notes:
- touchpad report descriptor has lots of room for improvement
- moved writeHIDReport() in ReportSender.kt just for style reasons
  • Loading branch information
Arian04 committed Jul 2, 2024
1 parent be4c5fb commit 66db628
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 132 deletions.
14 changes: 4 additions & 10 deletions app/src/main/java/me/arianb/usb_hid_client/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import kotlinx.coroutines.launch
import me.arianb.usb_hid_client.hid_utils.CharacterDeviceManager
import me.arianb.usb_hid_client.hid_utils.ModifiesStateDirectly
import me.arianb.usb_hid_client.report_senders.KeySender
import me.arianb.usb_hid_client.report_senders.MouseSender
import me.arianb.usb_hid_client.report_senders.ReportSender
import me.arianb.usb_hid_client.report_senders.TouchpadSender
import me.arianb.usb_hid_client.shell_utils.RootStateHolder
import timber.log.Timber
import java.io.FileNotFoundException
Expand All @@ -37,10 +37,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private val characterDeviceManager = CharacterDeviceManager.getInstance(application)
private val rootStateHolder = RootStateHolder.getInstance()
val keySender = KeySender()
val mouseSender = MouseSender()
val touchpadSender = TouchpadSender()
private val senderList = listOf(keySender, touchpadSender)

init {
for (sender: ReportSender in listOf(keySender, mouseSender)) {
for (sender: ReportSender in senderList) {
viewModelScope.launch(ReportSender.dispatcher) {
sender.start(
onSuccess = {
Expand Down Expand Up @@ -141,11 +142,4 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {

fun addMediaKey(key: Byte) =
keySender.addMediaKey(key)

// Mouse
fun click(button: Byte) =
mouseSender.click(button)

fun move(x: Byte, y: Byte) =
mouseSender.move(x, y)
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ class CharacterDeviceManager private constructor(private val application: Applic
companion object {
// character device paths
const val KEYBOARD_DEVICE_PATH = "/dev/hidg0"
const val MOUSE_DEVICE_PATH = "/dev/hidg1"
val ALL_CHARACTER_DEVICE_PATHS = listOf(KEYBOARD_DEVICE_PATH, MOUSE_DEVICE_PATH)
const val TOUCHPAD_DEVICE_PATH = "/dev/hidg1"
val ALL_CHARACTER_DEVICE_PATHS = listOf(KEYBOARD_DEVICE_PATH, TOUCHPAD_DEVICE_PATH)

// SeLinux stuff
private const val SELINUX_DOMAIN = "appdomain"
Expand Down
195 changes: 122 additions & 73 deletions app/src/main/java/me/arianb/usb_hid_client/input_views/TouchpadView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ package me.arianb.usb_hid_client.input_views

import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.view.Gravity
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import androidx.appcompat.widget.AppCompatTextView
import androidx.compose.foundation.BorderStroke
Expand All @@ -20,22 +20,16 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import me.arianb.usb_hid_client.MainViewModel
import me.arianb.usb_hid_client.R
import me.arianb.usb_hid_client.report_senders.MouseSender
import me.arianb.usb_hid_client.report_senders.TouchpadSender
import me.arianb.usb_hid_client.ui.utils.getColorByTheme
import timber.log.Timber
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor

// LEGACY: migrate this to Compose

// TODO: address the linting issue below
@SuppressLint("ClickableViewAccessibility")
class TouchpadView : AppCompatTextView {
private var mVelocityTracker: VelocityTracker? = null

// Vars for tracking a single touch event, which I'm defining as: ACTION_DOWN, (anything), ACTION_UP
private var currentPointerCount = 0
private var currentScanTime: UShort = getScanTime()

constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
Expand All @@ -45,95 +39,150 @@ class TouchpadView : AppCompatTextView {
defStyleAttr
)

fun setTouchListeners(mouseSender: MouseSender) {
// TODO:
// - add gestures
// - on double click and drag, send report without "release" until sending release on finger up
// - on long press and drag, send report without "release" until sending release on finger up
// - on two finger scroll, send scroll events
fun setTouchListeners(touchpadSender: TouchpadSender) {
setOnTouchListener { _: View?, motionEvent: MotionEvent ->
val action = motionEvent.actionMasked
val index = motionEvent.actionIndex
val pointerId = motionEvent.getPointerId(index)
when (action) {
val (pointerID, pointerX, pointerY) = getPointerTriple(motionEvent, pointerIndex = motionEvent.actionIndex)

// Scan time is reset when pointer 0 is sent
if (pointerID == 0) {
currentScanTime = getScanTime()
}

val pointerCount = motionEvent.pointerCount
when (val action = motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN -> {
currentPointerCount = 1
Timber.d("Action Down")
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain()
} else {
mVelocityTracker!!.clear()
}
mVelocityTracker!!.addMovement(motionEvent)
Timber.v("Action Down")
touchpadSender.send(pointerID, true, pointerX, pointerY, currentScanTime, pointerCount)
}

MotionEvent.ACTION_POINTER_DOWN -> {
Timber.v("Action Pointer Down")
touchpadSender.send(pointerID, true, pointerX, pointerY, currentScanTime, pointerCount)
}

MotionEvent.ACTION_MOVE -> {
//Timber.d("Action Move");
mVelocityTracker!!.addMovement(motionEvent)

// Compute velocity (cap it to byte because the report uses a byte per axis)
mVelocityTracker!!.computeCurrentVelocity(10, Byte.MAX_VALUE.toFloat())
var xVelocity = mVelocityTracker!!.getXVelocity(pointerId)
var yVelocity = mVelocityTracker!!.getYVelocity(pointerId)

// Scale up velocities < 1 in magnitude (accounting for deadzone) to allow for precise movements
xVelocity = scaleWithDeadzone(xVelocity)
yVelocity = scaleWithDeadzone(yVelocity)
val x = xVelocity.toInt().toByte()
val y = yVelocity.toInt().toByte()
mouseSender.move(x, y)
Timber.v("Action Move")
for (index in 0..<pointerCount) {
val (thisID, thisX, thisY) = getPointerTriple(motionEvent, index)

touchpadSender.send(thisID, true, thisX, thisY, currentScanTime, pointerCount)
}
}

MotionEvent.ACTION_UP -> {
Timber.d("Action Up (max pointer count = %d)", currentPointerCount)
val pointerDownTimeMillis = motionEvent.eventTime - motionEvent.downTime
Timber.d("Pointer was down for %d milliseconds", pointerDownTimeMillis)
Timber.v("Action Up")
touchpadSender.send(pointerID, false, pointerX, pointerY, currentScanTime, pointerCount)
}

// If user has been holding down for a while, don't send any clicks, they're probably dragging
if (pointerDownTimeMillis > 300) {
return@setOnTouchListener false
}
when (currentPointerCount) {
1 -> mouseSender.click(MouseSender.MOUSE_BUTTON_LEFT)
2 -> mouseSender.click(MouseSender.MOUSE_BUTTON_RIGHT)
3 -> mouseSender.click(MouseSender.MOUSE_BUTTON_MIDDLE)
}
currentPointerCount = 0
MotionEvent.ACTION_POINTER_UP -> {
Timber.v("Action Pointer Up")
touchpadSender.send(pointerID, false, pointerX, pointerY, currentScanTime, pointerCount)
}

MotionEvent.ACTION_POINTER_DOWN -> currentPointerCount++
MotionEvent.ACTION_POINTER_UP -> Timber.d("Action Pointer Up")
MotionEvent.ACTION_CANCEL -> {
Timber.d("Action Cancel")
mVelocityTracker!!.recycle() // Return a VelocityTracker object back to be re-used by others.
currentPointerCount = 0
Timber.v("Action Cancel")
touchpadSender.send(pointerID, false, pointerX, pointerY, currentScanTime, pointerCount)
}

else -> {
Timber.w("UNHANDLED ACTION CONSTANT: %s", action)
}
}
true
}
}
}

private fun scaleWithDeadzone(inputVelocity: Float): Float {
return if (abs(inputVelocity.toDouble()) != 0.0 && abs(inputVelocity.toDouble()) > DEADZONE) {
if (inputVelocity < 0) {
floor(inputVelocity.toDouble()).toFloat()
} else {
ceil(inputVelocity.toDouble()).toFloat()
}
} else {
inputVelocity
}
private fun getPointerTriple(motionEvent: MotionEvent, pointerIndex: Int): Triple<Int, Int, Int> {
val pointerID = motionEvent.getPointerId(pointerIndex)

val (rawPointerX, rawPointerY) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Pair(motionEvent.getRawX(pointerIndex), motionEvent.getRawY(pointerIndex))
} else {
Pair(motionEvent.getX(pointerIndex), motionEvent.getY(pointerIndex))
}

companion object {
private const val DEADZONE = 0.3f
val xRange = motionEvent.device.getMotionRange(MotionEvent.AXIS_X)
val xMax = xRange.max
val yRange = motionEvent.device.getMotionRange(MotionEvent.AXIS_Y)
val yMax = yRange.max

// Get device rotation because if the device is suddenly a wide rectangle instead of a tall rectangle, then the
// math changes.
val isRotated = motionEvent.orientation != 0f

val (pointerX, pointerY) = adjustRange(
point = Pair(rawPointerX.toInt(), rawPointerY.toInt()),
max = Pair(xMax, yMax),
isRotated
)

return Triple(pointerID, pointerX, pointerY)
}

// "Stretches" the values of the points to use up the entire logical range.
private fun adjustRange(point: Pair<Int, Int>, max: Pair<Float, Float>, isRotated: Boolean): Pair<Int, Int> {
Timber.d("DEVICE COORDINATE MAX = (%f, %f)", max.first, max.second)

val (logicalMaxX, logicalMaxY) = if (isRotated) {
// This works, but I'm not sure if it's okay to just be sending values higher than the logical maximum
Pair(5000, 2500)
} else {
Pair(2500, 5000)
}

val (pointerMaxX, pointerMaxY) = if (isRotated) {
Pair(max.second, max.first)
} else {
max
}

val xRatio: Float = logicalMaxX / pointerMaxX
val yRatio: Float = logicalMaxY / pointerMaxY

val adjustedX = (point.first * xRatio).toInt()
val adjustedY = (point.second * yRatio).toInt()

// This will probably never actually be necessary, but might as well do it just in case.
val finalX = adjustedX.coerceIn(0, logicalMaxX)
val finalY = adjustedY.coerceIn(0, logicalMaxY)

return Pair(finalX, finalY)
}

fun getScanTime(): UShort {
// Convert nanoseconds to microseconds
val microTime = System.nanoTime() / 1000

// Convert microseconds to 100s of microseconds
val hundredMicroTime = microTime / 100

return hundredMicroTime.toUShort()
}

/**
* Helper function to convert between types.
*/
private fun TouchpadSender.send(
pointerID: Int,
tipSwitch: Boolean,
x: Int,
y: Int,
currentScanTime: UShort,
pointerCount: Int
) = send(
pointerID.toByte(),
tipSwitch,
x.toShort(),
y.toShort(),
currentScanTime,
pointerCount.toByte()
)

@Composable
fun Touchpad(mainViewModel: MainViewModel = viewModel()) {
val touchpadText = stringResource(R.string.touchpad_label)
val mouseSender = mainViewModel.mouseSender
val touchpadSender = mainViewModel.touchpadSender

val textColor = getColorByTheme()

Expand All @@ -158,7 +207,7 @@ fun Touchpad(mainViewModel: MainViewModel = viewModel()) {
text = touchpadText
textSize = 22f
gravity = Gravity.CENTER
setTouchListeners(mouseSender)
setTouchListeners(touchpadSender)
}
},
update = {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import java.io.IOException
abstract class ReportSender(
val characterDevicePath: String,
val usesReportIDs: Boolean,
val autoRelease: Boolean = true,
) {
private val reportsChannel = Channel<ByteArray>(Channel.UNLIMITED) {
Timber.wtf("A channel with an unlimited buffer shouldn't be failing to receive elements")
Expand All @@ -18,7 +19,11 @@ abstract class ReportSender(
suspend fun start(onSuccess: () -> Unit, onException: (e: IOException) -> Unit) {
for (report in reportsChannel) {
try {
sendReport(report, characterDevicePath, usesReportIDs)
if (autoRelease) {
sendReport(report, characterDevicePath, usesReportIDs)
} else {
writeHIDReport(report, characterDevicePath)
}
onSuccess()
} catch (e: IOException) {
Timber.d(e)
Expand Down Expand Up @@ -56,15 +61,15 @@ abstract class ReportSender(
writeHIDReport(releaseReport, characterDevicePath)
}

// Writes HID report to character device
@Throws(IOException::class, FileNotFoundException::class)
private fun writeHIDReport(report: ByteArray, characterDevicePath: String) {
FileOutputStream(characterDevicePath).use { outputStream ->
outputStream.write(report)
}
}

companion object {
val dispatcher = Dispatchers.IO

// Writes HID report to character device
@Throws(IOException::class, FileNotFoundException::class)
private fun writeHIDReport(report: ByteArray, characterDevicePath: String) {
FileOutputStream(characterDevicePath).use { outputStream ->
outputStream.write(report)
}
}
}
}
Loading

0 comments on commit 66db628

Please sign in to comment.