diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ee47256e..4f131a57 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,7 +44,7 @@ jobs:
uses: gradle/actions/setup-gradle@v6.3.0
- name: Run fast JVM tests and generate coverage
- run: ./gradlew --no-daemon --stacktrace :app:testDebugUnitTest :uhid-server:test :koverXmlReportDebugJvm :uhid-server:jacocoTestReport
+ run: ./gradlew --no-daemon --stacktrace :app:testDebugUnitTest :koverXmlReportDebugJvm
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
@@ -52,7 +52,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
# Fork PRs use Codecov's tokenless upload; trusted runs authenticate with OIDC.
use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
- files: build/reports/kover/coverage-debug-jvm.xml,uhid-server/build/reports/jacoco/test/jacocoTestReport.xml
+ files: build/reports/kover/coverage-debug-jvm.xml
flags: jvm
disable_search: true
# Codecov returns an authentication error until the upstream repository is enabled there.
@@ -67,8 +67,6 @@ jobs:
path: |
app/build/reports/tests/
app/build/test-results/
- uhid-server/build/reports/tests/
- uhid-server/build/test-results/
if-no-files-found: warn
retention-days: 7
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 14f2772c..bff59758 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -22,8 +22,8 @@ android {
applicationId = "com.inputleaf.android"
minSdk = 26
targetSdk = 34
- versionCode = 7
- versionName = "1.4.1"
+ versionCode = 8
+ versionName = "1.4.2"
// JUnit4 runner so the androidTest classes are discovered on the emulator
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -60,12 +60,6 @@ android {
aidl = true // Enable AIDL for Shizuku IPC
}
- sourceSets {
- getByName("main").assets.srcDir(
- project(":uhid-server").layout.buildDirectory.dir("generated/assets/uhid").get().asFile
- )
- }
-
packaging {
jniLibs {
useLegacyPackaging = true
@@ -102,14 +96,6 @@ androidComponents {
}
}
-tasks.matching { it.name.startsWith("merge") && it.name.endsWith("Assets") }.configureEach {
- dependsOn(":uhid-server:buildDex")
-}
-
-tasks.matching { it.name.contains("lintVital", ignoreCase = true) }.configureEach {
- dependsOn(":uhid-server:buildDex")
-}
-
dependencies {
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
@@ -140,6 +126,8 @@ dependencies {
androidTestImplementation(composeBom)
androidTestImplementation(libs.compose.ui.test.junit4)
+ // Forces the Android 14+ compatible Espresso over the 3.5.0 the Compose BOM drags in.
+ androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index dfce3e41..5ac17aed 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -16,17 +16,14 @@
android:label="Input Leaf"
android:theme="@style/Theme.InputLeaf">
-
Unit)?) {
+ hidKeyboard.setOnNativePointerStateChanged(listener)
}
override fun send(event: InputLeapEvent) {
@@ -67,30 +109,40 @@ class AccessibilityInputInjector(
is InputLeapEvent.MouseMoveAbs -> {
mouseX = event.x.toFloat().coerceIn(0f, screenWidth.toFloat())
mouseY = event.y.toFloat().coerceIn(0f, screenHeight.toFloat())
- svc.injectTouchMove(mouseX, mouseY)
+ if (!hidKeyboard.tryHidMouse(event)) {
+ svc.injectTouchMove(mouseX, mouseY)
+ }
}
is InputLeapEvent.MouseMoveRel -> {
mouseX = (mouseX + event.dx).coerceIn(0f, screenWidth.toFloat())
mouseY = (mouseY + event.dy).coerceIn(0f, screenHeight.toFloat())
- svc.injectTouchMove(mouseX, mouseY)
+ if (!hidKeyboard.tryHidMouse(event)) {
+ svc.injectTouchMove(mouseX, mouseY)
+ }
}
is InputLeapEvent.MouseDown -> {
- svc.injectTouchDown(mouseX, mouseY)
+ if (!hidKeyboard.tryHidMouse(event)) {
+ svc.injectTouchDown(mouseX, mouseY)
+ }
}
is InputLeapEvent.MouseUp -> {
- svc.injectTouchUp(mouseX, mouseY)
+ if (!hidKeyboard.tryHidMouse(event)) {
+ svc.injectTouchUp(mouseX, mouseY)
+ }
}
is InputLeapEvent.MouseWheel -> {
- val swipeLength = 300f
- val startY = mouseY
- // event.yDelta > 0 means scroll up (swipe down), event.yDelta < 0 means scroll down (swipe up)
- val endY = (if (event.yDelta > 0) mouseY + swipeLength else mouseY - swipeLength)
- .coerceIn(0f, screenHeight.toFloat())
- svc.injectSwipe(mouseX, startY, mouseX, endY, 150)
+ if (!hidKeyboard.tryHidMouse(event)) {
+ val swipeLength = 300f
+ val startY = mouseY
+ // event.yDelta > 0 means scroll up (swipe down), event.yDelta < 0 means scroll down (swipe up)
+ val endY = (if (event.yDelta > 0) mouseY + swipeLength else mouseY - swipeLength)
+ .coerceIn(0f, screenHeight.toFloat())
+ svc.injectSwipe(mouseX, startY, mouseX, endY, 150)
+ }
}
is InputLeapEvent.KeyDown ->
@@ -110,13 +162,15 @@ class AccessibilityInputInjector(
}
private fun handleKeyEvent(keysym: Int, mask: Int, button: Int, isDown: Boolean) {
+ val scancode = scanCodeDecoder.toEvdev(button, keysym)
+ if (hidKeyboard.tryHidKey(scancode, isDown)) {
+ return
+ }
val ime = InputLeafIME.getInstance()
if (ime == null) {
Log.w(TAG, "InputLeafIME not running, dropping key event")
return
}
-
- val scancode = scanCodeDecoder.toEvdev(button, keysym)
val shortcutModifiers = KeyMapUtils.hasShortcutModifiers(metaState) ||
KeyMapUtils.protocolMaskHasShortcuts(mask)
when (val resolved = KeysymResolver.resolve(
@@ -152,13 +206,15 @@ class AccessibilityInputInjector(
}
private fun handleKeyRepeat(keysym: Int, mask: Int, button: Int, count: Int) {
+ val scancode = scanCodeDecoder.toEvdev(button, keysym)
+ if (hidKeyboard.tryHidKey(scancode, isDown = true)) {
+ return
+ }
val ime = InputLeafIME.getInstance()
if (ime == null) {
Log.w(TAG, "InputLeafIME not running, dropping KeyRepeat")
return
}
-
- val scancode = scanCodeDecoder.toEvdev(button, keysym)
val shortcutModifiers = KeyMapUtils.hasShortcutModifiers(metaState) ||
KeyMapUtils.protocolMaskHasShortcuts(mask)
val injectionMeta = metaState or KeyMapUtils.androidMetaFromProtocolMask(mask)
diff --git a/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputService.kt b/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputService.kt
index 64fcc248..e90bb58e 100644
--- a/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputService.kt
+++ b/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputService.kt
@@ -50,6 +50,11 @@ class AccessibilityInputService : AccessibilityService() {
updateCursorImage()
}
}
+ if (com.inputleaf.android.service.CursorOverlayService.isVisible.value) {
+ showCursorInternal()
+ } else {
+ ensureCursorView(visible = false)
+ }
}
private fun updateCursorImage() {
@@ -73,7 +78,10 @@ class AccessibilityInputService : AccessibilityService() {
try {
scope.cancel()
} catch (e: Exception) {}
- hideCursorInternal()
+ cursorView?.let {
+ runCatching { windowManager?.removeView(it) }
+ }
+ cursorView = null
instance = null
super.onDestroy()
}
@@ -106,49 +114,51 @@ class AccessibilityInputService : AccessibilityService() {
}
fun showCursorInternal() {
- if (cursorView != null) return
- val resId = if (currentCursorStyle == "leaf") {
- com.inputleaf.android.R.drawable.cursor
- } else {
- com.inputleaf.android.R.drawable.ic_cursor_aosp
- }
- val scaleXValue = if (currentCursorStyle == "leaf") -1f else 1f
- val view = android.widget.ImageView(this).apply {
- setImageResource(resId)
- scaleX = scaleXValue
- }
- cursorView = view
-
- val CURSOR_SIZE = 80
- val params = android.view.WindowManager.LayoutParams(
- CURSOR_SIZE,
- CURSOR_SIZE,
- android.view.WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
- android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
- android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
- android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
- android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
- android.graphics.PixelFormat.TRANSLUCENT
- ).apply {
- gravity = android.view.Gravity.TOP or android.view.Gravity.START
- x = com.inputleaf.android.service.CursorOverlayService.cursorX.value.toInt() - CURSOR_SIZE / 2
- y = com.inputleaf.android.service.CursorOverlayService.cursorY.value.toInt() - CURSOR_SIZE / 2
- }
-
- try {
- windowManager?.addView(view, params)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to add cursor overlay", e)
- }
+ ensureCursorView(visible = true)
}
- fun hideCursorInternal() {
- cursorView?.let {
+ private fun ensureCursorView(visible: Boolean) {
+ if (cursorView == null) {
+ val resId = if (currentCursorStyle == "leaf") {
+ com.inputleaf.android.R.drawable.cursor
+ } else {
+ com.inputleaf.android.R.drawable.ic_cursor_aosp
+ }
+ val scaleXValue = if (currentCursorStyle == "leaf") -1f else 1f
+ val view = android.widget.ImageView(this).apply {
+ setImageResource(resId)
+ scaleX = scaleXValue
+ visibility = android.view.View.INVISIBLE
+ }
+ val CURSOR_SIZE = 80
+ val params = android.view.WindowManager.LayoutParams(
+ CURSOR_SIZE,
+ CURSOR_SIZE,
+ android.view.WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
+ android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
+ android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
+ android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ android.graphics.PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = android.view.Gravity.TOP or android.view.Gravity.START
+ x = com.inputleaf.android.service.CursorOverlayService.cursorX.value.toInt() - CURSOR_SIZE / 2
+ y = com.inputleaf.android.service.CursorOverlayService.cursorY.value.toInt() - CURSOR_SIZE / 2
+ }
try {
- windowManager?.removeView(it)
- } catch (e: Exception) {}
+ windowManager?.addView(view, params)
+ cursorView = view
+ Log.i(TAG, "Cursor overlay view attached")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to add cursor overlay", e)
+ return
+ }
}
- cursorView = null
+ cursorView?.visibility = if (visible) android.view.View.VISIBLE else android.view.View.INVISIBLE
+ }
+
+ fun hideCursorInternal() {
+ cursorView?.visibility = android.view.View.INVISIBLE
}
fun moveCursorInternal(x: Float, y: Float) {
diff --git a/app/src/main/java/com/inputleaf/android/inject/InputInjector.kt b/app/src/main/java/com/inputleaf/android/inject/InputInjector.kt
index 068f81e4..81d595e1 100644
--- a/app/src/main/java/com/inputleaf/android/inject/InputInjector.kt
+++ b/app/src/main/java/com/inputleaf/android/inject/InputInjector.kt
@@ -8,4 +8,18 @@ interface InputInjector {
fun disconnect()
fun isAvailable(): Boolean
val name: String
+ fun setHidKeyboardAttached(attached: Boolean) {}
+ fun setHidMouseAttached(attached: Boolean) {}
+ fun usesNativePointer(): Boolean = false
+ fun nativePointerState(): NativePointerState = NativePointerState.NONE
+ fun expectsNativePointer(): Boolean = false
+ fun setOnNativePointerStateChanged(listener: ((NativePointerState) -> Unit)?) {}
+ fun updateScreenSize(width: Int, height: Int) {}
+ fun updatePointerSpeed(speed: Int) {}
+
+ /** Cursor entered at (x, y) in screen coords — snap the native pointer there. */
+ fun onHidMouseEnter(x: Int, y: Int) {}
+
+ /** Cursor left this screen. Marks the current HID target stale for a later detach. */
+ fun onHidMouseLeave() {}
}
diff --git a/app/src/main/java/com/inputleaf/android/inject/InputMethodResolver.kt b/app/src/main/java/com/inputleaf/android/inject/InputMethodResolver.kt
deleted file mode 100644
index 57a5bfb7..00000000
--- a/app/src/main/java/com/inputleaf/android/inject/InputMethodResolver.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.inputleaf.android.inject
-
-object InputMethodResolver {
- fun resolve(
- preferredMethod: String,
- isShizukuAvailable: Boolean,
- isAccessibilityAvailable: Boolean
- ): ResolvedMethod {
- return when (preferredMethod) {
- "shizuku" -> if (isShizukuAvailable) ResolvedMethod.SHIZUKU else ResolvedMethod.NONE
- "accessibility" -> if (isAccessibilityAvailable) ResolvedMethod.ACCESSIBILITY else ResolvedMethod.NONE
- "auto" -> {
- if (isShizukuAvailable) ResolvedMethod.SHIZUKU
- else if (isAccessibilityAvailable) ResolvedMethod.ACCESSIBILITY
- else ResolvedMethod.NONE
- }
- else -> ResolvedMethod.NONE
- }
- }
-}
-
-enum class ResolvedMethod {
- SHIZUKU, ACCESSIBILITY, NONE
-}
diff --git a/app/src/main/java/com/inputleaf/android/inject/NativePointerState.kt b/app/src/main/java/com/inputleaf/android/inject/NativePointerState.kt
new file mode 100644
index 00000000..728054d0
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/inject/NativePointerState.kt
@@ -0,0 +1,21 @@
+package com.inputleaf.android.inject
+
+/**
+ * Native (UHID / AOSP) pointer availability for overlay arbitration.
+ *
+ * [PENDING] and [ACTIVE] must hide Input Leaf's overlay so it cannot sit on top of
+ * the system pointer. [FALLBACK] is the only on-screen state that may show the overlay.
+ */
+enum class NativePointerState {
+ /** Off-screen, disconnected, or no native pointer path. */
+ NONE,
+
+ /** UHID mouse attach is in flight — hide overlay to avoid a dual-cursor flash. */
+ PENDING,
+
+ /** Native pointer is live. */
+ ACTIVE,
+
+ /** UHID mouse failed or timed out; overlay may be used if the user enabled it. */
+ FALLBACK,
+}
diff --git a/app/src/main/java/com/inputleaf/android/model/InputLeapEvent.kt b/app/src/main/java/com/inputleaf/android/model/InputLeapEvent.kt
index 7d407624..943fc8ab 100644
--- a/app/src/main/java/com/inputleaf/android/model/InputLeapEvent.kt
+++ b/app/src/main/java/com/inputleaf/android/model/InputLeapEvent.kt
@@ -14,6 +14,7 @@ sealed class InputLeapEvent {
val protocol: WireProtocol = WireProtocol.BARRIER,
) : InputLeapEvent()
data class QueryInfo(val dummy: Unit = Unit) : InputLeapEvent()
+ data class InfoAck(val dummy: Unit = Unit) : InputLeapEvent()
// Control
data class Enter(val x: Int, val y: Int, val seqNum: Int, val mask: Int) : InputLeapEvent()
object Leave : InputLeapEvent()
diff --git a/app/src/main/java/com/inputleaf/android/network/ConnectionTransportPolicy.kt b/app/src/main/java/com/inputleaf/android/network/ConnectionTransportPolicy.kt
index 4adc0846..e84028be 100644
--- a/app/src/main/java/com/inputleaf/android/network/ConnectionTransportPolicy.kt
+++ b/app/src/main/java/com/inputleaf/android/network/ConnectionTransportPolicy.kt
@@ -10,6 +10,35 @@ enum class ConnectionTransportPolicy(val storageValue: String) {
entries.firstOrNull { it.storageValue == value } ?: AUTO
}
+ fun order(preferredTransport: ServerTransport?): List =
+ order(preferredTransport, null)
+
+ fun order(
+ preferredTransport: ServerTransport?,
+ detectedMode: ServerSecurityMode?,
+ ): List = when (this) {
+ TLS_ONLY -> listOf(ServerTransport.TLS)
+ PLAIN_ONLY -> listOf(ServerTransport.PLAIN)
+ AUTO -> when (detectedMode) {
+ ServerSecurityMode.PLAIN -> listOf(ServerTransport.PLAIN)
+ ServerSecurityMode.TLS,
+ ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED -> listOf(ServerTransport.TLS)
+ null -> {
+ if (preferredTransport != null) {
+ val fallback = when (preferredTransport) {
+ ServerTransport.TLS -> ServerTransport.PLAIN
+ ServerTransport.PLAIN -> ServerTransport.TLS
+ }
+ listOf(preferredTransport, fallback)
+ } else {
+ // Deskflow is TLS by default. Probing TLS first fails fast on plain servers;
+ // probing plain first stalls for the handshake timeout on TLS servers.
+ listOf(ServerTransport.TLS, ServerTransport.PLAIN)
+ }
+ }
+ }
+ }
+
fun shouldRetry(reason: ConnectResult.FailureReason): Boolean =
this == AUTO && when (reason) {
ConnectResult.FailureReason.NETWORK,
diff --git a/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt b/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt
index 0c49eb3a..8dfc9a67 100644
--- a/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt
+++ b/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt
@@ -32,6 +32,10 @@ private const val TLS_CLIENT_AUTH_HANDSHAKE_TIMEOUT_MS = 90_000
private const val PLAIN_CONNECT_TIMEOUT_CACHED_MS = 800
private const val PLAIN_CONNECT_TIMEOUT_MS = 2_000
+private fun logD(message: String) { runCatching { Log.d(TAG, message) } }
+private fun logW(message: String) { runCatching { Log.w(TAG, message) } }
+private fun logE(message: String) { runCatching { Log.e(TAG, message) } }
+
class InputLeapConnection(
private val ip: String,
private val port: Int = 24800,
@@ -39,7 +43,6 @@ class InputLeapConnection(
private val pinnedFingerprint: String? = null,
private val transportPolicy: ConnectionTransportPolicy = ConnectionTransportPolicy.AUTO,
private val clientCertificate: ClientCertificateMaterial? = null,
- private val logger: Logger = AndroidLogger,
private val onCertificate: suspend (X509Certificate) -> Boolean,
) {
private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 64)
@@ -56,27 +59,6 @@ class InputLeapConnection(
/** Version advertised by the server before client-side minor-version negotiation. */
data class ServerBanner(val major: Int, val minor: Int)
- /** Minimal logging seam for tests and advanced integrations. */
- interface Logger {
- fun debug(message: String)
- fun warn(message: String)
- fun error(message: String)
- }
-
- private object AndroidLogger : Logger {
- override fun debug(message: String) {
- Log.d(TAG, message)
- }
-
- override fun warn(message: String) {
- Log.w(TAG, message)
- }
-
- override fun error(message: String) {
- Log.e(TAG, message)
- }
- }
-
/**
* Opens and handshakes a connection. Attempts are serialized, and calling this while the
* connection is already open throws [IllegalStateException]. Call [close] before reconnecting.
@@ -101,8 +83,7 @@ class InputLeapConnection(
) {
listOf(ServerTransport.TLS)
} else {
- TransportPolicy.order(
- policy = transportPolicy,
+ transportPolicy.order(
preferredTransport = preferredTransport,
detectedMode = detectedMode,
)
@@ -122,27 +103,23 @@ class InputLeapConnection(
return@withContext result
}
if (result is ConnectResult.Failed) {
- if (pinnedFingerprint != null ||
- !transportPolicy.shouldFallbackWithinAttempt(result.reason)
- ) {
- return@withContext result
- }
lastFailure = selectFailureToReport(lastFailure, result)
+ val shouldStop = pinnedFingerprint != null ||
+ !transportPolicy.shouldFallbackWithinAttempt(result.reason)
+ if (shouldStop) break
}
}
is SocketOpenResult.Rejected -> return@withContext ConnectResult.RejectedByUser
is SocketOpenResult.Failed -> {
- if (pinnedFingerprint != null ||
- !transportPolicy.shouldFallbackWithinAttempt(opened.failure.reason)
- ) {
- return@withContext opened.failure
- }
lastFailure = selectFailureToReport(lastFailure, opened.failure)
+ val shouldStop = pinnedFingerprint != null ||
+ !transportPolicy.shouldFallbackWithinAttempt(opened.failure.reason)
+ if (shouldStop) break
}
}
}
val failure = lastFailure ?: ConnectResult.Failed(ConnectResult.FailureReason.NETWORK)
- logger.error("All transports failed for $ip: ${failure.reason} ${failure.detail}")
+ logE("All transports failed for $ip: ${failure.reason} ${failure.detail}")
failure
}
}
@@ -159,7 +136,7 @@ class InputLeapConnection(
ServerTransport.PLAIN -> try {
SocketOpenResult.Ok(openPlainSocket(), ServerTransport.PLAIN)
} catch (e: Exception) {
- logger.warn("Plain open failed for $ip: ${e.message}")
+ logW("Plain open failed for $ip: ${e.message}")
SocketOpenResult.Failed(
ConnectResult.Failed(ConnectResult.FailureReason.NETWORK, e.message),
)
@@ -214,7 +191,7 @@ class InputLeapConnection(
} catch (e: Exception) {
runCatching { openedSocket?.close() }
if (clientCertificate == null && isClientCertificateRequired(e)) {
- logger.warn("Deskflow requires a client certificate")
+ logW("Deskflow requires a client certificate")
SocketOpenResult.Failed(
ConnectResult.Failed(
ConnectResult.FailureReason.CLIENT_CERT_REQUIRED,
@@ -222,7 +199,7 @@ class InputLeapConnection(
),
)
} else if (isCertificateMismatch(e)) {
- logger.warn("TLS certificate changed for $ip")
+ logW("TLS certificate changed for $ip")
SocketOpenResult.Failed(
ConnectResult.Failed(
ConnectResult.FailureReason.CERTIFICATE_MISMATCH,
@@ -230,7 +207,7 @@ class InputLeapConnection(
),
)
} else if (isPlainServerTlsError(e)) {
- logger.debug("TLS required, but $ip speaks plain Deskflow")
+ logD("TLS required, but $ip speaks plain Deskflow")
SocketOpenResult.Failed(
ConnectResult.Failed(
ConnectResult.FailureReason.TLS_AGAINST_PLAIN_SERVER,
@@ -238,7 +215,7 @@ class InputLeapConnection(
),
)
} else {
- logger.warn("TLS open failed for $ip: ${e.message}")
+ logW("TLS open failed for $ip: ${e.message}")
SocketOpenResult.Failed(
ConnectResult.Failed(ConnectResult.FailureReason.NETWORK, e.message),
)
@@ -303,7 +280,7 @@ class InputLeapConnection(
try {
repeat(32) {
val event = parser.readNext()
- logger.debug("Handshake recv: $event")
+ logD("Handshake recv: $event")
when (event) {
is InputLeapEvent.Hello -> {
bannerMajor = event.majorVersion
@@ -319,7 +296,7 @@ class InputLeapConnection(
protocol = negotiatedProtocol,
)
helloSent = true
- logger.debug(
+ logD(
"Handshake sent ${negotiatedProtocol.magic} client hello " +
"as $screenName using 1.$negotiatedMinor",
)
@@ -328,7 +305,7 @@ class InputLeapConnection(
is InputLeapEvent.QueryInfo -> {
writer?.writeDataInfo(screenWidth, screenHeight, 0, 0, 0, 0)
dinfSent = true
- logger.debug("Handshake sent DINF ${screenWidth}x$screenHeight")
+ logD("Handshake sent DINF ${screenWidth}x$screenHeight")
}
is InputLeapEvent.KeepAlive -> {
writer?.writeKeepAlive()
@@ -342,7 +319,7 @@ class InputLeapConnection(
}
}
is InputLeapEvent.Incompatible -> {
- logger.error("Server rejected handshake: $event")
+ logE("Server rejected handshake: $event")
close()
return ConnectResult.Failed(
ConnectResult.FailureReason.INCOMPATIBLE,
@@ -350,7 +327,7 @@ class InputLeapConnection(
)
}
is InputLeapEvent.Busy -> {
- logger.error("Server rejected handshake: busy")
+ logE("Server rejected handshake: busy")
close()
return ConnectResult.Failed(ConnectResult.FailureReason.BUSY)
}
@@ -359,12 +336,12 @@ class InputLeapConnection(
if (helloSent && dinfSent && sawPostDinf) {
rawSocket.soTimeout = 0
readJob = readerScope.launch { readLoop(parser) }
- logger.debug("Handshake complete via $transport")
+ logD("Handshake complete via $transport")
return ConnectResult.Ok(ServerBanner(bannerMajor, bannerMinor), transport)
}
}
} catch (e: Exception) {
- logger.error("Handshake error: ${e.javaClass.simpleName}: ${e.message}")
+ logE("Handshake error: ${e.javaClass.simpleName}: ${e.message}")
close()
return ConnectResult.Failed(ConnectResult.FailureReason.HANDSHAKE, e.message)
}
@@ -373,11 +350,11 @@ class InputLeapConnection(
if (helloSent && dinfSent) {
rawSocket.soTimeout = 0
readJob = readerScope.launch { readLoop(parser) }
- logger.debug("Handshake complete (lenient) via $transport")
+ logD("Handshake complete (lenient) via $transport")
return ConnectResult.Ok(ServerBanner(bannerMajor, bannerMinor), transport)
}
- logger.error("Handshake incomplete hello=$helloSent dinf=$dinfSent post=$sawPostDinf")
+ logE("Handshake incomplete hello=$helloSent dinf=$dinfSent post=$sawPostDinf")
close()
return ConnectResult.Failed(
ConnectResult.FailureReason.HANDSHAKE,
@@ -393,14 +370,19 @@ class InputLeapConnection(
event !is InputLeapEvent.MouseMoveRel &&
event !is InputLeapEvent.KeepAlive
) {
- logger.debug("Read event: $event")
+ logD("Read event: $event")
+ }
+ val mapped = when {
+ event is InputLeapEvent.Unhandled && event.tag == "CIAK" ->
+ InputLeapEvent.InfoAck()
+ else -> event
}
- _events.emit(event)
+ _events.emit(mapped)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
- logger.error("Read loop ended: ${e.javaClass.simpleName}: ${e.message}")
+ logE("Read loop ended: ${e.javaClass.simpleName}: ${e.message}")
_events.emit(InputLeapEvent.Unhandled("__DISCONNECTED__"))
}
}
@@ -409,7 +391,8 @@ class InputLeapConnection(
runCatching { socket?.soTimeout = 0 }
}
- fun sendDataInfo(w: Int, h: Int) = writer?.writeDataInfo(w, h, 0, 0, 0, 0)
+ fun sendDataInfo(w: Int, h: Int, mx: Int, my: Int) =
+ writer?.writeDataInfo(w, h, 0, 0, mx, my)
fun sendKeepAlive() = writer?.writeKeepAlive()
fun sendInfoAck() = writer?.writeInfoAck()
diff --git a/app/src/main/java/com/inputleaf/android/network/TransportPolicy.kt b/app/src/main/java/com/inputleaf/android/network/TransportPolicy.kt
deleted file mode 100644
index 0843aa88..00000000
--- a/app/src/main/java/com/inputleaf/android/network/TransportPolicy.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.inputleaf.android.network
-
-object TransportPolicy {
- fun order(
- policy: ConnectionTransportPolicy,
- preferredTransport: ServerTransport?,
- detectedMode: ServerSecurityMode? = null,
- ): List = when (policy) {
- ConnectionTransportPolicy.TLS_ONLY -> listOf(ServerTransport.TLS)
- ConnectionTransportPolicy.PLAIN_ONLY -> listOf(ServerTransport.PLAIN)
- ConnectionTransportPolicy.AUTO -> autoOrder(preferredTransport, detectedMode)
- }
-
- private fun autoOrder(
- preferredTransport: ServerTransport?,
- detectedMode: ServerSecurityMode?,
- ): List {
- when (detectedMode) {
- ServerSecurityMode.PLAIN -> return listOf(ServerTransport.PLAIN)
- ServerSecurityMode.TLS,
- ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED -> return listOf(ServerTransport.TLS)
- null -> Unit
- }
- if (preferredTransport != null) {
- val fallback = when (preferredTransport) {
- ServerTransport.TLS -> ServerTransport.PLAIN
- ServerTransport.PLAIN -> ServerTransport.TLS
- }
- return listOf(preferredTransport, fallback)
- }
- // Deskflow is TLS by default. Probing TLS first fails fast on plain servers;
- // probing plain first stalls for the handshake timeout on TLS servers.
- return listOf(ServerTransport.TLS, ServerTransport.PLAIN)
- }
-}
diff --git a/app/src/main/java/com/inputleaf/android/protocol/KeysymTable.kt b/app/src/main/java/com/inputleaf/android/protocol/KeysymTable.kt
deleted file mode 100644
index b3f2d8b3..00000000
--- a/app/src/main/java/com/inputleaf/android/protocol/KeysymTable.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package com.inputleaf.android.protocol
-
-object KeysymTable {
- // X11 keysym → USB HID Usage ID (page 0x07)
- // Source: Linux kernel hid-input.c + USB HID Usage Tables 1.12
- private val table: Map = hashMapOf(
- // Letters (a-z) → HID 0x04-0x1D
- 0x0061 to 0x04, 0x0062 to 0x05, 0x0063 to 0x06, 0x0064 to 0x07,
- 0x0065 to 0x08, 0x0066 to 0x09, 0x0067 to 0x0A, 0x0068 to 0x0B,
- 0x0069 to 0x0C, 0x006A to 0x0D, 0x006B to 0x0E, 0x006C to 0x0F,
- 0x006D to 0x10, 0x006E to 0x11, 0x006F to 0x12, 0x0070 to 0x13,
- 0x0071 to 0x14, 0x0072 to 0x15, 0x0073 to 0x16, 0x0074 to 0x17,
- 0x0075 to 0x18, 0x0076 to 0x19, 0x0077 to 0x1A, 0x0078 to 0x1B,
- 0x0079 to 0x1C, 0x007A to 0x1D,
- // Uppercase maps to same HID code (modifier handled separately)
- 0x0041 to 0x04, 0x0042 to 0x05, 0x0043 to 0x06, 0x0044 to 0x07,
- 0x0045 to 0x08, 0x0046 to 0x09, 0x0047 to 0x0A, 0x0048 to 0x0B,
- 0x0049 to 0x0C, 0x004A to 0x0D, 0x004B to 0x0E, 0x004C to 0x0F,
- 0x004D to 0x10, 0x004E to 0x11, 0x004F to 0x12, 0x0050 to 0x13,
- 0x0051 to 0x14, 0x0052 to 0x15, 0x0053 to 0x16, 0x0054 to 0x17,
- 0x0055 to 0x18, 0x0056 to 0x19, 0x0057 to 0x1A, 0x0058 to 0x1B,
- 0x0059 to 0x1C, 0x005A to 0x1D,
- // Digits 1-9,0 → HID 0x1E-0x27
- 0x0031 to 0x1E, 0x0032 to 0x1F, 0x0033 to 0x20, 0x0034 to 0x21,
- 0x0035 to 0x22, 0x0036 to 0x23, 0x0037 to 0x24, 0x0038 to 0x25,
- 0x0039 to 0x26, 0x0030 to 0x27,
- // Control keys
- 0xFF0D to 0x28, // Return
- 0xFF1B to 0x29, // Escape
- 0xFF08 to 0x2A, // BackSpace
- 0xFF09 to 0x2B, // Tab
- 0x0020 to 0x2C, // Space
- 0xFF50 to 0x4A, // Home
- 0xFF57 to 0x4D, // End
- 0xFF55 to 0x4B, // Page_Up
- 0xFF56 to 0x4E, // Page_Down
- 0xFF63 to 0x49, // Insert
- 0xFFFF to 0x4C, // Delete
- // Arrows
- 0xFF51 to 0x50, // Left
- 0xFF52 to 0x52, // Up
- 0xFF53 to 0x4F, // Right
- 0xFF54 to 0x51, // Down
- // Function keys F1-F12 → HID 0x3A-0x45
- 0xFFBE to 0x3A, 0xFFBF to 0x3B, 0xFFC0 to 0x3C, 0xFFC1 to 0x3D,
- 0xFFC2 to 0x3E, 0xFFC3 to 0x3F, 0xFFC4 to 0x40, 0xFFC5 to 0x41,
- 0xFFC6 to 0x42, 0xFFC7 to 0x43, 0xFFC8 to 0x44, 0xFFC9 to 0x45,
- // F13-F24 → HID 0x68-0x73
- 0xFFCA to 0x68, 0xFFCB to 0x69, 0xFFCC to 0x6A, 0xFFCD to 0x6B,
- 0xFFCE to 0x6C, 0xFFCF to 0x6D, 0xFFD0 to 0x6E, 0xFFD1 to 0x6F,
- 0xFFD2 to 0x70, 0xFFD3 to 0x71, 0xFFD4 to 0x72, 0xFFD5 to 0x73,
- // Modifiers
- 0xFFE1 to 0xE1, // Shift_L
- 0xFFE2 to 0xE5, // Shift_R
- 0xFFE3 to 0xE0, // Control_L
- 0xFFE4 to 0xE4, // Control_R
- 0xFFE9 to 0xE2, // Alt_L
- 0xFFEA to 0xE6, // Alt_R
- 0xFFEB to 0xE3, // Super_L (Meta)
- 0xFFEC to 0xE7, // Super_R
- // Punctuation
- 0x002D to 0x2D, 0x003D to 0x2E, 0x005B to 0x2F, 0x005D to 0x30,
- 0x005C to 0x31, 0x003B to 0x33, 0x0027 to 0x34, 0x0060 to 0x35,
- 0x002C to 0x36, 0x002E to 0x37, 0x002F to 0x38,
- // Media keys
- 0x1008FF14 to 0xCD, // Play/Pause
- 0x1008FF15 to 0xCD, // Pause
- 0x1008FF16 to 0xB6, // Prev
- 0x1008FF17 to 0xB5, // Next
- 0x1008FF11 to 0xEA, // Vol Down
- 0x1008FF13 to 0xE9, // Vol Up
- 0x1008FF12 to 0xE2, // Mute
- )
-
- fun toHidUsage(x11Keysym: Int): Int? = table[x11Keysym]
-}
diff --git a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt
index e570e24e..121fc057 100644
--- a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt
+++ b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt
@@ -2,14 +2,21 @@ package com.inputleaf.android.service
import android.app.Service
import android.content.Intent
+import android.content.res.Configuration
import android.graphics.Point
import android.graphics.Rect
+import android.database.ContentObserver
+import android.net.Uri
import android.os.Binder
import android.os.Build
+import android.os.Handler
import android.os.IBinder
+import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.WindowManager
+import com.inputleaf.android.inject.AccessibilityInputService
+import com.inputleaf.android.inject.NativePointerState
import com.inputleaf.android.model.ConnectionState
import com.inputleaf.android.model.InputLeapEvent
import com.inputleaf.android.network.ConnectResult
@@ -28,12 +35,16 @@ import rikka.shizuku.Shizuku
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
private const val TAG = "ConnectionService"
private const val KEEPALIVE_POLL_MS = 5_000L
+private const val LEAVE_DEBOUNCE_MS = 300L
+// How long the cursor must stay away before the HID mouse is actually destroyed.
+private const val HID_MOUSE_IDLE_DETACH_MS = 30_000L
class ConnectionService : Service() {
@@ -47,6 +58,7 @@ class ConnectionService : Service() {
private var retryJob: Job? = null
private var retryAttempt = 0
private var connectGeneration = 0
+ private var infoAckPending = false
private var userInitiatedDisconnect = false
private var cursorOverlayEnabled = false
private var mouseEnabled = true
@@ -54,15 +66,33 @@ class ConnectionService : Service() {
private var previousImeId: String? = null
private var previousImeLabel: String? = null
private var isUsingAccessibilityIme = false
- private var screenWidth = 0
- private var screenHeight = 0
+ // Written on the main thread (rotation / DINF), read from the IO event loop. The
+ // HID path is published through HidMouseState.resizeDisplay, but the fallback touch
+ // path reads these directly and would otherwise clamp against stale bounds.
+ @Volatile private var screenWidth = 0
+ @Volatile private var screenHeight = 0
private var currentMouseX = 0f
private var currentMouseY = 0f
private var activeServerIp: String? = null
private var activeScreenName: String? = null
private var shizukuRecoveryJob: Job? = null
+ private var leaveDebounceJob: Job? = null
+ private var hidMouseIdleJob: Job? = null
+ private val hidKeyboardGate = HidAttachmentController()
+ private val hidMouseGate = HidAttachmentController()
+ @Volatile private var pointerOnScreen = false
private lateinit var prefs: AppPreferences
+ private val pointerSpeedObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
+ override fun onChange(selfChange: Boolean) {
+ applyPointerSpeedFromSettings()
+ }
+
+ override fun onChange(selfChange: Boolean, uri: Uri?) {
+ applyPointerSpeedFromSettings()
+ }
+ }
+
private val shizukuBinderReceivedListener = Shizuku.OnBinderReceivedListener {
Log.i(TAG, "Shizuku binder received in ConnectionService")
handleShizukuRestarted()
@@ -101,6 +131,7 @@ class ConnectionService : Service() {
prefs.showCursor.collect { enabled ->
cursorOverlayEnabled = enabled
Log.d(TAG, "Cursor overlay enabled changed: $enabled")
+ applyCursorOverlay()
}
}
@@ -108,7 +139,8 @@ class ConnectionService : Service() {
mouseEnabled = prefs.mouseEnabled.first()
prefs.mouseEnabled.collect { enabled ->
mouseEnabled = enabled
- if (!enabled) hideCursorOverlay()
+ applyCursorOverlay()
+ setHidMouseAttached(enabled && stateMachine.state.value is ConnectionState.Active)
}
}
@@ -116,12 +148,34 @@ class ConnectionService : Service() {
keyboardEnabled = prefs.keyboardEnabled.first()
prefs.keyboardEnabled.collect { enabled ->
keyboardEnabled = enabled
+ setHidKeyboardAttached(enabled && stateMachine.state.value is ConnectionState.Active)
}
}
- if (Settings.canDrawOverlays(this)) {
+ if (canShowCursor()) {
startService(Intent(this, CursorOverlayService::class.java))
}
+
+ registerPointerSpeedObserver()
+ }
+
+ private fun registerPointerSpeedObserver() {
+ contentResolver.registerContentObserver(
+ Settings.System.getUriFor(SETTINGS_POINTER_SPEED_KEY),
+ false,
+ pointerSpeedObserver,
+ )
+ applyPointerSpeedFromSettings()
+ }
+
+ private fun unregisterPointerSpeedObserver() {
+ contentResolver.unregisterContentObserver(pointerSpeedObserver)
+ }
+
+ private fun applyPointerSpeedFromSettings() {
+ val speed = readPointerSpeed()
+ injector?.updatePointerSpeed(speed)
+ Log.d(TAG, "Pointer speed updated to $speed")
}
private fun observeState() = scope.launch {
@@ -146,6 +200,7 @@ class ConnectionService : Service() {
}
userInitiatedDisconnect = false
+ infoAckPending = false
activeServerIp = serverIp
activeScreenName = screenName
val generation = ++connectGeneration
@@ -298,20 +353,46 @@ class ConnectionService : Service() {
if (generation != connectGeneration) return@collect
when (event) {
is InputLeapEvent.Enter -> {
+ // Also cancels a pending HID-mouse idle detach: the mouse is
+ // usually still registered from the last visit, so this Enter is
+ // an ordinary delta from a position we still know.
+ cancelLeaveDebounce()
+ pointerOnScreen = true
+ Log.i(TAG, "Enter ${event.x},${event.y}")
stateMachine.onActive()
stateMachine.onKeepAlive()
- if (mouseEnabled) showCursorOverlay()
+ injector?.updatePointerSpeed(readPointerSpeed())
+ injector?.onHidMouseEnter(event.x, event.y)
+ applyCursorOverlay()
+ setHidKeyboardAttached(keyboardEnabled)
+ setHidMouseAttached(mouseEnabled)
}
is InputLeapEvent.Leave -> {
- stateMachine.onLeave()
- hideCursorOverlay()
+ injector?.onHidMouseLeave()
+ scheduleLeave(generation)
}
is InputLeapEvent.KeepAlive -> {
stateMachine.onKeepAlive()
conn.sendKeepAlive()
}
+ is InputLeapEvent.InfoAck -> {
+ infoAckPending = false
+ }
+ is InputLeapEvent.QueryInfo -> {
+ connection?.let {
+ if (generation == connectGeneration) {
+ it.sendDataInfo(
+ screenWidth,
+ screenHeight,
+ currentMouseX.toInt(),
+ currentMouseY.toInt(),
+ )
+ }
+ }
+ }
is InputLeapEvent.MouseMoveAbs -> {
if (!mouseEnabled) return@collect
+ if (infoAckPending) return@collect
stateMachine.onKeepAlive()
currentMouseX = event.x.toFloat()
currentMouseY = event.y.toFloat()
@@ -320,6 +401,7 @@ class ConnectionService : Service() {
}
is InputLeapEvent.MouseMoveRel -> {
if (!mouseEnabled) return@collect
+ if (infoAckPending) return@collect
stateMachine.onKeepAlive()
currentMouseX = (currentMouseX + event.dx).coerceIn(0f, screenWidth.toFloat())
currentMouseY = (currentMouseY + event.dy).coerceIn(0f, screenHeight.toFloat())
@@ -338,8 +420,12 @@ class ConnectionService : Service() {
}
is InputLeapEvent.Unhandled -> if (event.tag == "__DISCONNECTED__") {
if (generation != connectGeneration || userInitiatedDisconnect) return@collect
+ cancelLeaveDebounce()
+ pointerOnScreen = false
stateMachine.onDisconnected()
- hideCursorOverlay()
+ applyCursorOverlay()
+ setHidKeyboardAttached(false)
+ setHidMouseAttached(false)
restorePreviousIme()
scheduleRetry(ip, screenName, generation)
}
@@ -354,26 +440,118 @@ class ConnectionService : Service() {
fun setCursorOverlayEnabled(enabled: Boolean) {
cursorOverlayEnabled = enabled
- if (enabled && stateMachine.state.value is ConnectionState.Active) {
+ applyCursorOverlay()
+ }
+
+ private fun applyCursorOverlay() {
+ val inj = injector
+ val show = CursorOverlayPolicy.shouldShowOverlay(
+ cursorSettingEnabled = cursorOverlayEnabled,
+ onScreen = pointerOnScreen,
+ mouseEnabled = mouseEnabled,
+ native = inj?.nativePointerState() ?: NativePointerState.NONE,
+ expectsNativePointer = inj?.expectsNativePointer() == true,
+ )
+ if (show) {
showCursorOverlay()
- } else if (!enabled) {
+ } else {
hideCursorOverlay()
}
}
private fun showCursorOverlay() {
- if (!cursorOverlayEnabled) return
- if (!Settings.canDrawOverlays(this)) {
- Log.w(TAG, "Cannot draw overlays - permission not granted")
+ if (!canShowCursor()) {
+ Log.w(TAG, "Cannot show cursor — no overlay permission and accessibility is off")
return
}
CursorOverlayService.show()
}
+ private fun canShowCursor(): Boolean =
+ Settings.canDrawOverlays(this) ||
+ AccessibilityInputService.isServiceRunning() ||
+ isAccessibilityEnabled()
+
+ private fun isAccessibilityEnabled(): Boolean {
+ val enabled = Settings.Secure.getString(
+ contentResolver,
+ Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
+ ) ?: return false
+ val component = android.content.ComponentName(this, AccessibilityInputService::class.java)
+ return enabled.contains(component.flattenToShortString()) ||
+ enabled.contains(component.flattenToString())
+ }
+
private fun hideCursorOverlay() {
CursorOverlayService.hide()
}
+ private fun cancelLeaveDebounce() {
+ leaveDebounceJob?.cancel()
+ leaveDebounceJob = null
+ hidMouseIdleJob?.cancel()
+ hidMouseIdleJob = null
+ }
+
+ private fun scheduleLeave(generation: Int) {
+ leaveDebounceJob?.cancel()
+ leaveDebounceJob = scope.launch {
+ delay(LEAVE_DEBOUNCE_MS)
+ if (generation != connectGeneration) return@launch
+ Log.i(TAG, "Leave")
+ pointerOnScreen = false
+ stateMachine.onLeave()
+ applyCursorOverlay()
+ // The keyboard must go: while it is registered Android believes a physical
+ // keyboard is attached and keeps the soft keyboard suppressed.
+ setHidKeyboardAttached(false)
+ scheduleHidMouseIdleDetach(generation)
+ leaveDebounceJob = null
+ }
+ }
+
+ /**
+ * Keep the HID mouse registered across a Leave.
+ *
+ * Destroying it means the next Enter creates a new device, and AOSP seeds a new
+ * pointer at display centre. Warping away from that seed is a race against AOSP's
+ * own asynchronous initialisation, and one that cannot be won reliably -- there is
+ * no signal for "the seed has landed". Keeping the device sidesteps the race
+ * entirely: the pointer does not move while the cursor is away, so the position
+ * model stays true and Enter becomes an ordinary delta.
+ *
+ * The device is still dropped once the cursor has been away long enough that a
+ * parked pointer is more annoying than paying for a re-create.
+ */
+ private fun scheduleHidMouseIdleDetach(generation: Int) {
+ hidMouseIdleJob?.cancel()
+ hidMouseIdleJob = scope.launch {
+ delay(HID_MOUSE_IDLE_DETACH_MS)
+ if (generation != connectGeneration || pointerOnScreen) return@launch
+ Log.i(TAG, "HID mouse idle ${HID_MOUSE_IDLE_DETACH_MS}ms; detaching")
+ setHidMouseAttached(false)
+ hidMouseIdleJob = null
+ }
+ }
+
+ private fun setHidKeyboardAttached(attached: Boolean) {
+ hidKeyboardGate.setWanted(attached)
+ scope.launch(Dispatchers.IO) {
+ hidKeyboardGate.applyLatest { wanted ->
+ injector?.setHidKeyboardAttached(wanted)
+ }
+ }
+ }
+
+ private fun setHidMouseAttached(attached: Boolean) {
+ hidMouseGate.setWanted(attached)
+ scope.launch(Dispatchers.IO) {
+ hidMouseGate.applyLatest { wanted ->
+ injector?.setHidMouseAttached(wanted)
+ }
+ }
+ }
+
private fun updateCursorPosition(x: Float, y: Float) {
if (!cursorOverlayEnabled) return
CursorOverlayService.updatePosition(x, y)
@@ -381,6 +559,7 @@ class ConnectionService : Service() {
fun setInjector(injector: com.inputleaf.android.inject.InputInjector) {
if (this.injector != null && this.injector != injector) {
+ this.injector?.setOnNativePointerStateChanged(null)
this.injector?.disconnect()
if (this.injector is com.inputleaf.android.inject.AccessibilityInputInjector &&
injector !is com.inputleaf.android.inject.AccessibilityInputInjector
@@ -389,14 +568,30 @@ class ConnectionService : Service() {
}
}
this.injector = injector
+ hidKeyboardGate.noteInjectorChanged()
+ hidMouseGate.noteInjectorChanged()
+ // The service's bounds are authoritative (proven by DINF); application-context
+ // WindowManager metrics can disagree on some OEMs (seen: portrait from app
+ // context while landscape on ColorOS).
+ injector.updateScreenSize(screenWidth, screenHeight)
+ injector.updatePointerSpeed(readPointerSpeed())
+ injector.setOnNativePointerStateChanged {
+ scope.launch { applyCursorOverlay() }
+ }
if (injector is ShizukuInputInjector) {
injector.onServiceDisconnectedCallback = {
handleShizukuServiceDisconnected()
}
}
+ if (pointerOnScreen) {
+ setHidKeyboardAttached(keyboardEnabled)
+ setHidMouseAttached(mouseEnabled)
+ }
Log.i(TAG, "Input injector set to: ${injector.name}")
}
+ private fun readPointerSpeed(): Int = readSystemPointerSpeed(contentResolver)
+
private fun dispatchInput(event: InputLeapEvent) {
injector?.send(event)
}
@@ -410,8 +605,12 @@ class ConnectionService : Service() {
if (stateMachine.onKeepAliveMiss()) {
Log.w(TAG, "Keep-alive timeout — disconnecting")
conn.close()
+ cancelLeaveDebounce()
+ pointerOnScreen = false
stateMachine.onDisconnected()
- hideCursorOverlay()
+ applyCursorOverlay()
+ setHidKeyboardAttached(false)
+ setHidMouseAttached(false)
restorePreviousIme()
break
}
@@ -422,7 +621,8 @@ class ConnectionService : Service() {
private fun scheduleRetry(ip: String, screenName: String, generation: Int) {
if (userInitiatedDisconnect || generation != connectGeneration) return
retryJob?.cancel()
- val delayMs = RetryDelayCalculator.getDelay(retryAttempt++)
+ val delayMs = RetryDelayCalculator.getDelay(retryAttempt)
+ retryAttempt++
retryJob = scope.launch {
delay(delayMs)
if (userInitiatedDisconnect || generation != connectGeneration) return@launch
@@ -433,6 +633,7 @@ class ConnectionService : Service() {
private fun cancelPendingJobs(keepConnection: Boolean) {
retryJob?.cancel()
retryJob = null
+ cancelLeaveDebounce()
eventLoopJob?.cancel()
eventLoopJob = null
connectJob?.cancel()
@@ -452,14 +653,19 @@ class ConnectionService : Service() {
fun disconnect() {
userInitiatedDisconnect = true
+ infoAckPending = false
clearActiveSession()
shizukuRecoveryJob?.cancel()
shizukuRecoveryJob = null
connectGeneration++
cancelPendingJobs(keepConnection = false)
+ pointerOnScreen = false
+ setHidKeyboardAttached(false)
+ setHidMouseAttached(false)
+ injector?.setOnNativePointerStateChanged(null)
injector?.disconnect()
injector = null
- hideCursorOverlay()
+ applyCursorOverlay()
restorePreviousIme()
stateMachine.onDisconnected()
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -471,6 +677,28 @@ class ConnectionService : Service() {
return START_STICKY
}
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ val bounds = getScreenBounds()
+ val w = bounds.width()
+ val h = bounds.height()
+ if (w == screenWidth && h == screenHeight) return
+ Log.i(TAG, "Screen bounds changed ${screenWidth}x$screenHeight -> ${w}x$h")
+ screenWidth = w
+ screenHeight = h
+ currentMouseX = currentMouseX.coerceIn(0f, w.toFloat())
+ currentMouseY = currentMouseY.coerceIn(0f, h.toFloat())
+ injector?.updateScreenSize(w, h)
+ val connected = connection != null && stateMachine.state.value.let {
+ it is ConnectionState.Idle || it is ConnectionState.Active
+ }
+ if (connected) {
+ infoAckPending = true
+ connection?.sendDataInfo(w, h, currentMouseX.toInt(), currentMouseY.toInt())
+ Log.i(TAG, "Sent DINF update ${w}x${h}")
+ }
+ }
+
@Suppress("DEPRECATION")
private fun getScreenBounds(): Rect {
val wm = getSystemService(WindowManager::class.java)
@@ -586,6 +814,7 @@ class ConnectionService : Service() {
}
override fun onDestroy() {
+ unregisterPointerSpeedObserver()
try {
Shizuku.removeBinderReceivedListener(shizukuBinderReceivedListener)
Shizuku.removeBinderDeadListener(shizukuBinderDeadListener)
@@ -597,10 +826,74 @@ class ConnectionService : Service() {
connectGeneration++
cancelPendingJobs(keepConnection = false)
scope.cancel()
+ injector?.setOnNativePointerStateChanged(null)
injector?.disconnect()
- hideCursorOverlay()
+ pointerOnScreen = false
+ applyCursorOverlay()
restorePreviousIme()
stopService(Intent(this, CursorOverlayService::class.java))
super.onDestroy()
}
}
+
+internal enum class ConnectAttemptOutcome {
+ Success,
+ Retrying,
+ Rejected,
+ TerminalFailure,
+}
+
+internal fun shouldClearActiveSession(outcome: ConnectAttemptOutcome): Boolean =
+ outcome == ConnectAttemptOutcome.Rejected ||
+ outcome == ConnectAttemptOutcome.TerminalFailure
+
+class ConnectionStateMachine {
+ private val _state = MutableStateFlow(ConnectionState.Disconnected)
+ val state: StateFlow = _state
+
+ @Volatile private var keepAliveMissed = 0
+
+ fun onConnecting(ip: String) { _state.value = ConnectionState.Connecting(ip) }
+
+ fun onHandshaking(ip: String) { _state.value = ConnectionState.Handshaking(ip) }
+
+ fun onIdle(ip: String, serverName: String) {
+ keepAliveMissed = 0
+ _state.value = ConnectionState.Idle(ip, serverName)
+ }
+
+ fun onActive() {
+ val current = _state.value
+ if (current is ConnectionState.Active) {
+ println("StateMachine: Duplicate kMsgCEnter received — ignoring")
+ return
+ }
+ val (ip, name) = when (current) {
+ is ConnectionState.Idle -> current.serverIp to current.serverName
+ else -> {
+ println("StateMachine: kMsgCEnter received in unexpected state: $current — ignoring")
+ return
+ }
+ }
+ _state.value = ConnectionState.Active(ip, name)
+ }
+
+ fun onLeave() {
+ val current = _state.value as? ConnectionState.Active ?: return
+ _state.value = ConnectionState.Idle(current.serverIp, current.serverName)
+ }
+
+ fun onKeepAlive() { keepAliveMissed = 0 }
+
+ fun onKeepAliveMiss(): Boolean {
+ keepAliveMissed++
+ return keepAliveMissed >= 4
+ }
+
+ fun onDisconnected() {
+ keepAliveMissed = 0
+ _state.value = ConnectionState.Disconnected
+ }
+}
+
+
diff --git a/app/src/main/java/com/inputleaf/android/service/ConnectionStateMachine.kt b/app/src/main/java/com/inputleaf/android/service/ConnectionStateMachine.kt
deleted file mode 100644
index 84583bd1..00000000
--- a/app/src/main/java/com/inputleaf/android/service/ConnectionStateMachine.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package com.inputleaf.android.service
-
-import com.inputleaf.android.model.ConnectionState
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-
-class ConnectionStateMachine {
- private val _state = MutableStateFlow(ConnectionState.Disconnected)
- val state: StateFlow = _state
-
- @Volatile private var keepAliveMissed = 0
-
- fun onConnecting(ip: String) { _state.value = ConnectionState.Connecting(ip) }
-
- fun onHandshaking(ip: String) { _state.value = ConnectionState.Handshaking(ip) }
-
- fun onIdle(ip: String, serverName: String) {
- keepAliveMissed = 0
- _state.value = ConnectionState.Idle(ip, serverName)
- }
-
- fun onActive() {
- val current = _state.value
- if (current is ConnectionState.Active) {
- println("StateMachine: Duplicate kMsgCEnter received — ignoring")
- return
- }
- val (ip, name) = when (current) {
- is ConnectionState.Idle -> current.serverIp to current.serverName
- else -> {
- println("StateMachine: kMsgCEnter received in unexpected state: $current — ignoring")
- return
- }
- }
- _state.value = ConnectionState.Active(ip, name)
- }
-
- fun onLeave() {
- val current = _state.value as? ConnectionState.Active ?: return
- _state.value = ConnectionState.Idle(current.serverIp, current.serverName)
- }
-
- fun onKeepAlive() { keepAliveMissed = 0 }
-
- fun onKeepAliveMiss(): Boolean {
- keepAliveMissed++
- return keepAliveMissed >= 4
- }
-
- fun onDisconnected() {
- keepAliveMissed = 0
- _state.value = ConnectionState.Disconnected
- }
-}
diff --git a/app/src/main/java/com/inputleaf/android/service/CursorOverlayPolicy.kt b/app/src/main/java/com/inputleaf/android/service/CursorOverlayPolicy.kt
new file mode 100644
index 00000000..a0d793be
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/service/CursorOverlayPolicy.kt
@@ -0,0 +1,25 @@
+package com.inputleaf.android.service
+
+import com.inputleaf.android.inject.NativePointerState
+
+/**
+ * Deterministic overlay vs native-pointer arbitration. Pure function so Enter/Leave
+ * and async attach callbacks cannot disagree.
+ */
+internal object CursorOverlayPolicy {
+
+ fun shouldShowOverlay(
+ cursorSettingEnabled: Boolean,
+ onScreen: Boolean,
+ mouseEnabled: Boolean,
+ native: NativePointerState,
+ expectsNativePointer: Boolean,
+ ): Boolean {
+ if (!onScreen || !cursorSettingEnabled || !mouseEnabled) return false
+ return when (native) {
+ NativePointerState.PENDING, NativePointerState.ACTIVE -> false
+ NativePointerState.FALLBACK -> true
+ NativePointerState.NONE -> !expectsNativePointer
+ }
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/service/CursorOverlayService.kt b/app/src/main/java/com/inputleaf/android/service/CursorOverlayService.kt
index 99865495..d0105f6f 100644
--- a/app/src/main/java/com/inputleaf/android/service/CursorOverlayService.kt
+++ b/app/src/main/java/com/inputleaf/android/service/CursorOverlayService.kt
@@ -151,6 +151,8 @@ class CursorOverlayService : Service() {
if (_isVisible.value) {
Log.d(TAG, "isVisible was true on create, showing cursor")
showCursorInternal()
+ } else {
+ ensureCursorView(visible = false)
}
}
@@ -179,69 +181,67 @@ class CursorOverlayService : Service() {
}
private fun showCursorInternal() {
- Log.d(TAG, "showCursorInternal() called - isShowing=$isShowing, canDrawOverlays=${Settings.canDrawOverlays(this)}")
- if (isShowing || !Settings.canDrawOverlays(this)) return
-
- val resId = if (currentCursorStyle == "leaf") {
- com.inputleaf.android.R.drawable.cursor
- } else {
- com.inputleaf.android.R.drawable.ic_cursor_aosp
- }
- val scaleXValue = if (currentCursorStyle == "leaf") -1f else 1f
- val view = android.widget.ImageView(this).apply {
- setImageResource(resId)
- scaleX = scaleXValue
- }
- cursorView = view
-
- val params = WindowManager.LayoutParams(
- CURSOR_SIZE,
- CURSOR_SIZE,
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ ensureCursorView(visible = true)
+ }
+
+ private fun ensureCursorView(visible: Boolean) {
+ Log.d(TAG, "ensureCursorView visible=$visible isShowing=$isShowing canDrawOverlays=${Settings.canDrawOverlays(this)}")
+ if (cursorView == null) {
+ if (!Settings.canDrawOverlays(this)) {
+ isShowing = false
+ return
+ }
+ val resId = if (currentCursorStyle == "leaf") {
+ com.inputleaf.android.R.drawable.cursor
} else {
- @Suppress("DEPRECATION")
- WindowManager.LayoutParams.TYPE_PHONE
- },
- WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
- WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
- WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
- WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
- PixelFormat.TRANSLUCENT
- ).apply {
- gravity = Gravity.TOP or Gravity.START
- x = _cursorX.value.toInt() - CURSOR_SIZE / 2
- y = _cursorY.value.toInt() - CURSOR_SIZE / 2
- }
-
- try {
- windowManager?.addView(cursorView, params)
- isShowing = true
- Log.d(TAG, "Cursor overlay added to window manager successfully")
- } catch (e: Exception) {
- Log.e(TAG, "Failed to add cursor overlay", e)
- e.printStackTrace()
+ com.inputleaf.android.R.drawable.ic_cursor_aosp
+ }
+ val scaleXValue = if (currentCursorStyle == "leaf") -1f else 1f
+ val view = android.widget.ImageView(this).apply {
+ setImageResource(resId)
+ scaleX = scaleXValue
+ visibility = android.view.View.INVISIBLE
+ }
+ cursorView = view
+ val params = WindowManager.LayoutParams(
+ CURSOR_SIZE,
+ CURSOR_SIZE,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ } else {
+ @Suppress("DEPRECATION")
+ WindowManager.LayoutParams.TYPE_PHONE
+ },
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
+ WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.TOP or Gravity.START
+ x = _cursorX.value.toInt() - CURSOR_SIZE / 2
+ y = _cursorY.value.toInt() - CURSOR_SIZE / 2
+ }
+ try {
+ windowManager?.addView(view, params)
+ Log.d(TAG, "Cursor overlay added to window manager successfully")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to add cursor overlay", e)
+ cursorView = null
+ return
+ }
}
+ cursorView?.visibility = if (visible) android.view.View.VISIBLE else android.view.View.INVISIBLE
+ isShowing = visible
}
-
+
private fun hideCursorInternal() {
Log.d(TAG, "hideCursorInternal() called - isShowing=$isShowing")
- if (!isShowing) return
- try {
- cursorView?.let { view ->
- windowManager?.removeView(view)
- }
- cursorView = null
- isShowing = false
- Log.d(TAG, "Cursor overlay hidden")
- } catch (e: Exception) {
- Log.e(TAG, "Failed to hide cursor overlay", e)
- e.printStackTrace()
- }
+ cursorView?.visibility = android.view.View.INVISIBLE
+ isShowing = false
}
private fun moveCursorInternal(x: Float, y: Float) {
- if (!isShowing) return
cursorView?.let { view ->
val params = view.layoutParams as? WindowManager.LayoutParams ?: return
params.x = x.toInt() - CURSOR_SIZE / 2
diff --git a/app/src/main/java/com/inputleaf/android/service/HidAttachmentController.kt b/app/src/main/java/com/inputleaf/android/service/HidAttachmentController.kt
new file mode 100644
index 00000000..e5b76201
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/service/HidAttachmentController.kt
@@ -0,0 +1,42 @@
+package com.inputleaf.android.service
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/**
+ * Last-write-wins HID attach/detach gate.
+ *
+ * [wanted] is the latest UI/session intent. A generation token drains stale binder
+ * calls so rapid Leave→Enter cannot finish in the wrong order, and injector
+ * replacement always re-applies even when the wanted flag is unchanged.
+ */
+internal class HidAttachmentController {
+ private val mutex = Mutex()
+
+ @Volatile
+ private var wanted = false
+
+ @Volatile
+ private var generation = 0
+
+ fun wanted(): Boolean = wanted
+
+ fun setWanted(attached: Boolean) {
+ wanted = attached
+ generation++
+ }
+
+ fun noteInjectorChanged() {
+ generation++
+ }
+
+ suspend fun applyLatest(apply: (Boolean) -> Unit) {
+ mutex.withLock {
+ while (true) {
+ val seen = generation
+ apply(wanted)
+ if (seen == generation) return
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/service/PointerSpeed.kt b/app/src/main/java/com/inputleaf/android/service/PointerSpeed.kt
new file mode 100644
index 00000000..14f36301
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/service/PointerSpeed.kt
@@ -0,0 +1,18 @@
+package com.inputleaf.android.service
+
+import android.content.ContentResolver
+import android.provider.Settings
+
+/** Android pointer speed setting range (Settings → Pointer speed). */
+internal const val POINTER_SPEED_MIN = -7
+internal const val POINTER_SPEED_MAX = 7
+
+/** [Settings.System.POINTER_SPEED] is @TestApi; use the stable string at compileSdk 34. */
+internal const val SETTINGS_POINTER_SPEED_KEY = "pointer_speed"
+
+internal fun clampPointerSpeed(speed: Int): Int = speed.coerceIn(POINTER_SPEED_MIN, POINTER_SPEED_MAX)
+
+internal fun readSystemPointerSpeed(contentResolver: ContentResolver): Int =
+ clampPointerSpeed(
+ Settings.System.getInt(contentResolver, SETTINGS_POINTER_SPEED_KEY, 0),
+ )
diff --git a/app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt b/app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt
index f58a7422..469559ca 100644
--- a/app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt
+++ b/app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt
@@ -1,10 +1,16 @@
package com.inputleaf.android.service
-object RetryDelayCalculator {
+/**
+ * Reconnect backoff, per attempt.
+ *
+ * Kept as a standalone object rather than inlined into [ConnectionService] so the
+ * table and its boundaries stay reachable from the JVM suite: logic inside a Service
+ * method is unmeasurable, so a patch-coverage gate passes over it without asserting
+ * anything.
+ */
+internal object RetryDelayCalculator {
private val DELAYS = listOf(1_000L, 2_000L, 5_000L, 10_000L, 30_000L)
- fun getDelay(attempt: Int): Long {
- if (attempt < 0) return DELAYS[0]
- return DELAYS[minOf(attempt, DELAYS.lastIndex)]
- }
+ /** Clamps both ends: a negative attempt takes the first delay, overflow the last. */
+ fun getDelay(attempt: Int): Long = DELAYS[attempt.coerceIn(0, DELAYS.lastIndex)]
}
diff --git a/app/src/main/java/com/inputleaf/android/service/ShizukuActiveSessionPolicy.kt b/app/src/main/java/com/inputleaf/android/service/ShizukuActiveSessionPolicy.kt
deleted file mode 100644
index 0a23ff10..00000000
--- a/app/src/main/java/com/inputleaf/android/service/ShizukuActiveSessionPolicy.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.inputleaf.android.service
-
-internal enum class ConnectAttemptOutcome {
- Success,
- Retrying,
- Rejected,
- TerminalFailure,
-}
-
-internal fun shouldClearActiveSession(outcome: ConnectAttemptOutcome): Boolean =
- outcome == ConnectAttemptOutcome.Rejected ||
- outcome == ConnectAttemptOutcome.TerminalFailure
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt b/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt
index 90614a41..d2560b38 100644
--- a/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt
+++ b/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt
@@ -4,6 +4,10 @@ import android.os.SystemClock
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
+import com.inputleaf.android.shizuku.uhid.HidKeyboard
+import com.inputleaf.android.shizuku.uhid.HidMouse
+import com.inputleaf.android.shizuku.uhid.HidMouseEnterWarp
+import com.inputleaf.android.shizuku.uhid.UhidChannel
/**
* Shizuku UserService that runs with shell (ADB) privileges.
@@ -12,7 +16,16 @@ import android.view.MotionEvent
*
* This class is instantiated by Shizuku in a separate process with elevated privileges.
*/
-class InputInjectorService : IInputInjector.Stub() {
+class InputInjectorService : IInputInjector.Stub {
+
+ /** Seam so the UHID lifecycle can be exercised without a real `/dev/uhid`. */
+ private val openChannel: () -> UhidChannel?
+
+ constructor() : this({ UhidChannel.openHandle() })
+
+ internal constructor(openChannel: () -> UhidChannel?) {
+ this.openChannel = openChannel
+ }
private val inputManager: HiddenInputManager.Target? =
try {
@@ -23,6 +36,13 @@ class InputInjectorService : IInputInjector.Stub() {
}
companion object {
+ private const val KEYBOARD_DEVICE_NAME = "Input Leaf Keyboard HID"
+ private const val MOUSE_DEVICE_NAME = "Input Leaf Mouse HID"
+ private const val VENDOR_ID = 0x1209
+ private const val PRODUCT_KEYBOARD = 0x0001
+ private const val PRODUCT_MOUSE = 0x0002
+ private const val UNIQ_KEYBOARD = "inputleaf-kbd"
+ private const val UNIQ_MOUSE = "inputleaf-mouse"
// Injection mode: async (don't wait for injection to complete)
private const val INJECT_INPUT_EVENT_MODE_ASYNC = 0
// Wait until the system reports whether text injection was accepted.
@@ -184,8 +204,181 @@ class InputInjectorService : IInputInjector.Stub() {
false
}
}
+
+ private val keyboardLock = Any()
+ private val mouseLock = Any()
+ private var uhidKeyboardChannel: UhidChannel? = null
+ private var keyboard: HidKeyboard? = null
+ private var uhidMouseChannel: UhidChannel? = null
+ private var mouse: HidMouse? = null
+ private val mouseEnterWarp = HidMouseEnterWarp()
+ /** Guarded by [mouseLock]. True only while an [openVirtualMouse] that created the device warped. */
+ private var lastOpenWarpApplied = false
+
+ override fun openVirtualKeyboard(): Boolean = synchronized(keyboardLock) {
+ if (keyboard != null) return true
+ val channel = openChannel() ?: return false
+ return try {
+ val startedAt = android.os.SystemClock.uptimeMillis()
+ channel.createDevice(
+ KEYBOARD_DEVICE_NAME,
+ HidKeyboard.DESCRIPTOR,
+ vendor = VENDOR_ID,
+ product = PRODUCT_KEYBOARD,
+ uniq = UNIQ_KEYBOARD,
+ )
+ uhidKeyboardChannel = channel
+ keyboard = HidKeyboard(channel)
+ android.util.Log.i(
+ "InputInjectorService",
+ "HID keyboard connected in ${android.os.SystemClock.uptimeMillis() - startedAt}ms pid=${android.os.Process.myPid()}",
+ )
+ true
+ } catch (e: Exception) {
+ android.util.Log.w("InputInjectorService", "HID keyboard create failed", e)
+ runCatching { channel.close() }
+ false
+ }
+ }
+
+ override fun closeVirtualKeyboard() {
+ synchronized(keyboardLock) {
+ runCatching { keyboard?.releaseAll() }
+ runCatching { uhidKeyboardChannel?.close() }
+ uhidKeyboardChannel = null
+ keyboard = null
+ }
+ android.util.Log.i("InputInjectorService", "HID keyboard disconnected")
+ }
+
+ override fun injectHidKey(evdevCode: Int, isDown: Boolean): Boolean =
+ synchronized(keyboardLock) { keyboard }?.key(evdevCode, isDown) ?: false
+
+ override fun releaseHidKeys() {
+ synchronized(keyboardLock) { keyboard }?.releaseAll()
+ }
+
+ override fun openVirtualMouse(): Boolean = synchronized(mouseLock) {
+ if (mouse != null) {
+ // No CREATE2, so no warp was emitted; the client must send its own snap.
+ lastOpenWarpApplied = false
+ android.util.Log.i("InputInjectorService", "HID mouse already open (idempotent)")
+ return true
+ }
+ val channel = openChannel() ?: return false
+ return try {
+ val startedAt = android.os.SystemClock.uptimeMillis()
+ channel.createDevice(
+ MOUSE_DEVICE_NAME,
+ HidMouse.DESCRIPTOR,
+ vendor = VENDOR_ID,
+ product = PRODUCT_MOUSE,
+ uniq = UNIQ_MOUSE,
+ )
+ uhidMouseChannel = channel
+ val hidMouse = HidMouse(channel)
+ mouse = hidMouse
+ val pending = mouseEnterWarp.pending
+ val plans = mouseEnterWarp.applyIfPending(hidMouse)
+ lastOpenWarpApplied = pending != null && mouseEnterWarp.pending == null
+ android.util.Log.i(
+ "InputInjectorService",
+ "HID mouse connected in ${android.os.SystemClock.uptimeMillis() - startedAt}ms pid=${android.os.Process.myPid()}",
+ )
+ if (pending != null) {
+ android.util.Log.i(
+ "InputInjectorService",
+ "HID mouse enter warp from ${(pending.maxX) / 2},${(pending.maxY) / 2} " +
+ "to ${pending.x},${pending.y} speed=${pending.pointerSpeed} " +
+ "${if (lastOpenWarpApplied) "applied after UHID ready" else "kept pending"} " +
+ "hidReports=${plans.count { !it.isNoOp }}",
+ )
+ }
+ true
+ } catch (e: Exception) {
+ android.util.Log.w("InputInjectorService", "HID mouse create failed", e)
+ lastOpenWarpApplied = false
+ runCatching { channel.close() }
+ false
+ }
+ }
+
+ override fun closeVirtualMouse() {
+ synchronized(mouseLock) {
+ runCatching { mouse?.releaseAll() }
+ runCatching { uhidMouseChannel?.close() }
+ uhidMouseChannel = null
+ mouse = null
+ }
+ android.util.Log.i("InputInjectorService", "HID mouse disconnected")
+ }
+
+ override fun injectHidMouse(dx: Int, dy: Int, buttons: Int, wheel: Int): Boolean =
+ synchronized(mouseLock) { mouse }?.move(dx, dy, buttons, wheel) ?: false
+ override fun onHidMouseEnter(x: Int, y: Int, maxX: Int, maxY: Int, pointerSpeed: Int) {
+ synchronized(mouseLock) {
+ mouseEnterWarp.onEnter(x, y, maxX, maxY, pointerSpeed)
+ android.util.Log.i(
+ "InputInjectorService",
+ "HID mouse enter stored $x,$y max=$maxX,$maxY speed=$pointerSpeed mouseOpen=${mouse != null}",
+ )
+ }
+ }
+
+ override fun onHidMouseLeave() {
+ synchronized(mouseLock) { mouseEnterWarp.onLeave() }
+ }
+
+ override fun consumeEnterWarpApplied(): Boolean = synchronized(mouseLock) {
+ val applied = lastOpenWarpApplied
+ lastOpenWarpApplied = false
+ applied
+ }
+
+ private val clientLock = Any()
+ private var clientToken: android.os.IBinder? = null
+
+ /**
+ * Destroy the UHID devices from inside the process that owns the `/dev/uhid` fds
+ * when the client goes away, instead of relying on this process being reaped.
+ *
+ * The explicit teardown in the client's disconnect() cannot help here: once the
+ * client is gone those binder calls throw DeadObjectException and are swallowed, so
+ * no UHID_DESTROY is ever written and the devices stay attached for as long as this
+ * process lingers -- which on some OEM Shizuku builds is a long time.
+ */
+ private val clientDeathRecipient = android.os.IBinder.DeathRecipient {
+ android.util.Log.w("InputInjectorService", "Client died; destroying UHID devices")
+ closeVirtualKeyboard()
+ closeVirtualMouse()
+ }
+
+ override fun attachClient(token: android.os.IBinder?) {
+ if (token == null) return
+ synchronized(clientLock) {
+ runCatching { clientToken?.unlinkToDeath(clientDeathRecipient, 0) }
+ clientToken = token
+ // A token that is already dead throws here rather than calling back, so the
+ // devices have to be torn down inline.
+ val linked = runCatching { token.linkToDeath(clientDeathRecipient, 0) }.isSuccess
+ if (!linked) {
+ clientToken = null
+ android.util.Log.w("InputInjectorService", "Client token already dead at attach")
+ closeVirtualKeyboard()
+ closeVirtualMouse()
+ }
+ }
+ }
+
override fun destroy() {
- // Nothing to clean up
+ synchronized(clientLock) {
+ runCatching { clientToken?.unlinkToDeath(clientDeathRecipient, 0) }
+ clientToken = null
+ }
+ closeVirtualKeyboard()
+ closeVirtualMouse()
}
+
+ internal fun deathRecipientForTest(): android.os.IBinder.DeathRecipient = clientDeathRecipient
}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt
index 30e52c63..d9b2d032 100644
--- a/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt
+++ b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt
@@ -6,10 +6,12 @@ import android.content.pm.PackageManager
import android.os.DeadObjectException
import android.os.IBinder
import android.os.RemoteException
+import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import android.view.MotionEvent
import com.inputleaf.android.inject.InputInjector
+import com.inputleaf.android.inject.NativePointerState
import com.inputleaf.android.inject.InputLeafIME
import com.inputleaf.android.inject.KeyMapUtils
import com.inputleaf.android.inject.KeysymAction
@@ -17,57 +19,73 @@ import com.inputleaf.android.inject.KeysymInjection
import com.inputleaf.android.inject.KeysymResolver
import com.inputleaf.android.inject.ProtocolScanCodeDecoder
import com.inputleaf.android.model.InputLeapEvent
+import com.inputleaf.android.shizuku.uhid.HidMouseState
+import com.inputleaf.android.shizuku.uhid.MouseEdgeAnchor
+import com.inputleaf.android.shizuku.uhid.WheelNotchAccumulator
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import rikka.shizuku.Shizuku
private const val TAG = "ShizukuInputInjector"
+// Bumped for the attachClient and Enter-warp AIDL additions: a cached older UserService
+// does not implement them and would throw on every bind.
+private const val SERVICE_VERSION = 6
-/**
- * Wrapper for Shizuku-based input injection.
- * Handles binding to the privileged InputInjectorService and translating
- * InputLeap events to Android input events.
- */
class ShizukuInputInjector(
- private val screenWidth: Int,
- private val screenHeight: Int
+ screenWidth: Int,
+ screenHeight: Int,
) : InputInjector {
override val name: String = "Shizuku (ADB-level injection)"
-
+
+ private var screenWidth = screenWidth
+ private var screenHeight = screenHeight
+ private val hidMouse = HidMouseState(pointerMaxX(), pointerMaxY())
+
var onServiceDisconnectedCallback: (() -> Unit)? = null
+ /** Owned by this process, so the injector's death watch fires exactly when we die. */
+ private val clientToken = android.os.Binder()
+
private var service: IInputInjector? = null
private var isBound = false
private var connectDeferred: CompletableDeferred? = null
-
- // Track absolute mouse position (InputLeap sends absolute coords,
- // but we may need to synthesize relative movements)
+
private var mouseX = 0f
private var mouseY = 0f
-
- // Track button state for proper motion event sequencing
private var buttonState = 0
-
- // Modifier key state (for meta state in key events)
+
+ private val wheelNotches = WheelNotchAccumulator()
+ @Volatile private var pointerSpeed = 0
+ /** True until the first successful attach after [setHidMouseAttached(false)] or disconnect. */
+ private var clientClosedMouse = true
+ @Volatile private var currentNativePointerState: NativePointerState = NativePointerState.NONE
+ @Volatile private var nativePointerListener: ((NativePointerState) -> Unit)? = null
+
private var metaState = 0
private val scanCodeDecoder = ProtocolScanCodeDecoder()
-
+
private val serviceArgs = Shizuku.UserServiceArgs(
ComponentName(
"com.inputleaf.android",
- InputInjectorService::class.java.name
- )
- ).daemon(false).processNameSuffix("input_injector")
-
+ InputInjectorService::class.java.name,
+ ),
+ ).daemon(false).processNameSuffix("input_injector").version(SERVICE_VERSION)
+
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
Log.d(TAG, "Shizuku service connected")
- service = IInputInjector.Stub.asInterface(binder)
+ val injector = IInputInjector.Stub.asInterface(binder)
+ // Best-effort: an injector that cannot watch us still works, it just falls
+ // back to process reaping for UHID cleanup.
+ runCatching { injector.attachClient(clientToken) }
+ .onFailure { Log.w(TAG, "Could not register client death watch", it) }
+ service = injector
isBound = true
connectDeferred?.complete(true)
}
-
+
override fun onServiceDisconnected(name: ComponentName?) {
Log.d(TAG, "Shizuku service disconnected")
notifyDisconnected()
@@ -75,31 +93,50 @@ class ShizukuInputInjector(
}
}
+ private fun pointerMaxX(): Int = (screenWidth - 1).coerceAtLeast(0)
+ private fun pointerMaxY(): Int = (screenHeight - 1).coerceAtLeast(0)
+
+ override fun updateScreenSize(width: Int, height: Int) {
+ if (width == screenWidth && height == screenHeight) return
+ Log.i(TAG, "Screen size updated ${screenWidth}x$screenHeight -> ${width}x$height")
+ screenWidth = width
+ screenHeight = height
+ hidMouse.resizeDisplay(pointerMaxX(), pointerMaxY())
+ }
+
+ override fun updatePointerSpeed(speed: Int) {
+ val clamped = speed.coerceIn(-7, 7)
+ if (clamped == pointerSpeed) return
+ pointerSpeed = clamped
+ }
+
private fun notifyDisconnected() {
val wasActive = isBound || service != null
service = null
isBound = false
+ hidMouse.resetOnDisconnect()
+ wheelNotches.reset()
+ clientClosedMouse = true
+ publishNativePointerState(NativePointerState.NONE)
if (wasActive) {
onServiceDisconnectedCallback?.invoke()
}
}
-
- /**
- * Check if Shizuku is available and we have permission.
- */
+
+ private fun publishNativePointerState(state: NativePointerState) {
+ currentNativePointerState = state
+ nativePointerListener?.invoke(state)
+ }
+
override fun isAvailable(): Boolean {
return try {
- Shizuku.pingBinder() &&
+ Shizuku.pingBinder() &&
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
} catch (e: Exception) {
false
}
}
-
- /**
- * Bind to the Shizuku service. Must be called before sending events.
- * @return true if binding was initiated successfully
- */
+
override suspend fun connect(): Boolean {
if (!isAvailable()) {
Log.e(TAG, "Shizuku not available or permission not granted")
@@ -108,15 +145,27 @@ class ShizukuInputInjector(
if (isBound && service != null) {
return true
}
-
+
+ repeat(3) { attempt ->
+ if (bindOnce()) return true
+ Log.w(TAG, "Shizuku bind attempt ${attempt + 1}/3 failed")
+ runCatching {
+ Shizuku.unbindUserService(serviceArgs, serviceConnection, true)
+ }
+ isBound = false
+ service = null
+ delay(400)
+ if (!isAvailable()) return false
+ }
+ return false
+ }
+
+ private suspend fun bindOnce(): Boolean {
val deferred = CompletableDeferred()
connectDeferred = deferred
-
return try {
Shizuku.bindUserService(serviceArgs, serviceConnection)
- withTimeout(5000) {
- deferred.await()
- }
+ withTimeout(10_000) { deferred.await() }
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "Shizuku service bind timeout")
false
@@ -127,13 +176,14 @@ class ShizukuInputInjector(
connectDeferred = null
}
}
-
- /**
- * Unbind from the Shizuku service.
- */
+
override fun disconnect() {
if (isBound || service != null) {
try {
+ runCatching { service?.releaseHidKeys() }
+ runCatching { service?.closeVirtualKeyboard() }
+ runCatching { service?.closeVirtualMouse() }
+ hidMouse.detach()
service?.destroy()
Shizuku.unbindUserService(serviceArgs, serviceConnection, true)
} catch (e: Exception) {
@@ -141,100 +191,349 @@ class ShizukuInputInjector(
}
service = null
isBound = false
+ hidMouse.resetOnDisconnect()
+ wheelNotches.reset()
+ clientClosedMouse = true
+ publishNativePointerState(NativePointerState.NONE)
}
}
-
+
+ override fun setHidKeyboardAttached(attached: Boolean) {
+ val svc = service ?: return
+ try {
+ if (attached) {
+ if (svc.openVirtualKeyboard()) {
+ Log.i(TAG, "HID keyboard attached")
+ } else {
+ Log.w(TAG, "HID keyboard unavailable; keys use injectKeyEvent")
+ }
+ } else {
+ svc.releaseHidKeys()
+ svc.closeVirtualKeyboard()
+ }
+ } catch (e: DeadObjectException) {
+ Log.w(TAG, "Shizuku service binder is dead", e)
+ notifyDisconnected()
+ } catch (e: Exception) {
+ handleRemoteException(e, "Failed to update HID keyboard attachment")
+ }
+ }
+
+ override fun setHidMouseAttached(attached: Boolean) {
+ val svc = service ?: return
+ if (!attached) {
+ try {
+ svc.closeVirtualMouse()
+ hidMouse.detach()
+ wheelNotches.reset()
+ clientClosedMouse = true
+ publishNativePointerState(NativePointerState.NONE)
+ Log.i(TAG, "HID mouse detached")
+ } catch (e: DeadObjectException) {
+ Log.w(TAG, "Shizuku service binder is dead", e)
+ notifyDisconnected()
+ } catch (e: Exception) {
+ handleRemoteException(e, "Failed to detach HID mouse")
+ }
+ return
+ }
+
+ if (hidMouse.attached) {
+ publishNativePointerState(NativePointerState.ACTIVE)
+ try {
+ finishPendingSnap(svc)
+ } catch (e: DeadObjectException) {
+ Log.w(TAG, "Shizuku service binder is dead", e)
+ notifyDisconnected()
+ } catch (e: Exception) {
+ handleRemoteException(e, "Failed to snap HID mouse")
+ }
+ return
+ }
+ if (hidMouse.phase == HidMouseState.AttachPhase.ATTACHING) {
+ return
+ }
+
+ val newDevice = clientClosedMouse
+ hidMouse.beginAttach()
+ publishNativePointerState(NativePointerState.PENDING)
+ try {
+ if (svc.openVirtualMouse()) {
+ hidMouse.completeAttach(newDevice = newDevice)
+ clientClosedMouse = false
+ publishNativePointerState(NativePointerState.ACTIVE)
+ // Only the injector knows whether this call created the device and
+ // emitted the warp; inferring it from app state strands the cursor.
+ val daemonWarped = runCatching { svc.consumeEnterWarpApplied() }.getOrDefault(false)
+ Log.i(TAG, "HID mouse attached (newDevice=$newDevice daemonWarped=$daemonWarped)")
+ finishPendingSnap(svc, sendHid = !daemonWarped)
+ } else {
+ hidMouse.markUnusable()
+ hidMouse.detach()
+ publishNativePointerState(NativePointerState.FALLBACK)
+ Log.w(TAG, "HID mouse unavailable; pointer uses injectMotionEvent")
+ }
+ } catch (e: DeadObjectException) {
+ Log.w(TAG, "Shizuku service binder is dead", e)
+ hidMouse.detach()
+ notifyDisconnected()
+ } catch (e: Exception) {
+ hidMouse.detach()
+ publishNativePointerState(NativePointerState.FALLBACK)
+ handleRemoteException(e, "Failed to attach HID mouse")
+ }
+ }
+
+ override fun usesNativePointer(): Boolean =
+ isBound && hidMouse.attached && hidMouse.usable
+
+ override fun nativePointerState(): NativePointerState = currentNativePointerState
+
+ override fun expectsNativePointer(): Boolean = isBound
+
+ override fun setOnNativePointerStateChanged(listener: ((NativePointerState) -> Unit)?) {
+ nativePointerListener = listener
+ listener?.invoke(currentNativePointerState)
+ }
+
+ override fun onHidMouseEnter(x: Int, y: Int) {
+ hidMouse.onEnter(x, y)
+ Log.i(
+ TAG,
+ "HID mouse enter $x,$y phase=${hidMouse.phase} cooked=${hidMouse.cookedX},${hidMouse.cookedY}",
+ )
+ val svc = service ?: return
+ // Hand the coords to the injector so a CREATE2 can warp there itself. Warping
+ // from here loses the race against AOSP seeding the new pointer at centre.
+ try {
+ svc.onHidMouseEnter(x, y, pointerMaxX(), pointerMaxY(), pointerSpeed)
+ if (hidMouse.attached) {
+ finishPendingSnap(svc)
+ }
+ } catch (e: DeadObjectException) {
+ Log.w(TAG, "Shizuku service binder is dead", e)
+ notifyDisconnected()
+ } catch (e: Exception) {
+ handleRemoteException(e, "Failed to store HID mouse enter on injector")
+ }
+ }
+
+ override fun onHidMouseLeave() {
+ hidMouse.markLeave()
+ try {
+ service?.onHidMouseLeave()
+ } catch (e: DeadObjectException) {
+ Log.w(TAG, "Shizuku service binder is dead", e)
+ notifyDisconnected()
+ } catch (e: Exception) {
+ handleRemoteException(e, "Failed to clear HID mouse enter on injector")
+ }
+ }
+
/**
- * Send an InputLeap event to be injected.
+ * @param sendHid false when the injector already emitted this warp itself, so the
+ * cooked model advances without replaying the reports and doubling the movement.
*/
+ private fun finishPendingSnap(svc: IInputInjector, sendHid: Boolean = true) {
+ val target = hidMouse.peekPendingSnap() ?: return
+ hidMouse.updatePointerTarget(target.first, target.second)
+ Log.i(
+ TAG,
+ "HID mouse snap to ${target.first},${target.second} from ${hidMouse.cookedX},${hidMouse.cookedY} " +
+ "centerSeed=${hidMouse.needsCenterSeed()} sendHid=$sendHid",
+ )
+ val now = SystemClock.uptimeMillis()
+ val plans = MouseEdgeAnchor.planSnap(hidMouse.plannerInput(now), pointerSpeed)
+ for (plan in plans) {
+ if (plan.isNoOp) continue
+ if (sendHid) {
+ val sent = svc.injectHidMouse(plan.hidX, plan.hidY, hidMouse.buttons(), 0)
+ if (!sent) return
+ }
+ hidMouse.applySuccessfulPlan(plan, now)
+ }
+ val (errX, errY) = hidMouse.errorToTarget(target.first, target.second)
+ val stillEdge = MouseEdgeAnchor.needsEdgePulse(
+ hidMouse.edgeFlags,
+ target.first,
+ target.second,
+ hidMouse.maxXExclusive(),
+ hidMouse.maxYExclusive(),
+ )
+ if (errX == 0 && errY == 0 && !stillEdge) {
+ hidMouse.consumePendingSnap()
+ }
+ }
+
+ fun tryHidKey(evdevCode: Int, isDown: Boolean): Boolean {
+ val svc = service ?: return false
+ if (evdevCode == 0) return false
+ return try {
+ svc.injectHidKey(evdevCode, isDown)
+ } catch (e: DeadObjectException) {
+ Log.w(TAG, "Shizuku service binder is dead", e)
+ notifyDisconnected()
+ false
+ } catch (e: Exception) {
+ handleRemoteException(e, null)
+ false
+ }
+ }
+
+ fun tryHidMouse(event: InputLeapEvent): Boolean {
+ if (hidMouse.phase == HidMouseState.AttachPhase.ATTACHING) {
+ return when (event) {
+ is InputLeapEvent.MouseMoveAbs ->
+ hidMouse.queueTargetWhileAttaching(event.x, event.y)
+ is InputLeapEvent.MouseMoveRel -> {
+ val tx = hidMouse.clampX(hidMouse.protocolX + event.dx)
+ val ty = hidMouse.clampY(hidMouse.protocolY + event.dy)
+ hidMouse.queueTargetWhileAttaching(tx, ty)
+ }
+ else -> true
+ }
+ }
+ if (!hidMouse.attached) return false
+ val svc = service ?: return false
+ return try {
+ when (event) {
+ is InputLeapEvent.MouseMoveAbs ->
+ moveHidToProtocolTarget(
+ hidMouse.clampX(event.x),
+ hidMouse.clampY(event.y),
+ svc,
+ )
+ is InputLeapEvent.MouseMoveRel -> {
+ val tx = hidMouse.clampX(hidMouse.protocolX + event.dx)
+ val ty = hidMouse.clampY(hidMouse.protocolY + event.dy)
+ moveHidToProtocolTarget(tx, ty, svc)
+ }
+ is InputLeapEvent.MouseDown -> {
+ val buttons = hidMouse.buttons() or inputLeapButtonToHid(event.buttonId)
+ hidMouse.setButtons(buttons)
+ svc.injectHidMouse(0, 0, buttons, 0)
+ }
+ is InputLeapEvent.MouseUp -> {
+ val buttons = hidMouse.buttons() and inputLeapButtonToHid(event.buttonId).inv()
+ hidMouse.setButtons(buttons)
+ svc.injectHidMouse(0, 0, buttons, 0)
+ }
+ is InputLeapEvent.MouseWheel -> {
+ val notches = wheelNotches.accept(event.yDelta)
+ // Handled either way: a partial notch is banked, not passed to the
+ // fallback path, which would scroll it a second time.
+ if (notches == 0) true else svc.injectHidMouse(0, 0, hidMouse.buttons(), notches)
+ }
+ else -> false
+ }
+ } catch (e: DeadObjectException) {
+ Log.w(TAG, "Shizuku service binder is dead", e)
+ notifyDisconnected()
+ false
+ } catch (e: Exception) {
+ handleRemoteException(e, null)
+ false
+ }
+ }
+
+ private fun moveHidToProtocolTarget(targetX: Int, targetY: Int, svc: IInputInjector): Boolean {
+ hidMouse.updatePointerTarget(targetX, targetY)
+ val now = SystemClock.uptimeMillis()
+ val plan = MouseEdgeAnchor.plan(hidMouse.plannerInput(now), pointerSpeed)
+ if (plan.isNoOp) return true
+ val sent = svc.injectHidMouse(plan.hidX, plan.hidY, hidMouse.buttons(), 0)
+ if (sent) {
+ hidMouse.applySuccessfulPlan(plan, now)
+ }
+ return sent
+ }
+
override fun send(event: InputLeapEvent) {
val svc = service ?: return
-
+
try {
when (event) {
is InputLeapEvent.MouseMoveAbs -> {
mouseX = event.x.toFloat().coerceIn(0f, screenWidth.toFloat())
mouseY = event.y.toFloat().coerceIn(0f, screenHeight.toFloat())
-
- // Determine action based on button state
- val action = if (buttonState != 0) {
- MotionEvent.ACTION_MOVE
- } else {
- MotionEvent.ACTION_HOVER_MOVE
+ if (!tryHidMouse(event)) {
+ val action = if (buttonState != 0) {
+ MotionEvent.ACTION_MOVE
+ } else {
+ MotionEvent.ACTION_HOVER_MOVE
+ }
+ svc.injectMotionEvent(action, mouseX, mouseY, buttonState)
}
- svc.injectMotionEvent(action, mouseX, mouseY, buttonState)
}
-
+
is InputLeapEvent.MouseMoveRel -> {
mouseX = (mouseX + event.dx).coerceIn(0f, screenWidth.toFloat())
mouseY = (mouseY + event.dy).coerceIn(0f, screenHeight.toFloat())
-
- val action = if (buttonState != 0) {
- MotionEvent.ACTION_MOVE
- } else {
- MotionEvent.ACTION_HOVER_MOVE
+ if (!tryHidMouse(event)) {
+ val action = if (buttonState != 0) {
+ MotionEvent.ACTION_MOVE
+ } else {
+ MotionEvent.ACTION_HOVER_MOVE
+ }
+ svc.injectMotionEvent(action, mouseX, mouseY, buttonState)
}
- svc.injectMotionEvent(action, mouseX, mouseY, buttonState)
}
-
+
is InputLeapEvent.MouseDown -> {
- val button = inputLeapButtonToAndroid(event.buttonId)
- buttonState = buttonState or button
- svc.injectMotionEvent(MotionEvent.ACTION_DOWN, mouseX, mouseY, buttonState)
+ if (!tryHidMouse(event)) {
+ val button = inputLeapButtonToAndroid(event.buttonId)
+ buttonState = buttonState or button
+ svc.injectMotionEvent(MotionEvent.ACTION_DOWN, mouseX, mouseY, buttonState)
+ }
}
-
+
is InputLeapEvent.MouseUp -> {
- val button = inputLeapButtonToAndroid(event.buttonId)
- buttonState = buttonState and button.inv()
- svc.injectMotionEvent(MotionEvent.ACTION_UP, mouseX, mouseY, buttonState)
+ if (!tryHidMouse(event)) {
+ val button = inputLeapButtonToAndroid(event.buttonId)
+ buttonState = buttonState and button.inv()
+ svc.injectMotionEvent(MotionEvent.ACTION_UP, mouseX, mouseY, buttonState)
+ }
}
-
+
is InputLeapEvent.MouseWheel -> {
- // InputLeap sends 120 units per notch, Android expects -1 to 1
- val vScroll = event.yDelta / 120f
- val hScroll = event.xDelta / 120f
- svc.injectScrollEvent(mouseX, mouseY, hScroll, vScroll)
+ if (!tryHidMouse(event)) {
+ val vScroll = event.yDelta / 120f
+ val hScroll = event.xDelta / 120f
+ svc.injectScrollEvent(mouseX, mouseY, hScroll, vScroll)
+ }
}
-
+
is InputLeapEvent.KeyDown -> {
- Log.d(
- TAG,
- "KeyDown: keysym=0x${event.keyId.toString(16)} mask=${event.mask} " +
- "button=${event.scancode}",
- )
handleKeyEvent(svc, event.keyId, event.mask, event.scancode, isDown = true)
}
-
+
is InputLeapEvent.KeyUp -> {
- Log.d(
- TAG,
- "KeyUp: keysym=0x${event.keyId.toString(16)} mask=${event.mask} " +
- "button=${event.scancode}",
- )
handleKeyEvent(svc, event.keyId, event.mask, event.scancode, isDown = false)
}
-
+
is InputLeapEvent.KeyRepeat -> {
handleKeyRepeat(svc, event.keyId, event.mask, event.scancode, event.count)
}
-
- else -> {
- // Ignore non-input events
- }
+
+ else -> Unit
}
} catch (e: DeadObjectException) {
Log.w(TAG, "Shizuku service binder is dead", e)
notifyDisconnected()
} catch (e: Exception) {
- if (e is RemoteException || e.cause is DeadObjectException || e.cause is RemoteException) {
- Log.w(TAG, "Shizuku service remote exception / dead binder", e)
- notifyDisconnected()
- } else {
- Log.e(TAG, "Failed to inject event", e)
- }
+ handleRemoteException(e, "Failed to inject event")
}
}
-
+
+ private fun handleRemoteException(e: Exception, message: String?) {
+ if (e is RemoteException || e.cause is DeadObjectException || e.cause is RemoteException) {
+ Log.w(TAG, message ?: "Shizuku service remote exception / dead binder", e)
+ notifyDisconnected()
+ } else if (message != null) {
+ Log.w(TAG, message, e)
+ }
+ }
+
private fun handleKeyEvent(
svc: IInputInjector,
keysym: Int,
@@ -243,6 +542,9 @@ class ShizukuInputInjector(
isDown: Boolean,
) {
val scancode = scanCodeDecoder.toEvdev(button, keysym)
+ if (scancode != 0 && svc.injectHidKey(scancode, isDown)) {
+ return
+ }
val shortcutModifiers = KeyMapUtils.hasShortcutModifiers(metaState) ||
KeyMapUtils.protocolMaskHasShortcuts(mask)
val injectionMeta = metaState or KeyMapUtils.androidMetaFromProtocolMask(mask)
@@ -253,7 +555,6 @@ class ShizukuInputInjector(
shortcutModifiers = shortcutModifiers,
)) {
is KeysymAction.KeyEventAction -> {
- Log.d(TAG, "Mapped to Android keyCode: ${resolved.keyCode} evdev=$scancode")
KeysymInjection.applyKeyEventAction(
action = resolved,
isDown = isDown,
@@ -273,14 +574,7 @@ class ShizukuInputInjector(
injectPhysicalFallback(svc, scancode, isDown, injectionMeta)
}
}
- is KeysymAction.Ignore -> {
- if (isDown) {
- Log.w(
- TAG,
- "Ignoring key id=0x${keysym.toString(16)} button=$button evdev=$scancode",
- )
- }
- }
+ is KeysymAction.Ignore -> Unit
}
}
@@ -292,6 +586,9 @@ class ShizukuInputInjector(
count: Int,
) {
val scancode = scanCodeDecoder.toEvdev(button, keysym)
+ if (scancode != 0 && svc.injectHidKey(scancode, true)) {
+ return
+ }
val shortcutModifiers = KeyMapUtils.hasShortcutModifiers(metaState) ||
KeyMapUtils.protocolMaskHasShortcuts(mask)
val injectionMeta = metaState or KeyMapUtils.androidMetaFromProtocolMask(mask)
@@ -328,7 +625,6 @@ class ShizukuInputInjector(
if (svc.injectText(char)) return true
val ime = InputLeafIME.getInstance()
if (ime != null) {
- Log.w(TAG, "Shizuku injectText failed for '$char'; falling back to IME commitText")
ime.commitText(char)
return true
}
@@ -344,20 +640,25 @@ class ShizukuInputInjector(
) {
val keyCode = KeyMapUtils.scancodeToAndroidKeyCode(scancode)
if (keyCode == KeyEvent.KEYCODE_UNKNOWN) return
- Log.w(TAG, "Falling back to physical keyCode=$keyCode evdev=$scancode")
val action = if (isDown) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP
svc.injectKeyEvent(action, keyCode, scancode, metaState)
}
private fun inputLeapButtonToAndroid(buttonId: Int): Int {
- // InputLeap button IDs: 1=left, 2=middle, 3=right
return when (buttonId) {
1 -> MotionEvent.BUTTON_PRIMARY
- 2 -> MotionEvent.BUTTON_TERTIARY // middle
- 3 -> MotionEvent.BUTTON_SECONDARY // right
+ 2 -> MotionEvent.BUTTON_TERTIARY
+ 3 -> MotionEvent.BUTTON_SECONDARY
else -> 0
}
}
-
-}
+ private fun inputLeapButtonToHid(buttonId: Int): Int {
+ return when (buttonId) {
+ 1 -> 1
+ 3 -> 2
+ 2 -> 4
+ else -> 0
+ }
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/EvdevToHid.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/EvdevToHid.kt
new file mode 100644
index 00000000..7cf81e4f
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/EvdevToHid.kt
@@ -0,0 +1,108 @@
+package com.inputleaf.android.shizuku.uhid
+
+/**
+ * Maps Linux evdev key codes to USB HID keyboard usage codes.
+ *
+ * The InputLeap protocol already carries evdev scancodes and
+ * [com.inputleaf.android.protocol.ProtocolScanCodeDecoder] decodes them, so this is the
+ * only translation needed — no keysym table in between.
+ *
+ * Deliberately broader than the old `uhid-server` `KeysymToHid`, which held roughly 80
+ * entries with no punctuation, symbols or numpad, and silently dropped everything else.
+ */
+internal object EvdevToHid {
+
+ /** HID modifier bits, in report byte 0. */
+ private val MODIFIER_BITS = mapOf(
+ 29 to 0x01, // KEY_LEFTCTRL
+ 42 to 0x02, // KEY_LEFTSHIFT
+ 56 to 0x04, // KEY_LEFTALT
+ 125 to 0x08, // KEY_LEFTMETA
+ 97 to 0x10, // KEY_RIGHTCTRL
+ 54 to 0x20, // KEY_RIGHTSHIFT
+ 100 to 0x40, // KEY_RIGHTALT
+ 126 to 0x80, // KEY_RIGHTMETA
+ )
+
+ private val USAGES: Map = buildMap {
+ // Letters: evdev order is qwerty, HID order is alphabetical.
+ val letterRows = listOf(
+ 16 to "qwertyuiop",
+ 30 to "asdfghjkl",
+ 44 to "zxcvbnm",
+ )
+ for ((base, row) in letterRows) {
+ row.forEachIndexed { index, letter ->
+ put(base + index, 0x04 + (letter - 'a'))
+ }
+ }
+
+ // Digit row: evdev 2..11 is 1234567890; HID 0x1E..0x26 is 1..9 with 0 at 0x27.
+ for (digit in 1..9) put(1 + digit, 0x1D + digit)
+ put(11, 0x27) // KEY_0
+
+ putAll(
+ mapOf(
+ 1 to 0x29, // ESC
+ 12 to 0x2D, // MINUS
+ 13 to 0x2E, // EQUAL
+ 14 to 0x2A, // BACKSPACE
+ 15 to 0x2B, // TAB
+ 26 to 0x2F, // LEFTBRACE
+ 27 to 0x30, // RIGHTBRACE
+ 28 to 0x28, // ENTER
+ 39 to 0x33, // SEMICOLON
+ 40 to 0x34, // APOSTROPHE
+ 41 to 0x35, // GRAVE
+ 43 to 0x31, // BACKSLASH
+ 51 to 0x36, // COMMA
+ 52 to 0x37, // DOT
+ 53 to 0x38, // SLASH
+ 57 to 0x2C, // SPACE
+ 58 to 0x39, // CAPSLOCK
+ // Navigation
+ 99 to 0x46, // SYSRQ / PrintScreen
+ 70 to 0x47, // SCROLLLOCK
+ 119 to 0x48, // PAUSE
+ 110 to 0x49, // INSERT
+ 102 to 0x4A, // HOME
+ 104 to 0x4B, // PAGEUP
+ 111 to 0x4C, // DELETE
+ 107 to 0x4D, // END
+ 109 to 0x4E, // PAGEDOWN
+ 106 to 0x4F, // RIGHT
+ 105 to 0x50, // LEFT
+ 108 to 0x51, // DOWN
+ 103 to 0x52, // UP
+ 127 to 0x65, // COMPOSE / Application
+ // Keypad
+ 69 to 0x53, // NUMLOCK
+ 98 to 0x54, // KPSLASH
+ 55 to 0x55, // KPASTERISK
+ 74 to 0x56, // KPMINUS
+ 78 to 0x57, // KPPLUS
+ 96 to 0x58, // KPENTER
+ 83 to 0x63, // KPDOT
+ 82 to 0x62, // KP0
+ )
+ )
+
+ // Keypad 1-9: evdev order is 7 8 9 / 4 5 6 / 1 2 3; HID 0x59..0x61 is 1..9.
+ val keypadRows = listOf(71 to 7, 75 to 4, 79 to 1)
+ for ((base, firstDigit) in keypadRows) {
+ for (offset in 0..2) put(base + offset, 0x59 + (firstDigit + offset - 1))
+ }
+
+ // F1-F10 are contiguous in both; F11/F12 sit elsewhere in evdev.
+ for (index in 0..9) put(59 + index, 0x3A + index)
+ put(87, 0x44) // F11
+ put(88, 0x45) // F12
+ }
+
+ fun isModifier(evdevCode: Int): Boolean = MODIFIER_BITS.containsKey(evdevCode)
+
+ fun modifierBit(evdevCode: Int): Int = MODIFIER_BITS[evdevCode] ?: 0
+
+ /** HID usage for [evdevCode], or null when unmapped. */
+ fun usage(evdevCode: Int): Int? = USAGES[evdevCode]
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidKeyboard.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidKeyboard.kt
new file mode 100644
index 00000000..3b8bd2ef
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidKeyboard.kt
@@ -0,0 +1,86 @@
+package com.inputleaf.android.shizuku.uhid
+
+/**
+ * A standard HID boot keyboard.
+ *
+ * Registering a real keyboard is what makes Android treat incoming keys as hardware
+ * input. Two consequences follow, both wanted: the platform stops showing the soft
+ * keyboard (giving the screen back), and the user's own IME stays selected, so its emoji
+ * and GIF pickers remain available once summoned.
+ *
+ * Report is the 8-byte boot format: modifier bitmask, a reserved byte, then six key
+ * slots. Six is the hardware limit for this format (6KRO); further simultaneous keys are
+ * dropped rather than corrupting the report.
+ */
+internal class HidKeyboard(private val sink: HidReportSink) {
+
+ private var modifiers = 0
+ private val pressed = mutableListOf()
+
+ fun key(evdevCode: Int, isDown: Boolean): Boolean {
+ if (EvdevToHid.isModifier(evdevCode)) {
+ val bit = EvdevToHid.modifierBit(evdevCode)
+ modifiers = if (isDown) modifiers or bit else modifiers and bit.inv()
+ sendReport()
+ return true
+ }
+
+ val usage = EvdevToHid.usage(evdevCode) ?: return false
+ if (isDown) {
+ if (usage in pressed) return true
+ if (pressed.size >= MAX_KEYS) return false
+ pressed += usage
+ } else {
+ pressed.remove(usage)
+ }
+ sendReport()
+ return true
+ }
+
+ /** Releases everything, so a disconnect mid-keypress cannot leave a key stuck down. */
+ fun releaseAll() {
+ if (modifiers == 0 && pressed.isEmpty()) return
+ modifiers = 0
+ pressed.clear()
+ sendReport()
+ }
+
+ private fun sendReport() {
+ val report = ByteArray(REPORT_SIZE)
+ report[0] = modifiers.toByte()
+ pressed.forEachIndexed { index, usage -> report[2 + index] = usage.toByte() }
+ sink.sendReport(report)
+ }
+
+ companion object {
+ const val REPORT_SIZE = 8
+ const val MAX_KEYS = 6
+
+ /** Report: modifiers(8) | reserved(8) | six key slots(8 each). */
+ val DESCRIPTOR: ByteArray = byteArrayOf(
+ 0x05, 0x01, // Usage Page (Generic Desktop)
+ 0x09, 0x06, // Usage (Keyboard)
+ 0xA1.toByte(), 0x01, // Collection (Application)
+ 0x05, 0x07, // Usage Page (Keyboard/Keypad)
+ 0x19, 0xE0.toByte(), // Usage Minimum (Left Control)
+ 0x29, 0xE7.toByte(), // Usage Maximum (Right GUI)
+ 0x15, 0x00, // Logical Minimum (0)
+ 0x25, 0x01, // Logical Maximum (1)
+ 0x75, 0x01, // Report Size (1)
+ 0x95.toByte(), 0x08, // Report Count (8)
+ 0x81.toByte(), 0x02, // Input (Data,Var,Abs) - modifiers
+ 0x95.toByte(), 0x01, // Report Count (1)
+ 0x75, 0x08, // Report Size (8)
+ 0x81.toByte(), 0x03, // Input (Cnst,Var,Abs) - reserved
+ 0x95.toByte(), 0x06, // Report Count (6)
+ 0x75, 0x08, // Report Size (8)
+ 0x15, 0x00, // Logical Minimum (0)
+ 0x26, 0xFF.toByte(), 0x00, // Logical Maximum (255)
+ 0x05, 0x07, // Usage Page (Keyboard/Keypad)
+ 0x19, 0x00, // Usage Minimum (0)
+ 0x2A, 0xFF.toByte(), 0x00, // Usage Maximum (255)
+ 0x81.toByte(), 0x00, // Input (Data,Ary,Abs) - key slots
+ 0xC0.toByte(), // End Collection
+ )
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouse.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouse.kt
new file mode 100644
index 00000000..3b18ecbe
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouse.kt
@@ -0,0 +1,84 @@
+package com.inputleaf.android.shizuku.uhid
+
+/**
+ * A standard HID relative mouse.
+ *
+ * Report is six bytes: button bitmask, 16-bit relative X and Y, then an 8-bit
+ * relative wheel. Buttons are absolute in each report — there is no separate
+ * held-button state beyond what the caller sends.
+ */
+internal class HidMouse(private val sink: HidReportSink) {
+
+ fun move(dx: Int, dy: Int, buttons: Int, wheel: Int): Boolean {
+ return sendReport(
+ buttons = buttons and BUTTON_MASK,
+ dx = dx.coerceIn(MIN_DELTA, MAX_DELTA),
+ dy = dy.coerceIn(MIN_DELTA, MAX_DELTA),
+ wheel = wheel.coerceIn(MIN_WHEEL, MAX_WHEEL),
+ )
+ }
+
+ /** Releases every button so a disconnect cannot leave one held down. */
+ fun releaseAll() {
+ sendReport(buttons = 0, dx = 0, dy = 0, wheel = 0)
+ }
+
+ private fun sendReport(buttons: Int, dx: Int, dy: Int, wheel: Int): Boolean {
+ val report = ByteArray(REPORT_SIZE)
+ report[0] = buttons.toByte()
+ report[1] = (dx and 0xFF).toByte()
+ report[2] = ((dx shr 8) and 0xFF).toByte()
+ report[3] = (dy and 0xFF).toByte()
+ report[4] = ((dy shr 8) and 0xFF).toByte()
+ report[5] = wheel.toByte()
+ return sink.sendReport(report)
+ }
+
+ companion object {
+ const val REPORT_SIZE = 6
+ const val BUTTON_LEFT = 0x01
+ const val BUTTON_RIGHT = 0x02
+ const val BUTTON_MIDDLE = 0x04
+ const val BUTTON_MASK = BUTTON_LEFT or BUTTON_RIGHT or BUTTON_MIDDLE
+
+ const val MIN_DELTA = -32767
+ const val MAX_DELTA = 32767
+ private const val MIN_WHEEL = -127
+ private const val MAX_WHEEL = 127
+
+ val DESCRIPTOR: ByteArray = byteArrayOf(
+ 0x05, 0x01,
+ 0x09, 0x02,
+ 0xA1.toByte(), 0x01,
+ 0x09, 0x01,
+ 0xA1.toByte(), 0x00,
+ 0x05, 0x09,
+ 0x19, 0x01,
+ 0x29, 0x03,
+ 0x15, 0x00,
+ 0x25, 0x01,
+ 0x95.toByte(), 0x03,
+ 0x75, 0x01,
+ 0x81.toByte(), 0x02,
+ 0x95.toByte(), 0x01,
+ 0x75, 0x05,
+ 0x81.toByte(), 0x03,
+ 0x05, 0x01,
+ 0x09, 0x30,
+ 0x09, 0x31,
+ 0x16, 0x01, 0x80.toByte(),
+ 0x26, 0xFF.toByte(), 0x7F,
+ 0x75, 0x10,
+ 0x95.toByte(), 0x02,
+ 0x81.toByte(), 0x06,
+ 0x09, 0x38,
+ 0x15, 0x81.toByte(),
+ 0x25, 0x7F,
+ 0x75, 0x08,
+ 0x95.toByte(), 0x01,
+ 0x81.toByte(), 0x06,
+ 0xC0.toByte(),
+ 0xC0.toByte(),
+ )
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouseEnterWarp.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouseEnterWarp.kt
new file mode 100644
index 00000000..5825586b
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouseEnterWarp.kt
@@ -0,0 +1,67 @@
+package com.inputleaf.android.shizuku.uhid
+
+/**
+ * Enter compensation that must run on the process that owns the UHID fd.
+ *
+ * AOSP [PointerController] seeds a new mouse at display center. The Shizuku-era
+ * mapper ([HidMouseState] + [MouseEdgeAnchor.planSnap]) warps from that center to
+ * the last InputLeap Enter. Live DMMV tracking stays in the app process; this
+ * only stores Enter until CREATE2 is START-ready, then emits the same snap.
+ */
+internal class HidMouseEnterWarp(
+ private val nowMs: () -> Long = { System.currentTimeMillis() },
+) {
+ data class Pending(
+ val x: Int,
+ val y: Int,
+ val maxX: Int,
+ val maxY: Int,
+ val pointerSpeed: Int,
+ )
+
+ @Volatile
+ var pending: Pending? = null
+ private set
+
+ fun onEnter(x: Int, y: Int, maxX: Int, maxY: Int, pointerSpeed: Int) {
+ val width = maxX.coerceAtLeast(0)
+ val height = maxY.coerceAtLeast(0)
+ pending = Pending(
+ x = x.coerceIn(0, width),
+ y = y.coerceIn(0, height),
+ maxX = width,
+ maxY = height,
+ pointerSpeed = pointerSpeed,
+ )
+ }
+
+ fun onLeave() {
+ pending = null
+ }
+
+ /**
+ * Warp a newly CREATE2'd mouse from AOSP center to [pending].
+ *
+ * Cooked origin is always display center, never the stored Enter. Seeding
+ * cooked at (x,y) makes [MouseEdgeAnchor.planSnap] a no-op and leaves the
+ * native sprite at center. Keeps Enter coords if a HID write fails.
+ */
+ fun applyIfPending(mouse: HidMouse): List {
+ val target = pending ?: return emptyList()
+ val state = HidMouseState(target.maxX, target.maxY)
+ state.completeAttach(newDevice = true)
+ state.onEnter(target.x, target.y)
+ val now = nowMs()
+ val input = state.plannerInput(now)
+ val plans = MouseEdgeAnchor.planSnap(input, target.pointerSpeed)
+ for (plan in plans) {
+ if (plan.isNoOp) continue
+ if (!mouse.move(plan.hidX, plan.hidY, 0, 0)) {
+ return plans
+ }
+ state.applySuccessfulPlan(plan, now)
+ }
+ pending = null
+ return plans
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouseState.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouseState.kt
new file mode 100644
index 00000000..1a46e031
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouseState.kt
@@ -0,0 +1,314 @@
+package com.inputleaf.android.shizuku.uhid
+
+/**
+ * Serialized HID mouse attach/target state. Thread-safe for nonblocking pending updates;
+ * callers must not hold this lock across Binder I/O.
+ */
+internal class HidMouseState(
+ private var maxX: Int,
+ private var maxY: Int,
+) {
+ enum class AttachPhase {
+ DETACHED,
+ ATTACHING,
+ ATTACHED,
+ }
+
+ private val lock = Any()
+
+ var phase: AttachPhase = AttachPhase.DETACHED
+ private set
+
+ /** True only after a successful attach; false while attaching or after detach/failure. */
+ var attached: Boolean = false
+ private set
+
+ var usable: Boolean = true
+ private set
+
+ var buttons: Int = 0
+ private set
+
+ /** Protocol-authoritative target from InputLeap. */
+ var protocolX: Int = 0
+ private set
+ var protocolY: Int = 0
+ private set
+
+ /** Open-loop estimate of Android's cooked pointer position. */
+ var cookedX: Int = centerX()
+ private set
+ var cookedY: Int = centerY()
+ private set
+
+ /** Latest enter/DMMV target retained while attach is in flight. */
+ var pendingSnapX: Int? = null
+ private set
+ var pendingSnapY: Int? = null
+ private set
+
+ /**
+ * Monotonic generation of [protocolX]/[protocolY] stores. [markLeave] snapshots this
+ * so a later detach cannot drop an Enter/DMMV that arrived after Leave was received.
+ */
+ private var targetEpoch: Int = 0
+ private var leaveEpoch: Int = -1
+
+ /** True when the next snap should assume AOSP seeded the pointer at display center. */
+ var needsCenterSeed: Boolean = false
+ private set
+
+ var lastHidMoveAt: Long = 0L
+ private set
+
+ var edgeFlags: MouseEdgeAnchor.Flags = MouseEdgeAnchor.Flags()
+ private set
+
+ var lastWasEdgePulse: Boolean = false
+ private set
+
+ fun maxXExclusive(): Int = maxX
+ fun maxYExclusive(): Int = maxY
+
+ fun centerX(): Int = maxX / 2
+ fun centerY(): Int = maxY / 2
+
+ fun clampX(x: Int): Int = x.coerceIn(0, maxX)
+ fun clampY(y: Int): Int = y.coerceIn(0, maxY)
+
+ fun markUnusable() {
+ synchronized(lock) {
+ usable = false
+ attached = false
+ phase = AttachPhase.DETACHED
+ }
+ }
+
+ fun beginAttach() {
+ synchronized(lock) {
+ if (phase == AttachPhase.ATTACHED) return
+ phase = AttachPhase.ATTACHING
+ }
+ }
+
+ /**
+ * @param newDevice true when the UHID device was actually created (not idempotent reopen).
+ */
+ fun completeAttach(newDevice: Boolean) {
+ synchronized(lock) {
+ attached = true
+ usable = true
+ phase = AttachPhase.ATTACHED
+ if (newDevice) {
+ cookedX = centerX()
+ cookedY = centerY()
+ needsCenterSeed = true
+ lastHidMoveAt = 0L
+ lastWasEdgePulse = false
+ edgeFlags = MouseEdgeAnchor.Flags()
+ }
+ }
+ }
+
+ fun detach() {
+ synchronized(lock) {
+ attached = false
+ phase = AttachPhase.DETACHED
+ buttons = 0
+ needsCenterSeed = false
+ lastWasEdgePulse = false
+ edgeFlags = MouseEdgeAnchor.Flags()
+ // Drop the pre-leave target only. A newer Enter/DMMV (targetEpoch > leaveEpoch)
+ // must survive so attach can snap to it after a drain-ordered close.
+ if (targetEpoch == leaveEpoch) {
+ pendingSnapX = null
+ pendingSnapY = null
+ }
+ }
+ }
+
+ /**
+ * Snapshot the current target generation when InputLeap Leave is received.
+ * Must run at Leave-receive, not when the 300ms debounce later detaches, so a
+ * subsequent Enter cannot be marked stale.
+ */
+ fun markLeave() {
+ synchronized(lock) {
+ leaveEpoch = targetEpoch
+ }
+ }
+
+ fun resetOnDisconnect() {
+ synchronized(lock) {
+ attached = false
+ usable = true
+ phase = AttachPhase.DETACHED
+ buttons = 0
+ pendingSnapX = null
+ pendingSnapY = null
+ targetEpoch = 0
+ leaveEpoch = -1
+ needsCenterSeed = false
+ lastHidMoveAt = 0L
+ lastWasEdgePulse = false
+ edgeFlags = MouseEdgeAnchor.Flags()
+ }
+ }
+
+ /**
+ * Authoritative InputLeap Enter. Always last-write-wins for the pending snap,
+ * and re-arms edge saturation so a new edge can pulse after a prior session.
+ */
+ fun onEnter(x: Int, y: Int) {
+ val tx = clampX(x)
+ val ty = clampY(y)
+ synchronized(lock) {
+ storeTargetLocked(tx, ty)
+ lastWasEdgePulse = false
+ edgeFlags = MouseEdgeAnchor.Flags()
+ }
+ }
+
+ fun updatePointerTarget(x: Int, y: Int) {
+ val tx = clampX(x)
+ val ty = clampY(y)
+ synchronized(lock) {
+ storeTargetLocked(tx, ty)
+ }
+ }
+
+ /** Retain latest protocol coords during attach without emitting HID yet. */
+ fun queueTargetWhileAttaching(x: Int, y: Int): Boolean {
+ synchronized(lock) {
+ if (phase != AttachPhase.ATTACHING) return false
+ storeTargetLocked(clampX(x), clampY(y))
+ return true
+ }
+ }
+
+ private fun storeTargetLocked(x: Int, y: Int) {
+ protocolX = x
+ protocolY = y
+ pendingSnapX = x
+ pendingSnapY = y
+ targetEpoch++
+ }
+
+ fun consumePendingSnap(): Pair? {
+ synchronized(lock) {
+ val x = pendingSnapX ?: return null
+ val y = pendingSnapY ?: return null
+ pendingSnapX = null
+ pendingSnapY = null
+ return Pair(x, y)
+ }
+ }
+
+ fun peekPendingSnap(): Pair? {
+ synchronized(lock) {
+ val x = pendingSnapX ?: return null
+ val y = pendingSnapY ?: return null
+ return Pair(x, y)
+ }
+ }
+
+ fun clearCenterSeed() {
+ synchronized(lock) {
+ needsCenterSeed = false
+ }
+ }
+
+ fun needsCenterSeed(): Boolean = synchronized(lock) { needsCenterSeed }
+
+ fun applySuccessfulMove(plan: MousePointerCompensation.HidMovePlan, nowMs: Long) {
+ val (dx, dy) = plan.cookedDelta()
+ synchronized(lock) {
+ cookedX = (cookedX + dx).coerceIn(0, maxX)
+ cookedY = (cookedY + dy).coerceIn(0, maxY)
+ lastHidMoveAt = nowMs
+ lastWasEdgePulse = false
+ needsCenterSeed = false
+ }
+ }
+
+ fun applySuccessfulPlan(plan: MouseEdgeAnchor.Plan, nowMs: Long) {
+ synchronized(lock) {
+ cookedX = plan.cookedXAfter
+ cookedY = plan.cookedYAfter
+ edgeFlags = plan.flagsAfter
+ lastHidMoveAt = nowMs
+ lastWasEdgePulse = plan.isEdgePulse
+ needsCenterSeed = false
+ }
+ }
+
+ fun plannerInput(nowMs: Long): MouseEdgeAnchor.Input {
+ synchronized(lock) {
+ return MouseEdgeAnchor.Input(
+ targetX = protocolX,
+ targetY = protocolY,
+ cookedX = cookedX,
+ cookedY = cookedY,
+ maxX = maxX,
+ maxY = maxY,
+ flags = edgeFlags,
+ preferTangentialFirst = needsCenterSeed,
+ lastWasEdgePulse = lastWasEdgePulse,
+ dtMs = if (lastHidMoveAt == 0L) 0L else nowMs - lastHidMoveAt,
+ )
+ }
+ }
+
+ fun movementDtMs(nowMs: Long): Long {
+ synchronized(lock) {
+ return if (lastHidMoveAt == 0L) 0L else nowMs - lastHidMoveAt
+ }
+ }
+
+ fun setButtons(value: Int) {
+ synchronized(lock) {
+ buttons = value
+ }
+ }
+
+ fun buttons(): Int = synchronized(lock) { buttons }
+
+ fun errorToTarget(targetX: Int, targetY: Int): Pair {
+ synchronized(lock) {
+ return Pair(targetX - cookedX, targetY - cookedY)
+ }
+ }
+
+ fun cookedPosition(): Pair = synchronized(lock) { Pair(cookedX, cookedY) }
+
+ fun isAttachedOrAttaching(): Boolean = synchronized(lock) {
+ phase == AttachPhase.ATTACHED || phase == AttachPhase.ATTACHING
+ }
+
+ /**
+ * Resize bounds without re-seeding center when the HID device stays attached.
+ * Scales cooked/protocol estimates proportionally.
+ */
+ fun resizeDisplay(newMaxX: Int, newMaxY: Int) {
+ synchronized(lock) {
+ if (newMaxX == maxX && newMaxY == maxY) return
+ val oldMaxX = maxX
+ val oldMaxY = maxY
+ protocolX = MousePointerCompensation.scaleCoord(protocolX, oldMaxX, newMaxX)
+ protocolY = MousePointerCompensation.scaleCoord(protocolY, oldMaxY, newMaxY)
+ pendingSnapX = pendingSnapX?.let { MousePointerCompensation.scaleCoord(it, oldMaxX, newMaxX) }
+ pendingSnapY = pendingSnapY?.let { MousePointerCompensation.scaleCoord(it, oldMaxY, newMaxY) }
+ if (phase == AttachPhase.ATTACHED) {
+ cookedX = MousePointerCompensation.scaleCoord(cookedX, oldMaxX, newMaxX)
+ cookedY = MousePointerCompensation.scaleCoord(cookedY, oldMaxY, newMaxY)
+ } else {
+ cookedX = newMaxX / 2
+ cookedY = newMaxY / 2
+ }
+ maxX = newMaxX
+ maxY = newMaxY
+ edgeFlags = MouseEdgeAnchor.Flags()
+ lastWasEdgePulse = false
+ }
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidReportSink.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidReportSink.kt
new file mode 100644
index 00000000..35a93dfb
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidReportSink.kt
@@ -0,0 +1,7 @@
+package com.inputleaf.android.shizuku.uhid
+
+/** Destination for HID input reports, so pointer logic is testable without `/dev/uhid`. */
+internal interface HidReportSink {
+ /** @return false when the report could not be written. */
+ fun sendReport(report: ByteArray): Boolean
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresence.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresence.kt
new file mode 100644
index 00000000..61826d3b
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresence.kt
@@ -0,0 +1,29 @@
+package com.inputleaf.android.shizuku.uhid
+
+import java.io.File
+
+/**
+ * EventHub/evdev appearance check via sysfs. `/proc/bus/input/devices` is denied
+ * to shell on ColorOS; sysfs `eventN/device/name` and `uniq` are the fallback
+ * the privileged injector can still read on AOSP.
+ */
+internal object HidSysfsPresence {
+
+ fun present(name: String, uniq: String, root: File = File("/sys/class/input")): Boolean {
+ val nodes = root.listFiles() ?: return false
+ for (node in nodes) {
+ if (!node.name.startsWith("event")) continue
+ val device = File(node, "device")
+ if (uniq.isNotEmpty()) {
+ val deviceUniq = readTrim(File(device, "uniq"))
+ if (deviceUniq == uniq) return true
+ }
+ val deviceName = readTrim(File(device, "name"))
+ if (deviceName == name) return true
+ }
+ return false
+ }
+
+ private fun readTrim(file: File): String =
+ runCatching { file.readText().trim() }.getOrDefault("")
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/MouseEdgeAnchor.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/MouseEdgeAnchor.kt
new file mode 100644
index 00000000..c5938f63
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/MouseEdgeAnchor.kt
@@ -0,0 +1,272 @@
+package com.inputleaf.android.shizuku.uhid
+
+/**
+ * Hard edge anchoring for the relative UHID mouse.
+ *
+ * AOSP [MouseCursorController] clamps the cooked pointer to
+ * `[0, width-1] × [0, height-1]`. Open-loop compensation can reach that max in
+ * the predicted coordinate while the physical pointer still undershoots; later
+ * DMMVs then become no-ops. A one-shot outward HID pulse on the boundary axis
+ * lets Android's own clamp place the real pointer on that edge.
+ *
+ * Tangential correction is never combined with the pulse: CurvedVelocityControl
+ * uses hypot(vx, vy), so a ±32767 edge delta would distort the other axis.
+ */
+internal object MouseEdgeAnchor {
+
+ /**
+ * AOSP [VelocityTracker] LSQR horizon (100 ms). After an edge pulse, normal
+ * compensation in this window uses last-segment gain so a follow-up
+ * tangential report is not amplified into a park-then-snap jump.
+ */
+ const val EDGE_PULSE_GAIN_HORIZON_MS = 100L
+
+ /** HID counts/s large enough to select the last acceleration-curve segment. */
+ private const val LAST_SEGMENT_HID_SPEED = 1_000_000.0
+
+ data class Flags(
+ val minX: Boolean = false,
+ val maxX: Boolean = false,
+ val minY: Boolean = false,
+ val maxY: Boolean = false,
+ )
+
+ data class Input(
+ val targetX: Int,
+ val targetY: Int,
+ val cookedX: Int,
+ val cookedY: Int,
+ val maxX: Int,
+ val maxY: Int,
+ val flags: Flags,
+ val preferTangentialFirst: Boolean,
+ val lastWasEdgePulse: Boolean,
+ val dtMs: Long,
+ )
+
+ data class Plan(
+ val hidX: Int,
+ val hidY: Int,
+ val gain: Double,
+ val flagsAfter: Flags,
+ val cookedXAfter: Int,
+ val cookedYAfter: Int,
+ val isEdgePulse: Boolean,
+ ) {
+ val isNoOp: Boolean get() = hidX == 0 && hidY == 0
+ }
+
+ fun atMin(value: Int): Boolean = value <= 0
+
+ fun atMax(value: Int, max: Int): Boolean = value >= max
+
+ fun rearm(flags: Flags, targetX: Int, targetY: Int, maxX: Int, maxY: Int): Flags = Flags(
+ minX = flags.minX && atMin(targetX),
+ maxX = flags.maxX && atMax(targetX, maxX),
+ minY = flags.minY && atMin(targetY),
+ maxY = flags.maxY && atMax(targetY, maxY),
+ )
+
+ fun needsEdgePulse(flags: Flags, targetX: Int, targetY: Int, maxX: Int, maxY: Int): Boolean {
+ val armed = rearm(flags, targetX, targetY, maxX, maxY)
+ return pendingPulses(targetX, targetY, maxX, maxY, armed).any
+ }
+
+ fun plan(input: Input, settingsSpeed: Int): Plan {
+ val armed = rearm(input.flags, input.targetX, input.targetY, input.maxX, input.maxY)
+ val pending = pendingPulses(input.targetX, input.targetY, input.maxX, input.maxY, armed)
+ val errX = input.targetX - input.cookedX
+ val errY = input.targetY - input.cookedY
+
+ if (pending.any) {
+ if (input.preferTangentialFirst) {
+ val tangential = normalPlan(
+ errX = if (pending.x) 0 else errX,
+ errY = if (pending.y) 0 else errY,
+ settingsSpeed = settingsSpeed,
+ dtMs = input.dtMs,
+ lastWasEdgePulse = input.lastWasEdgePulse,
+ cookedX = input.cookedX,
+ cookedY = input.cookedY,
+ maxX = input.maxX,
+ maxY = input.maxY,
+ flagsAfter = armed,
+ )
+ if (!tangential.isNoOp) return tangential
+ }
+ return edgePulse(pending, armed, input, settingsSpeed)
+ }
+
+ return normalPlan(
+ errX = errX,
+ errY = errY,
+ settingsSpeed = settingsSpeed,
+ dtMs = input.dtMs,
+ lastWasEdgePulse = input.lastWasEdgePulse,
+ cookedX = input.cookedX,
+ cookedY = input.cookedY,
+ maxX = input.maxX,
+ maxY = input.maxY,
+ flagsAfter = armed,
+ )
+ }
+
+ /**
+ * Attach/Enter may emit tangential compensation first, then remaining edge
+ * pulses (one report per axis). Never pulse-then-tangential in this burst
+ * (that was the park-then-snap bug).
+ */
+ fun planSnap(input: Input, settingsSpeed: Int): List {
+ val first = plan(input.copy(preferTangentialFirst = true), settingsSpeed)
+ if (first.isNoOp) return listOf(first)
+ val plans = mutableListOf(first)
+ var current = followUpInput(input, first)
+ if (!first.isEdgePulse) {
+ val second = plan(current, settingsSpeed)
+ if (second.isEdgePulse) {
+ plans += second
+ current = followUpInput(input, second)
+ }
+ }
+ // Remaining boundary axes only. Never emit tangential after a pulse in this
+ // burst (that reintroduces the park-then-snap velocity-tracker jump).
+ while (
+ plans.size < 3 &&
+ needsEdgePulse(current.flags, current.targetX, current.targetY, current.maxX, current.maxY)
+ ) {
+ val pulse = plan(current.copy(preferTangentialFirst = false), settingsSpeed)
+ if (!pulse.isEdgePulse) break
+ plans += pulse
+ current = followUpInput(input, pulse)
+ }
+ return plans
+ }
+
+ private fun followUpInput(original: Input, plan: Plan): Input = Input(
+ targetX = original.targetX,
+ targetY = original.targetY,
+ cookedX = plan.cookedXAfter,
+ cookedY = plan.cookedYAfter,
+ maxX = original.maxX,
+ maxY = original.maxY,
+ flags = plan.flagsAfter,
+ preferTangentialFirst = false,
+ lastWasEdgePulse = plan.isEdgePulse,
+ dtMs = 0L,
+ )
+
+ private data class PendingPulses(
+ val minX: Boolean,
+ val maxX: Boolean,
+ val minY: Boolean,
+ val maxY: Boolean,
+ ) {
+ val x: Boolean get() = minX || maxX
+ val y: Boolean get() = minY || maxY
+ val any: Boolean get() = x || y
+ }
+
+ private fun pendingPulses(
+ targetX: Int,
+ targetY: Int,
+ maxX: Int,
+ maxY: Int,
+ armed: Flags,
+ ): PendingPulses {
+ val minX = atMin(targetX) && !armed.minX
+ // A 1-px axis is both min and max; pulse min only.
+ val maxXPulse = maxX > 0 && atMax(targetX, maxX) && !armed.maxX && !minX
+ val minY = atMin(targetY) && !armed.minY
+ val maxYPulse = maxY > 0 && atMax(targetY, maxY) && !armed.maxY && !minY
+ return PendingPulses(minX, maxXPulse, minY, maxYPulse)
+ }
+
+ private fun edgePulse(
+ pending: PendingPulses,
+ armed: Flags,
+ input: Input,
+ settingsSpeed: Int,
+ ): Plan {
+ val hidX = when {
+ pending.minX -> HidMouse.MIN_DELTA
+ pending.maxX -> HidMouse.MAX_DELTA
+ else -> 0
+ }
+ val hidY = when {
+ pending.minY -> HidMouse.MIN_DELTA
+ pending.maxY -> HidMouse.MAX_DELTA
+ else -> 0
+ }
+ return Plan(
+ hidX = hidX,
+ hidY = hidY,
+ gain = MousePointerCompensation.firstSegmentGain(settingsSpeed),
+ flagsAfter = Flags(
+ minX = armed.minX || pending.minX,
+ maxX = armed.maxX || pending.maxX,
+ minY = armed.minY || pending.minY,
+ maxY = armed.maxY || pending.maxY,
+ ),
+ cookedXAfter = when {
+ pending.minX -> 0
+ pending.maxX -> input.maxX
+ else -> input.cookedX
+ },
+ cookedYAfter = when {
+ pending.minY -> 0
+ pending.maxY -> input.maxY
+ else -> input.cookedY
+ },
+ isEdgePulse = true,
+ )
+ }
+
+ private fun normalPlan(
+ errX: Int,
+ errY: Int,
+ settingsSpeed: Int,
+ dtMs: Long,
+ lastWasEdgePulse: Boolean,
+ cookedX: Int,
+ cookedY: Int,
+ maxX: Int,
+ maxY: Int,
+ flagsAfter: Flags,
+ ): Plan {
+ if (errX == 0 && errY == 0) {
+ return Plan(
+ hidX = 0,
+ hidY = 0,
+ gain = MousePointerCompensation.firstSegmentGain(settingsSpeed),
+ flagsAfter = flagsAfter,
+ cookedXAfter = cookedX,
+ cookedYAfter = cookedY,
+ isEdgePulse = false,
+ )
+ }
+ val hidPlan = if (
+ lastWasEdgePulse &&
+ dtMs > 0L &&
+ dtMs < EDGE_PULSE_GAIN_HORIZON_MS
+ ) {
+ val gain = MousePointerCompensation.gainForHidSpeed(LAST_SEGMENT_HID_SPEED, settingsSpeed)
+ MousePointerCompensation.HidMovePlan(
+ hidX = MousePointerCompensation.screenDeltaToHid(errX, gain),
+ hidY = MousePointerCompensation.screenDeltaToHid(errY, gain),
+ gain = gain,
+ )
+ } else {
+ MousePointerCompensation.planHidMove(errX, errY, settingsSpeed, dtMs)
+ }
+ val (dx, dy) = hidPlan.cookedDelta()
+ return Plan(
+ hidX = hidPlan.hidX,
+ hidY = hidPlan.hidY,
+ gain = hidPlan.gain,
+ flagsAfter = flagsAfter,
+ cookedXAfter = (cookedX + dx).coerceIn(0, maxX),
+ cookedYAfter = (cookedY + dy).coerceIn(0, maxY),
+ isEdgePulse = false,
+ )
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensation.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensation.kt
new file mode 100644
index 00000000..c9f99a0c
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensation.kt
@@ -0,0 +1,169 @@
+package com.inputleaf.android.shizuku.uhid
+
+import android.os.Build
+import kotlin.math.hypot
+import kotlin.math.roundToInt
+
+/**
+ * Open-loop inverse of AOSP Android 16 [CurvedVelocityControl].
+ *
+ * HID relative counts are not screen pixels. Predicted cooked position is model-based only —
+ * there is no closed-loop correction from actual pointer feedback.
+ */
+internal object MousePointerCompensation {
+
+ /** Mirrors [VelocityControl.h] STOP_TIME (500 ms). */
+ const val STOP_TIME_MS = 500L
+
+ private val SENSITIVITY_FACTORS =
+ intArrayOf(1, 2, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 18, 20)
+
+ private const val FIRST_SEGMENT_BASE_GAIN = 3.19
+ private const val SEGMENT0_MAX_SPEED_MM_PER_S = 32.002
+ private const val MOUSE_CPI = 800.0
+ private const val MM_PER_INCH = 25.4
+
+ private val CURVE_SEGMENTS = arrayOf(
+ CurveSegment(SEGMENT0_MAX_SPEED_MM_PER_S, FIRST_SEGMENT_BASE_GAIN, 0.0),
+ CurveSegment(52.83, 4.79, -51.254),
+ CurveSegment(119.124, 7.28, -182.737),
+ CurveSegment(Double.POSITIVE_INFINITY, 15.04, -1107.556),
+ )
+
+ /** When true, [effectivePointerSpeedForGain] applies the OnePlus/OPPO -1 offset. */
+ private var oplusFamilyRemapEnabled: Boolean = isOplusFamilyDevice()
+
+ data class HidMovePlan(
+ val hidX: Int,
+ val hidY: Int,
+ val gain: Double,
+ ) {
+ val isNoOp: Boolean get() = hidX == 0 && hidY == 0
+
+ fun cookedDelta(): Pair = cookedDeltaFromHid(hidX, hidY, gain)
+ }
+
+ private data class CurveSegment(
+ val maxSpeedMmPerS: Double,
+ val baseGain: Double,
+ val reciprocal: Double,
+ )
+
+ /**
+ * First-segment base gain for a **settings** pointer speed (-7..7).
+ * OEM remap is applied exactly once.
+ */
+ fun firstSegmentGain(settingsSpeed: Int): Double {
+ return segmentBaseGainFromEffectiveSpeed(effectivePointerSpeedForGain(settingsSpeed))
+ }
+
+ /**
+ * Settings report pointer_speed=0 on OnePlus Nord 4 (Android 16), but measured
+ * cooked_delta/hid_counts matches [firstSegmentGain] at speed -1 (1.838 vs 2.0416).
+ *
+ * @param settingsSpeed raw value from Settings.System pointer_speed
+ * @return effective speed index for sensitivity lookup (-7..7)
+ */
+ fun effectivePointerSpeedForGain(settingsSpeed: Int): Int {
+ val speed = settingsSpeed.coerceIn(-7, 7)
+ if (oplusFamilyRemapEnabled) {
+ return (speed - 1).coerceIn(-7, 7)
+ }
+ return speed
+ }
+
+ /** Sensitivity scale factor; [effectiveSpeed] must already be remapped. */
+ internal fun commonFactorFromEffectiveSpeed(effectiveSpeed: Int): Double {
+ val speed = effectiveSpeed.coerceIn(-7, 7)
+ return 0.64 * SENSITIVITY_FACTORS[speed + 7] / 10.0
+ }
+
+ /** Sensitivity scale factor from a **settings** pointer speed (applies OEM remap once). */
+ private fun commonFactorFromSettingsSpeed(settingsSpeed: Int): Double {
+ return commonFactorFromEffectiveSpeed(effectivePointerSpeedForGain(settingsSpeed))
+ }
+
+ /** First-segment base gain; [effectiveSpeed] must already be remapped. */
+ internal fun segmentBaseGainFromEffectiveSpeed(effectiveSpeed: Int): Double {
+ return commonFactorFromEffectiveSpeed(effectiveSpeed) * FIRST_SEGMENT_BASE_GAIN
+ }
+
+ fun gainForHidSpeed(hidCountsPerSecond: Double, settingsSpeed: Int): Double {
+ if (hidCountsPerSecond <= 0.0) {
+ return firstSegmentGain(settingsSpeed)
+ }
+ val speedMmPerS = hidCountsPerSecond / MOUSE_CPI * MM_PER_INCH
+ return gainForSpeedMmPerS(speedMmPerS, settingsSpeed)
+ }
+
+ fun gainForSpeedMmPerS(speedMmPerS: Double, settingsSpeed: Int): Double {
+ val factor = commonFactorFromSettingsSpeed(settingsSpeed)
+ // The last segment is terminated by +Inf, so some segment always matches and
+ // there is no reachable fallback after this loop.
+ val segment = CURVE_SEGMENTS.first { speedMmPerS <= it.maxSpeedMmPerS }
+ val base = factor * segment.baseGain
+ val reciprocal = factor * segment.reciprocal
+ return if (reciprocal == 0.0) base else base + reciprocal / speedMmPerS
+ }
+
+ fun screenDeltaToHid(screenDelta: Int, gain: Double): Int {
+ if (screenDelta == 0 || gain <= 0.0) return 0
+ return (screenDelta / gain).roundToInt().coerceIn(HidMouse.MIN_DELTA, HidMouse.MAX_DELTA)
+ }
+
+ /**
+ * Plan one HID report and the gain used to predict its cooked effect.
+ * When [dtMs] is zero/unknown or >= [STOP_TIME_MS], AOSP resets velocity tracking
+ * and the first-segment base gain applies.
+ */
+ fun planHidMove(
+ errX: Int,
+ errY: Int,
+ settingsSpeed: Int,
+ dtMs: Long,
+ ): HidMovePlan {
+ if (errX == 0 && errY == 0) {
+ return HidMovePlan(0, 0, firstSegmentGain(settingsSpeed))
+ }
+ val gain = if (dtMs <= 0L || dtMs >= STOP_TIME_MS) {
+ firstSegmentGain(settingsSpeed)
+ } else {
+ val prelimGain = firstSegmentGain(settingsSpeed)
+ val prelimHidX = screenDeltaToHid(errX, prelimGain)
+ val prelimHidY = screenDeltaToHid(errY, prelimGain)
+ val hidPerSecond = hypot(prelimHidX.toDouble(), prelimHidY.toDouble()) * 1000.0 / dtMs
+ gainForHidSpeed(hidPerSecond, settingsSpeed)
+ }
+ return HidMovePlan(
+ hidX = screenDeltaToHid(errX, gain),
+ hidY = screenDeltaToHid(errY, gain),
+ gain = gain,
+ )
+ }
+
+ fun cookedDeltaFromHid(hidX: Int, hidY: Int, gain: Double): Pair {
+ return Pair(
+ (hidX * gain).roundToInt(),
+ (hidY * gain).roundToInt(),
+ )
+ }
+
+ /** Scale a pointer coordinate when display size changes (same display, new bounds). */
+ fun scaleCoord(value: Int, oldMax: Int, newMax: Int): Int {
+ if (oldMax <= 0 || newMax <= 0) return value.coerceIn(0, newMax)
+ return ((value.toLong() * newMax) / oldMax).toInt().coerceIn(0, newMax)
+ }
+
+ internal fun setOplusFamilyRemapForTest(enabled: Boolean) {
+ oplusFamilyRemapEnabled = enabled
+ }
+
+ internal fun resetVendorPolicyForTest() {
+ oplusFamilyRemapEnabled = isOplusFamilyDevice()
+ }
+
+ private fun isOplusFamilyDevice(): Boolean {
+ val vendor = Build.MANUFACTURER
+ return vendor.equals("OnePlus", ignoreCase = true) || vendor.equals("OPPO", ignoreCase = true)
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidChannel.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidChannel.kt
new file mode 100644
index 00000000..827271b5
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidChannel.kt
@@ -0,0 +1,317 @@
+package com.inputleaf.android.shizuku.uhid
+
+import android.util.Log
+import android.os.ParcelFileDescriptor
+import java.io.Closeable
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import java.io.RandomAccessFile
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.util.ArrayDeque
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Timeouts for UHID device readiness.
+ *
+ * The gate is [UhidProtocol.UHID_OPEN] — a consumer opened the evdev node — followed by
+ * a bounded sysfs presence probe. [UhidProtocol.UHID_START] only says hid-core created
+ * the device, which is too early to write INPUT2. ColorOS never emits OPEN, so the wait
+ * is bounded and creation continues regardless.
+ */
+internal data class UhidReadinessConfig(
+ val openTimeoutMs: Long = 300L,
+ val presenceTimeoutMs: Long = 150L,
+ val pollIntervalMs: Long = 10L,
+ val presence: (name: String, uniq: String) -> Boolean =
+ { name, uniq -> HidSysfsPresence.present(name, uniq) },
+ val sleeper: (Long) -> Unit = { Thread.sleep(it) },
+ val nanoTime: () -> Long = System::nanoTime,
+)
+
+/**
+ * An open `/dev/uhid` handle with a registered HID device.
+ */
+internal class UhidChannel private constructor(
+ private val output: OutputStream,
+ private val input: InputStream?,
+ private val underlying: Closeable?,
+ private val readinessConfig: UhidReadinessConfig,
+ private val readPfd: ParcelFileDescriptor?,
+) : HidReportSink, Closeable {
+
+ private val closed = AtomicBoolean(false)
+ private val readerStop = AtomicBoolean(false)
+ private val writeLock = Any()
+ private val input2Scratch = ByteArray(UhidProtocol.INPUT2_PACKET_SIZE)
+
+ /**
+ * Reports queued before the device is ready.
+ *
+ * AOSP seeds a new pointer at display centre while it enumerates the device. A
+ * report written before that lands is overwritten by the seed, which strands the
+ * cursor: the app believes the pointer moved, the sprite never did, and every later
+ * relative delta is computed from the wrong origin. Holding reports until readiness
+ * and flushing them in one go keeps the warp after the seed rather than before it.
+ */
+ private val deferredReports = ArrayDeque()
+ private var inputAllowed = false
+ private var readerThread: Thread? = null
+ private val readiness = AtomicReference()
+
+ private data class ReadinessWait(
+ val sawStart: AtomicBoolean = AtomicBoolean(false),
+ val sawOpen: AtomicBoolean = AtomicBoolean(false),
+ )
+
+ override fun sendReport(report: ByteArray): Boolean {
+ if (closed.get()) return false
+ return try {
+ synchronized(writeLock) {
+ if (closed.get()) return false
+ if (!inputAllowed) {
+ deferredReports.addLast(report.copyOf())
+ return true
+ }
+ writeInput2Locked(report)
+ }
+ true
+ } catch (e: Exception) {
+ Log.w(TAG, "Dropping HID report after write failure", e)
+ false
+ }
+ }
+
+ private fun writeInput2Locked(report: ByteArray) {
+ UhidProtocol.writeInput2Into(input2Scratch, report)
+ output.write(input2Scratch)
+ output.flush()
+ }
+
+ private fun allowInputLocked() {
+ inputAllowed = true
+ if (deferredReports.isNotEmpty()) {
+ Log.i(TAG, "Flushing ${deferredReports.size} deferred INPUT2 after UHID ready")
+ }
+ while (deferredReports.isNotEmpty()) {
+ writeInput2Locked(deferredReports.removeFirst())
+ }
+ }
+
+ fun createDevice(
+ name: String,
+ descriptor: ByteArray,
+ vendor: Int = 0,
+ product: Int = 0,
+ uniq: String = "",
+ ) {
+ val wait = if (input != null) ReadinessWait().also(readiness::set) else null
+ if (input != null) startReader()
+ val startedAt = readinessConfig.nanoTime()
+ synchronized(writeLock) {
+ output.write(UhidProtocol.create2Packet(name, descriptor, vendor, product, uniq))
+ output.flush()
+ }
+ if (wait != null) {
+ awaitReady(wait, name, uniq)
+ readiness.compareAndSet(wait, null)
+ stopReader()
+ val elapsedMs = TimeUnit.NANOSECONDS.toMillis(readinessConfig.nanoTime() - startedAt)
+ when {
+ wait.sawOpen.get() ->
+ Log.i(TAG, "UHID device '$name' ready (OPEN) in ${elapsedMs}ms")
+ wait.sawStart.get() ->
+ Log.w(
+ TAG,
+ "UHID device '$name' START but no OPEN after ${elapsedMs}ms; continuing",
+ )
+ else ->
+ Log.w(TAG, "No UHID_START/OPEN for '$name' after ${elapsedMs}ms; continuing")
+ }
+ }
+ synchronized(writeLock) {
+ allowInputLocked()
+ }
+ }
+
+ /**
+ * Drop the blocking readiness reader so INPUT2 and DESTROY are not serialised behind
+ * a pending `read()` on the same `/dev/uhid` fd.
+ *
+ * A [java.io.FileInputStream] built from a [ParcelFileDescriptor] does not own the
+ * fd, so closing the stream is a no-op for the kernel object. Closing the descriptor
+ * is what unblocks `read()` and lets later writes proceed on the original handle.
+ */
+ private fun stopReader() {
+ readerStop.set(true)
+ readerThread?.interrupt()
+ runCatching { readPfd?.close() }
+ val reader = readerThread
+ readerThread = null
+ if (reader != null && reader != Thread.currentThread()) {
+ runCatching { reader.join(READER_JOIN_MS) }
+ }
+ }
+
+ /**
+ * Wait for OPEN, then confirm the node through sysfs.
+ *
+ * Both windows are bounded and creation proceeds either way: ColorOS never emits
+ * OPEN, so blocking on it indefinitely would stall every attach on that OEM.
+ */
+ private fun awaitReady(wait: ReadinessWait, name: String, uniq: String) {
+ val openDeadline = readinessConfig.nanoTime() +
+ TimeUnit.MILLISECONDS.toNanos(readinessConfig.openTimeoutMs)
+ while (readinessConfig.nanoTime() < openDeadline && !wait.sawOpen.get()) {
+ readinessConfig.sleeper(readinessConfig.pollIntervalMs)
+ }
+ if (!wait.sawOpen.get()) return
+
+ val presenceDeadline = readinessConfig.nanoTime() +
+ TimeUnit.MILLISECONDS.toNanos(readinessConfig.presenceTimeoutMs)
+ while (readinessConfig.nanoTime() < presenceDeadline) {
+ if (readinessConfig.presence(name, uniq)) return
+ readinessConfig.sleeper(readinessConfig.pollIntervalMs)
+ }
+ }
+
+ fun destroyDevice() {
+ runCatching {
+ output.write(UhidProtocol.destroyPacket())
+ output.flush()
+ }
+ }
+
+ override fun close() {
+ if (!closed.compareAndSet(false, true)) return
+ synchronized(writeLock) {
+ deferredReports.clear()
+ inputAllowed = false
+ }
+ stopReader()
+ readerThread?.interrupt()
+ runCatching {
+ output.write(UhidProtocol.destroyPacket())
+ output.flush()
+ }
+ runCatching { underlying?.close() ?: output.close() }
+ }
+
+ private fun startReader() {
+ if (readerThread != null || input == null) return
+ readerThread = Thread({
+ val buffer = ByteArray(UhidProtocol.EVENT_PACKET_SIZE)
+ while (!closed.get() && !readerStop.get() && !Thread.currentThread().isInterrupted) {
+ try {
+ val count = input.read(buffer)
+ if (count < 0) break
+ if (count < Int.SIZE_BYTES) continue
+ val type = ByteBuffer.wrap(buffer, 0, Int.SIZE_BYTES)
+ .order(ByteOrder.LITTLE_ENDIAN)
+ .int
+ val wait = readiness.get() ?: continue
+ when (type) {
+ // START is diagnostic only; OPEN is the gate.
+ UhidProtocol.UHID_START -> {
+ if (wait.sawStart.compareAndSet(false, true)) {
+ Log.d(TAG, "UHID_START")
+ }
+ }
+ UhidProtocol.UHID_OPEN -> {
+ if (wait.sawOpen.compareAndSet(false, true)) {
+ Log.i(TAG, "UHID_OPEN")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ break
+ }
+ }
+ }, "uhid-reader").apply {
+ isDaemon = true
+ start()
+ }
+ }
+
+ companion object {
+ private const val TAG = "UhidChannel"
+ private const val READER_JOIN_MS = 100L
+ private const val DEVICE_PATH = "/dev/uhid"
+
+ fun openHandle(
+ path: String = DEVICE_PATH,
+ readinessConfig: UhidReadinessConfig = UhidReadinessConfig(),
+ ): UhidChannel? {
+ val file = try {
+ RandomAccessFile(path, "rw")
+ } catch (e: Exception) {
+ Log.w(TAG, "Cannot open $path (SELinux or permissions?)", e)
+ return null
+ }
+ var readPfd: ParcelFileDescriptor? = null
+ var readStream: FileInputStream? = null
+ return try {
+ readPfd = try {
+ ParcelFileDescriptor.dup(file.fd)
+ } catch (_: Exception) {
+ null
+ }
+ // Without a dup there is no descriptor we can close to unblock read(), so
+ // a reader would pin the shared fd and serialise every later write. Skip
+ // readiness detection instead and let reports flush immediately.
+ if (readPfd == null) {
+ Log.w(TAG, "Cannot dup $path; skipping UHID readiness reader")
+ }
+ readStream = readPfd?.let { FileInputStream(it.fileDescriptor) }
+ val channel = UhidChannel(
+ FileOutputStream(file.fd),
+ readStream,
+ file,
+ readinessConfig,
+ readPfd,
+ )
+ Log.i(TAG, "Opened $path")
+ channel
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to wrap $path", e)
+ runCatching { readStream?.close() }
+ runCatching { readPfd?.close() }
+ runCatching { file.close() }
+ null
+ }
+ }
+
+ fun open(
+ name: String,
+ descriptor: ByteArray,
+ path: String = DEVICE_PATH,
+ vendor: Int = 0,
+ product: Int = 0,
+ uniq: String = "",
+ readinessConfig: UhidReadinessConfig = UhidReadinessConfig(),
+ ): UhidChannel? {
+ val channel = openHandle(path, readinessConfig) ?: return null
+ return try {
+ channel.createDevice(name, descriptor, vendor, product, uniq)
+ Log.i(TAG, "Registered HID device '$name'")
+ channel
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to register HID device '$name'", e)
+ runCatching { channel.close() }
+ null
+ }
+ }
+
+ fun forTesting(
+ output: OutputStream,
+ input: InputStream? = null,
+ readinessConfig: UhidReadinessConfig = UhidReadinessConfig(
+ presence = { _, _ -> false },
+ ),
+ ): UhidChannel = UhidChannel(output, input, null, readinessConfig, readPfd = null)
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidProtocol.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidProtocol.kt
new file mode 100644
index 00000000..fdf5a353
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidProtocol.kt
@@ -0,0 +1,192 @@
+package com.inputleaf.android.shizuku.uhid
+
+import java.io.InputStream
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+/**
+ * Packet layout for `/dev/uhid`, from Linux `uhid.h`.
+ *
+ * Unlike uinput, uhid needs no `ioctl` at all — every operation is a plain `write` of a
+ * fixed-size `uhid_event`. That is why this transport carries no hidden-API risk.
+ *
+ * The field that is easy to get wrong: `rd_size` is a **u16 at offset 260**, after
+ * `name[128]`, `phys[64]` and `uniq[64]`. Writing it as a u32 at 132 lands inside
+ * `phys[]` and the kernel rejects or misparses the device.
+ *
+ * The event-type constants below are the kernel's, and the tests assert the literal
+ * numbers so the ABI is guarded rather than merely self-consistent. They previously
+ * read START=4 and OPEN=6, which are really the kernel's OPEN and OUTPUT: the code
+ * happened to gate on the right practical signal under the wrong name, while the
+ * "OPEN" branch waited on a host→device report that never arrives.
+ */
+internal object UhidProtocol {
+
+ // enum uhid_event_type, in declaration order from uapi/linux/uhid.h. These are wire
+ // values: do not renumber them to suit the code.
+ const val UHID_DESTROY = 1
+
+ /** hid-core created the device. Nothing is necessarily listening yet. */
+ const val UHID_START = 2
+ const val UHID_STOP = 3
+
+ /**
+ * A consumer (Android's EventHub) opened the evdev node — safe to send the first
+ * input report. This, not [UHID_START], is the readiness gate: START fires before
+ * EventHub attaches, so writing INPUT2 on START races the very drop that the
+ * readiness wait exists to prevent.
+ */
+ const val UHID_OPEN = 4
+ const val UHID_CLOSE = 5
+
+ /** Device→host report. Carries `data[4096] + u16 size + u8 rtype`. */
+ const val UHID_OUTPUT = 6
+ const val UHID_GET_REPORT = 9
+ const val UHID_CREATE2 = 11
+ const val UHID_INPUT2 = 12
+ const val UHID_SET_REPORT = 13
+
+ const val HID_MAX_DESCRIPTOR_SIZE = 4096
+ const val UHID_DATA_MAX = 4096
+
+ private const val NAME_SIZE = 128
+ private const val PHYS_SIZE = 64
+ private const val UNIQ_SIZE = 64
+
+ /** name + phys + uniq + rd_size + bus + vendor + product + version + country */
+ const val CREATE2_HEADER_SIZE =
+ NAME_SIZE + PHYS_SIZE + UNIQ_SIZE + 2 + 2 + 4 + 4 + 4 + 4
+
+ const val CREATE2_PACKET_SIZE = 4 + CREATE2_HEADER_SIZE + HID_MAX_DESCRIPTOR_SIZE
+ const val INPUT2_PACKET_SIZE = 4 + 2 + UHID_DATA_MAX
+ /** Largest possible `struct uhid_event`, used for reads from `/dev/uhid`. */
+ const val EVENT_PACKET_SIZE = CREATE2_PACKET_SIZE
+
+ const val OFFSET_PHYS = 4 + NAME_SIZE
+ const val OFFSET_UNIQ = OFFSET_PHYS + PHYS_SIZE
+ const val OFFSET_RD_SIZE = OFFSET_UNIQ + UNIQ_SIZE
+ const val OFFSET_BUS = OFFSET_RD_SIZE + 2
+ const val OFFSET_DESCRIPTOR = 4 + CREATE2_HEADER_SIZE
+
+ const val BUS_USB = 0x0003
+
+ fun create2Packet(
+ name: String,
+ descriptor: ByteArray,
+ vendor: Int = 0,
+ product: Int = 0,
+ uniq: String = "",
+ ): ByteArray {
+ require(descriptor.size <= HID_MAX_DESCRIPTOR_SIZE) {
+ "Descriptor too large: ${descriptor.size}"
+ }
+ val packet = ByteBuffer.allocate(CREATE2_PACKET_SIZE).order(ByteOrder.LITTLE_ENDIAN)
+ packet.putInt(0, UHID_CREATE2)
+
+ writeFixedString(packet.array(), 4, NAME_SIZE, name)
+ writeFixedString(packet.array(), OFFSET_PHYS, PHYS_SIZE, "")
+ writeFixedString(packet.array(), OFFSET_UNIQ, UNIQ_SIZE, uniq)
+
+ packet.putShort(OFFSET_RD_SIZE, descriptor.size.toShort())
+ packet.putShort(OFFSET_BUS, BUS_USB.toShort())
+ packet.putInt(OFFSET_BUS + 2, vendor)
+ packet.putInt(OFFSET_BUS + 6, product)
+
+ System.arraycopy(descriptor, 0, packet.array(), OFFSET_DESCRIPTOR, descriptor.size)
+ return packet.array()
+ }
+
+ private fun writeFixedString(array: ByteArray, offset: Int, size: Int, value: String) {
+ val bytes = value.toByteArray(Charsets.UTF_8)
+ System.arraycopy(bytes, 0, array, offset, minOf(bytes.size, size - 1))
+ }
+
+ /**
+ * Writes an INPUT2 event into [dest], which must be at least [INPUT2_PACKET_SIZE].
+ * Reusing [dest] avoids a 4KB allocation on every mouse report in the injector.
+ */
+ fun writeInput2Into(dest: ByteArray, report: ByteArray) {
+ require(dest.size >= INPUT2_PACKET_SIZE) { "INPUT2 dest too small: ${dest.size}" }
+ require(report.size <= UHID_DATA_MAX) { "Report too large: ${report.size}" }
+ val buffer = ByteBuffer.wrap(dest).order(ByteOrder.LITTLE_ENDIAN)
+ buffer.putInt(0, UHID_INPUT2)
+ buffer.putShort(4, report.size.toShort())
+ System.arraycopy(report, 0, dest, 6, report.size)
+ }
+
+ fun input2Packet(report: ByteArray): ByteArray {
+ val packet = ByteArray(INPUT2_PACKET_SIZE)
+ writeInput2Into(packet, report)
+ return packet
+ }
+
+ /**
+ * Full `struct uhid_event` with [UHID_DESTROY] in the type word. A 4-byte write is
+ * legal on AOSP, but ColorOS InputReader has been observed to keep evdev nodes if
+ * the write is shorter than the event union.
+ */
+ fun destroyPacket(): ByteArray {
+ val packet = ByteArray(EVENT_PACKET_SIZE)
+ ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).putInt(0, UHID_DESTROY)
+ return packet
+ }
+
+ /**
+ * Waits briefly for [UHID_START], draining any other kernel event that arrives first.
+ * Returns whether START was seen; the caller proceeds either way, since some kernels
+ * deliver it late and the device still works.
+ */
+ fun waitForStart(input: InputStream, timeoutMs: Long, now: () -> Long = System::currentTimeMillis): Boolean {
+ val deadline = now() + timeoutMs
+ val header = ByteArray(4)
+ var offset = 0
+ while (now() < deadline) {
+ if (input.available() <= 0) {
+ try {
+ Thread.sleep(POLL_INTERVAL_MS)
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ return false
+ }
+ continue
+ }
+ val read = input.read(header, offset, 4 - offset)
+ if (read < 0) return false
+ offset += read
+ if (offset < 4) continue
+
+ val type = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN).getInt(0)
+ if (type == UHID_START) return true
+ drainPayload(input, type)
+ offset = 0
+ }
+ return false
+ }
+
+ private fun drainPayload(input: InputStream, type: Int) {
+ var remaining = payloadSize(type)
+ while (remaining > 0) {
+ val skipped = input.skip(remaining.toLong())
+ if (skipped <= 0) return
+ remaining -= skipped.toInt()
+ }
+ }
+
+ /**
+ * Payload bytes following the u32 type word, from the packed structs in `uhid.h`.
+ * Used to step over an event whose body we do not care about, so a wrong size here
+ * desynchronises every later read on the stream.
+ */
+ internal fun payloadSize(type: Int): Int = when (type) {
+ UHID_START -> 8 // struct uhid_start_req { u64 dev_flags }
+ UHID_STOP, UHID_OPEN, UHID_CLOSE -> 0
+ UHID_OUTPUT -> UHID_DATA_MAX + 2 + 1 // data[4096] + u16 size + u8 rtype
+ UHID_GET_REPORT -> 6 // u32 id + u8 rnum + u8 rtype
+ UHID_SET_REPORT -> 4 + 1 + 1 + 2 + UHID_DATA_MAX
+ UHID_INPUT2 -> 2 + UHID_DATA_MAX
+ UHID_CREATE2 -> CREATE2_HEADER_SIZE + HID_MAX_DESCRIPTOR_SIZE
+ else -> UHID_DATA_MAX
+ }
+
+ private const val POLL_INTERVAL_MS = 10L
+}
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/uhid/WheelNotchAccumulator.kt b/app/src/main/java/com/inputleaf/android/shizuku/uhid/WheelNotchAccumulator.kt
new file mode 100644
index 00000000..4439f357
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/WheelNotchAccumulator.kt
@@ -0,0 +1,30 @@
+package com.inputleaf.android.shizuku.uhid
+
+/**
+ * Converts protocol wheel deltas into whole HID notches, carrying the remainder.
+ *
+ * HID reports scroll in notches, so `delta / 120` silently discards anything finer and
+ * a 60-unit delta scrolls nothing at all. Input Leap normalises to 120 units, so a
+ * conforming server never hits this, but a non-conforming one lost scroll input
+ * entirely. Banking the remainder makes those deltas add up to a notch instead.
+ *
+ * Extracted rather than inlined into ShizukuInputInjector so the boundaries stay
+ * reachable from the JVM suite -- that class needs a bound AIDL service to exercise.
+ */
+internal class WheelNotchAccumulator(private val unitsPerNotch: Int = 120) {
+
+ private var remainder = 0
+
+ /** @return whole notches to send now; 0 means the delta was banked, not dropped. */
+ fun accept(delta: Int): Int {
+ remainder += delta
+ val notches = remainder / unitsPerNotch
+ remainder -= notches * unitsPerNotch
+ return notches
+ }
+
+ /** Drops any partial notch so it cannot leak into the next attach. */
+ fun reset() {
+ remainder = 0
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/ui/AdbSetupWizard.kt b/app/src/main/java/com/inputleaf/android/ui/AdbSetupWizard.kt
deleted file mode 100644
index fe9d244d..00000000
--- a/app/src/main/java/com/inputleaf/android/ui/AdbSetupWizard.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package com.inputleaf.android.ui
-
-import androidx.compose.foundation.layout.*
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.unit.dp
-
-@Composable
-fun AdbSetupWizard(
- adbPushCommand: String,
- adbStartCommand: String,
- isVerifying: Boolean,
- verifyResult: Boolean?,
- onVerify: () -> Unit,
- onDismiss: () -> Unit
-) {
- val clipboard = LocalClipboardManager.current
-
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("Set Up ADB") },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
- Text("To inject keyboard and mouse events, run these commands on your PC:")
-
- listOf(
- "1. Enable USB Debugging (Settings → Developer Options)",
- "2. Connect USB cable",
- "3. Run on PC:"
- ).forEach { Text(it) }
-
- CommandBlock(adbPushCommand) {
- clipboard.setText(AnnotatedString(adbPushCommand))
- }
- CommandBlock(adbStartCommand) {
- clipboard.setText(AnnotatedString(adbStartCommand))
- }
-
- when {
- isVerifying -> Row {
- CircularProgressIndicator(Modifier.size(20.dp))
- Spacer(Modifier.width(8.dp))
- Text("Verifying…")
- }
- verifyResult == true -> Text("✅ UHID server is running!")
- verifyResult == false -> Text("❌ UHID server not detected. Check commands above.")
- }
- }
- },
- confirmButton = {
- TextButton(onClick = onVerify, enabled = !isVerifying) { Text("Verify") }
- },
- dismissButton = { TextButton(onClick = onDismiss) { Text("Skip") } }
- )
-}
-
-@Composable
-private fun CommandBlock(command: String, onCopy: () -> Unit) {
- Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.small) {
- Row(Modifier.fillMaxWidth().padding(8.dp)) {
- Text(command, Modifier.weight(1f), style = MaterialTheme.typography.bodySmall,
- fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
- TextButton(onClick = onCopy) { Text("Copy") }
- }
- }
-}
diff --git a/app/src/main/java/com/inputleaf/android/ui/AppNavigation.kt b/app/src/main/java/com/inputleaf/android/ui/AppNavigation.kt
deleted file mode 100644
index 64a8f2e9..00000000
--- a/app/src/main/java/com/inputleaf/android/ui/AppNavigation.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.inputleaf.android.ui
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-
-@Composable
-fun AppNavigation(viewModel: MainViewModel) {
- Box(Modifier.fillMaxSize()) {
- LeafNavigation(viewModel)
- }
-}
diff --git a/app/src/main/java/com/inputleaf/android/ui/LeafNavigation.kt b/app/src/main/java/com/inputleaf/android/ui/LeafNavigation.kt
index c9102ca8..bbb09191 100644
--- a/app/src/main/java/com/inputleaf/android/ui/LeafNavigation.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/LeafNavigation.kt
@@ -17,23 +17,33 @@ import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Shield
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.inputleaf.android.network.ConnectionTransportPolicy
-import com.inputleaf.android.ui.components.AnimatedBottomNavigation
-import com.inputleaf.android.ui.components.NavItem
import com.inputleaf.android.ui.components.UpdateAvailableDialog
import com.inputleaf.android.ui.components.WhatsNewDialog
import com.inputleaf.android.update.UpdateCheckResult
import com.inputleaf.android.util.BatteryOptimizationHelper
+private data class NavItem(
+ val label: String,
+ val icon: ImageVector,
+ val route: String,
+)
+
private sealed class LeafRoute(val key: String) {
data object Home : LeafRoute("main")
data object Servers : LeafRoute("servers")
@@ -46,6 +56,7 @@ fun LeafNavigation(viewModel: MainViewModel) {
var screen by remember { mutableStateOf(LeafRoute.Home.key) }
val context = LocalContext.current
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
+ val pendingConnectIp by viewModel.pendingConnectIp.collectAsStateWithLifecycle()
val discoveredServers by viewModel.discoveredServers.collectAsStateWithLifecycle()
val isScanning by viewModel.isScanning.collectAsStateWithLifecycle()
val screenName by viewModel.screenName.collectAsState(initial = "android-phone")
@@ -234,11 +245,20 @@ fun LeafNavigation(viewModel: MainViewModel) {
Scaffold(
bottomBar = {
- AnimatedBottomNavigation(
- items = navItems,
- selectedIndex = selectedIndex,
- onItemSelected = { index -> screen = navItems[index].route },
- )
+ NavigationBar(
+ modifier = Modifier.fillMaxWidth(),
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ tonalElevation = 0.dp,
+ ) {
+ navItems.forEachIndexed { index, item ->
+ NavigationBarItem(
+ icon = { Icon(item.icon, contentDescription = item.label) },
+ label = { Text(item.label) },
+ selected = selectedIndex == index,
+ onClick = { screen = item.route },
+ )
+ }
+ }
},
) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) {
@@ -262,6 +282,7 @@ fun LeafNavigation(viewModel: MainViewModel) {
onScreenNameChange = { viewModel.saveScreenName(it) },
onToggleMouse = { viewModel.toggleMouseEnabled(it) },
onToggleKeyboard = { viewModel.toggleKeyboardEnabled(it) },
+ pendingConnectIp = pendingConnectIp,
)
LeafRoute.Servers.key -> ServerListScreen(
connectionState = connectionState,
@@ -272,6 +293,7 @@ fun LeafNavigation(viewModel: MainViewModel) {
onConnect = { viewModel.connect(it) },
onAddManual = { viewModel.addManualServer(it) },
onToggleFavorite = { viewModel.toggleFavoriteServer(it) },
+ pendingConnectIp = pendingConnectIp,
)
LeafRoute.Setup.key -> SetupScreen(
shizukuStatus = shizukuStatus,
diff --git a/app/src/main/java/com/inputleaf/android/ui/MainActivity.kt b/app/src/main/java/com/inputleaf/android/ui/MainActivity.kt
index 2701870f..7b5229d8 100644
--- a/app/src/main/java/com/inputleaf/android/ui/MainActivity.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/MainActivity.kt
@@ -1,14 +1,9 @@
package com.inputleaf.android.ui
import android.Manifest
-import android.content.ComponentName
-import android.content.Intent
import android.content.pm.PackageManager
-import android.net.Uri
import android.os.Build
import android.os.Bundle
-import android.provider.Settings
-import com.inputleaf.android.util.BatteryOptimizationHelper
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -106,7 +101,7 @@ class MainActivity : ComponentActivity() {
shapes = com.inputleaf.android.ui.theme.InputLeafShapes
) {
Surface(Modifier.fillMaxSize()) {
- AppNavigation(viewModel)
+ LeafNavigation(viewModel)
}
}
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/MainScreen.kt b/app/src/main/java/com/inputleaf/android/ui/MainScreen.kt
index 13c0cb9c..1fc653ed 100644
--- a/app/src/main/java/com/inputleaf/android/ui/MainScreen.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/MainScreen.kt
@@ -49,6 +49,7 @@ fun MainScreen(
onScreenNameChange: (String) -> Unit,
onToggleMouse: (Boolean) -> Unit,
onToggleKeyboard: (Boolean) -> Unit,
+ pendingConnectIp: String? = null,
) {
var showEditNameDialog by remember { mutableStateOf(false) }
var tempName by remember(screenName) { mutableStateOf(screenName) }
@@ -157,12 +158,19 @@ fun MainScreen(
)
}
items(favorites) { server ->
+ val connectingIp = connectingServerIp(connectionState, pendingConnectIp)
val isConnected = when (connectionState) {
is ConnectionState.Idle -> connectionState.serverIp == server.ip
is ConnectionState.Active -> connectionState.serverIp == server.ip
else -> false
}
- ServerListItem(server, isConnected, onConnect)
+ ServerListItem(
+ server = server,
+ isConnected = isConnected,
+ onServerClick = onConnect,
+ isConnecting = connectingIp == server.ip,
+ enabled = connectingIp == null,
+ )
}
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt b/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt
index c9a92ad5..21fdbea8 100644
--- a/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt
@@ -24,7 +24,6 @@ import com.inputleaf.android.network.ServerScanner
import com.inputleaf.android.service.ConnectionService
import com.inputleaf.android.storage.AppPreferences
import com.inputleaf.android.storage.ClientCertificateStore
-import com.inputleaf.android.update.ChangelogProvider
import com.inputleaf.android.update.UpdateCheckResult
import com.inputleaf.android.update.UpdateService
import com.inputleaf.android.update.VersionChangelog
@@ -94,6 +93,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
val connectionState: StateFlow = _connectionState
+ private val _pendingConnectIp = MutableStateFlow(null)
+ val pendingConnectIp: StateFlow = _pendingConnectIp
private val _discoveredServers = MutableStateFlow>(emptyList())
val discoveredServers: StateFlow> = _discoveredServers
@@ -337,7 +338,12 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
_connectionState.value = localService.state.value
viewModelScope.launch {
- localService.state.collect { _connectionState.value = it }
+ localService.state.collect {
+ _connectionState.value = it
+ if (it !is ConnectionState.Disconnected) {
+ _pendingConnectIp.value = null
+ }
+ }
}
// Wire TOFU callback: bridge service's suspend callback → UI Channel
service!!.onFingerprintConfirmationRequired = { ip, newFp, oldFp ->
@@ -416,7 +422,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val currentVersion = UpdateService.getCurrentVersion(app)
if (lastSeenCode != null && lastSeenCode < currentCode) {
- _whatsNewChangelog.value = ChangelogProvider.getChangelog(currentVersion)
+ _whatsNewChangelog.value = UpdateService.getChangelog(currentVersion)
}
prefs.saveLastSeenVersionCode(currentCode)
@@ -479,16 +485,12 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val shizukuInjector = com.inputleaf.android.shizuku.ShizukuInputInjector(bounds.width(), bounds.height())
val accessibilityInjector = com.inputleaf.android.inject.AccessibilityInputInjector(getApplication(), bounds.width(), bounds.height())
- val resolved = com.inputleaf.android.inject.InputMethodResolver.resolve(
- preferredMethod = method,
- isShizukuAvailable = shizukuInjector.isAvailable(),
- isAccessibilityAvailable = accessibilityInjector.isAvailable()
- )
-
- return when (resolved) {
- com.inputleaf.android.inject.ResolvedMethod.SHIZUKU -> shizukuInjector
- com.inputleaf.android.inject.ResolvedMethod.ACCESSIBILITY -> accessibilityInjector
- com.inputleaf.android.inject.ResolvedMethod.NONE -> null
+ return when {
+ method == "shizuku" && shizukuInjector.isAvailable() -> shizukuInjector
+ method == "accessibility" && accessibilityInjector.isAvailable() -> accessibilityInjector
+ method == "auto" && shizukuInjector.isAvailable() -> shizukuInjector
+ method == "auto" && accessibilityInjector.isAvailable() -> accessibilityInjector
+ else -> null
}
}
@@ -497,32 +499,44 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
scanJob?.cancel()
viewModelScope.launch {
val state = _connectionState.value
- if (state is ConnectionState.Connecting || state is ConnectionState.Handshaking) {
+ if (state is ConnectionState.Connecting ||
+ state is ConnectionState.Handshaking ||
+ _pendingConnectIp.value != null
+ ) {
Log.d("InputLeaf", "Ignoring connect — already connecting to ${server.ip}")
return@launch
}
+ _pendingConnectIp.value = server.ip
val name = prefs.screenName.first()
prefs.saveLastServer(server.ip)
val injector = resolveInjector()
if (injector == null) {
+ _pendingConnectIp.value = null
_errorState.value = "No input method available. Enable Shizuku or Accessibility Service."
return@launch
}
val connected = injector.connect()
if (!connected) {
+ _pendingConnectIp.value = null
_errorState.value = "Failed to connect to input method: ${injector.name}"
return@launch
}
service?.setInjector(injector)
+ if (service == null) {
+ _pendingConnectIp.value = null
+ _errorState.value = "Connection service is not ready"
+ return@launch
+ }
service?.connect(server.ip, name)
}
}
fun disconnect() {
userRequestedDisconnect = true
+ _pendingConnectIp.value = null
service?.disconnect()
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/ServerListItem.kt b/app/src/main/java/com/inputleaf/android/ui/ServerListItem.kt
index eab03335..88338b4f 100644
--- a/app/src/main/java/com/inputleaf/android/ui/ServerListItem.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/ServerListItem.kt
@@ -12,15 +12,25 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import com.inputleaf.android.model.ConnectionState
import com.inputleaf.android.model.ServerInfo
import com.inputleaf.android.ui.components.CircularAvatar
import com.inputleaf.android.ui.components.GradientCard
+internal fun connectingServerIp(
+ connectionState: ConnectionState,
+ pendingConnectIp: String?,
+): String? = pendingConnectIp ?: when (connectionState) {
+ is ConnectionState.Connecting -> connectionState.serverIp
+ is ConnectionState.Handshaking -> connectionState.serverIp
+ else -> null
+}
+
@Composable
fun ServerListItem(
server: ServerInfo,
@@ -28,13 +38,17 @@ fun ServerListItem(
onServerClick: (ServerInfo) -> Unit,
modifier: Modifier = Modifier,
isFavorite: Boolean = false,
- onToggleFavorite: (() -> Unit)? = null
+ onToggleFavorite: (() -> Unit)? = null,
+ isConnecting: Boolean = false,
+ enabled: Boolean = true,
) {
+ val clickable = enabled && !isConnecting && !isConnected
GradientCard(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
- .clickable { onServerClick(server) },
+ .alpha(if (enabled || isConnecting || isConnected) 1f else 0.4f)
+ .clickable(enabled = clickable) { onServerClick(server) },
backgroundColor = if (isConnected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
cornerRadius = 24.dp,
elevation = if (isConnected) 0.dp else 1.dp,
@@ -63,16 +77,20 @@ fun ServerListItem(
)
Spacer(modifier = Modifier.height(2.dp))
Text(
- text = if (isConnected) "${server.ip} • Connected" else server.ip,
- color = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ text = when {
+ isConnecting -> "Connecting…"
+ isConnected -> "${server.ip} • Connected"
+ else -> server.ip
+ },
+ color = if (isConnected || isConnecting) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
}
- // Favorite toggle
if (onToggleFavorite != null) {
IconButton(
onClick = onToggleFavorite,
+ enabled = enabled,
modifier = Modifier.size(32.dp)
) {
Icon(
@@ -84,13 +102,18 @@ fun ServerListItem(
}
}
- if (isConnected) {
+ if (isConnecting) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ } else if (isConnected) {
Box(
modifier = Modifier
.size(12.dp)
.shadow(4.dp, CircleShape, ambientColor = MaterialTheme.colorScheme.primary)
.clip(CircleShape)
- .then(Modifier.size(12.dp))
) {
Surface(
modifier = Modifier.fillMaxSize(),
diff --git a/app/src/main/java/com/inputleaf/android/ui/ServerListScreen.kt b/app/src/main/java/com/inputleaf/android/ui/ServerListScreen.kt
index 79052b3f..e3383602 100644
--- a/app/src/main/java/com/inputleaf/android/ui/ServerListScreen.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/ServerListScreen.kt
@@ -25,11 +25,13 @@ fun ServerListScreen(
onScan: () -> Unit,
onConnect: (ServerInfo) -> Unit,
onAddManual: (String) -> Unit,
- onToggleFavorite: (String) -> Unit
+ onToggleFavorite: (String) -> Unit,
+ pendingConnectIp: String? = null,
) {
var showAddDialog by remember { mutableStateOf(false) }
var manualIp by remember { mutableStateOf("") }
+ val connectingIp = connectingServerIp(connectionState, pendingConnectIp)
val favorites = discoveredServers.filter { favoriteServers.contains(it.ip) }
val others = discoveredServers.filter { !favoriteServers.contains(it.ip) }
@@ -74,12 +76,15 @@ fun ServerListScreen(
items(favorites) { server ->
val isConnected = isServerConnected(connectionState, server)
+ val isConnecting = connectingIp == server.ip
ServerListItem(
server = server,
isConnected = isConnected,
onServerClick = onConnect,
isFavorite = true,
- onToggleFavorite = { onToggleFavorite(server.ip) }
+ onToggleFavorite = { onToggleFavorite(server.ip) },
+ isConnecting = isConnecting,
+ enabled = connectingIp == null,
)
}
}
@@ -99,12 +104,15 @@ fun ServerListScreen(
items(others) { server ->
val isConnected = isServerConnected(connectionState, server)
+ val isConnecting = connectingIp == server.ip
ServerListItem(
server = server,
isConnected = isConnected,
onServerClick = onConnect,
isFavorite = false,
- onToggleFavorite = { onToggleFavorite(server.ip) }
+ onToggleFavorite = { onToggleFavorite(server.ip) },
+ isConnecting = isConnecting,
+ enabled = connectingIp == null,
)
}
@@ -136,7 +144,7 @@ fun ServerListScreen(
) {
OutlinedButton(
onClick = onScan,
- enabled = !isScanning,
+ enabled = !isScanning && connectingIp == null,
modifier = Modifier.weight(1f)
) {
if (isScanning) {
@@ -147,6 +155,7 @@ fun ServerListScreen(
}
OutlinedButton(
onClick = { showAddDialog = true },
+ enabled = connectingIp == null,
modifier = Modifier.weight(1f)
) {
Text("Add Manually")
diff --git a/app/src/main/java/com/inputleaf/android/ui/SettingsScreen.kt b/app/src/main/java/com/inputleaf/android/ui/SettingsScreen.kt
index 8b5c9f77..052f4697 100644
--- a/app/src/main/java/com/inputleaf/android/ui/SettingsScreen.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/SettingsScreen.kt
@@ -27,7 +27,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.inputleaf.android.ui.components.CircularAvatar
import com.inputleaf.android.ui.components.GradientCard
-import com.inputleaf.android.ui.components.MaterialToggleSwitch
import com.inputleaf.android.ui.components.SectionHeader
import com.inputleaf.android.ui.components.SettingsRow
import com.inputleaf.android.ui.components.ThemeModeOption
@@ -72,11 +71,12 @@ fun SettingsScreen(
onBack: () -> Unit,
) {
val context = LocalContext.current
+ val cursorAvailable = canDrawOverlays || accessibilityAvailable
val versionName = remember(context) {
try {
- context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.4.1"
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.4.2"
} catch (_: Exception) {
- "1.4.1"
+ "1.4.2"
}
}
val installSource = remember(context) { UpdateService.getInstallSource(context) }
@@ -151,9 +151,9 @@ fun SettingsScreen(
icon = Icons.Rounded.Build,
title = "Auto-connect on launch",
trailingContent = {
- MaterialToggleSwitch(
+ Switch(
checked = autoConnect,
- onCheckedChange = onAutoConnectChange
+ onCheckedChange = onAutoConnectChange,
)
}
)
@@ -203,7 +203,7 @@ fun SettingsScreen(
padding = 0.dp
) {
Column {
- if (!canDrawOverlays) {
+ if (!cursorAvailable) {
// Permission warning
Row(
modifier = Modifier
@@ -222,13 +222,13 @@ fun SettingsScreen(
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
- text = "Overlay permission required",
+ text = "Overlay or Accessibility required",
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium
)
Text(
- text = "Required to show cursor on screen",
+ text = "Grant overlay permission or enable Accessibility Service to show the cursor",
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f),
style = MaterialTheme.typography.bodySmall
)
@@ -245,16 +245,19 @@ fun SettingsScreen(
SettingsRow(
icon = Icons.Rounded.Info,
title = "Show cursor overlay",
- subtitle = if (canDrawOverlays) "Display cursor when active" else "Grant permission first",
+ subtitle = when {
+ canDrawOverlays || accessibilityAvailable -> "Display cursor when active"
+ else -> "Needs overlay permission or Accessibility"
+ },
trailingContent = {
- MaterialToggleSwitch(
+ Switch(
checked = showCursor,
onCheckedChange = onShowCursorChange,
- enabled = canDrawOverlays
+ enabled = cursorAvailable,
)
}
)
- if (showCursor && canDrawOverlays) {
+ if (showCursor && cursorAvailable) {
SettingsRow(
icon = Icons.Rounded.Edit,
title = "Cursor style",
diff --git a/app/src/main/java/com/inputleaf/android/ui/SplashActivity.kt b/app/src/main/java/com/inputleaf/android/ui/SplashActivity.kt
deleted file mode 100644
index becc2bfb..00000000
--- a/app/src/main/java/com/inputleaf/android/ui/SplashActivity.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-package com.inputleaf.android.ui
-
-import android.content.Intent
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.graphics.Color
-import com.inputleaf.android.ui.theme.MistGreen
-import com.inputleaf.android.ui.theme.MistGreenDim
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.inputleaf.android.R
-import kotlinx.coroutines.delay
-
-class SplashActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- setContent {
- SplashScreen {
- startActivity(Intent(this@SplashActivity, MainActivity::class.java))
- overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
- finish()
- }
- }
- }
-}
-
-@Composable
-fun SplashScreen(onTimeout: () -> Unit) {
- var startAnimation by remember { mutableStateOf(false) }
- val alphaAnim by animateFloatAsState(
- targetValue = if (startAnimation) 1f else 0f,
- animationSpec = tween(durationMillis = 800),
- label = "splash_alpha"
- )
-
- LaunchedEffect(Unit) {
- startAnimation = true
- delay(2000L)
- onTimeout()
- }
-
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(Color(0xFF0C3244)),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_splash_logo),
- contentDescription = "Input Leaf Logo",
- modifier = Modifier
- .size(200.dp)
- .alpha(alphaAnim)
- )
-
- Spacer(modifier = Modifier.height(24.dp))
-
- Image(
- painter = painterResource(id = R.drawable.ic_splash_text),
- contentDescription = "Input Leaf",
- modifier = Modifier
- .height(48.dp)
- .alpha(alphaAnim),
- contentScale = androidx.compose.ui.layout.ContentScale.Fit
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Text(
- text = "Android extension of Input Leap",
- color = MistGreen,
- fontSize = 13.sp,
- modifier = Modifier.alpha(alphaAnim)
- )
-
- Text(
- text = "Open Source KVM Software",
- color = MistGreenDim,
- fontSize = 12.sp,
- modifier = Modifier.alpha(alphaAnim)
- )
- }
-}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/AnimatedBottomNavigation.kt b/app/src/main/java/com/inputleaf/android/ui/components/AnimatedBottomNavigation.kt
deleted file mode 100644
index 45ff9169..00000000
--- a/app/src/main/java/com/inputleaf/android/ui/components/AnimatedBottomNavigation.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.inputleaf.android.ui.components
-
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.unit.dp
-
-data class NavItem(
- val label: String,
- val icon: ImageVector,
- val route: String
-)
-
-@Composable
-fun AnimatedBottomNavigation(
- items: List,
- selectedIndex: Int,
- onItemSelected: (Int) -> Unit,
- modifier: Modifier = Modifier
-) {
- if (items.isEmpty()) return
-
- NavigationBar(
- modifier = modifier.fillMaxWidth(),
- containerColor = MaterialTheme.colorScheme.surfaceContainer,
- tonalElevation = 0.dp
- ) {
- items.forEachIndexed { index, item ->
- NavigationBarItem(
- icon = { Icon(item.icon, contentDescription = item.label) },
- label = { Text(item.label) },
- selected = selectedIndex == index,
- onClick = { onItemSelected(index) }
- )
- }
- }
-}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/MarkdownContent.kt b/app/src/main/java/com/inputleaf/android/ui/components/MarkdownContent.kt
new file mode 100644
index 00000000..7d455e32
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/ui/components/MarkdownContent.kt
@@ -0,0 +1,412 @@
+package com.inputleaf.android.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLinkStyles
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+sealed interface MarkdownBlock {
+ data class Heading(val level: Int, val text: String) : MarkdownBlock
+ data class ListItem(val marker: String, val text: String, val isOrdered: Boolean) : MarkdownBlock
+ data class Paragraph(val text: String) : MarkdownBlock
+ data class CodeBlock(val code: String, val language: String = "") : MarkdownBlock
+ data class Blockquote(val text: String) : MarkdownBlock
+ data object Divider : MarkdownBlock
+}
+
+object MarkdownParser {
+
+ private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)$")
+ private val BULLET_REGEX = Regex("^([*+\\-•])\\s+(.+)$")
+ private val ORDERED_REGEX = Regex("^(\\d+\\.)\\s+(.+)$")
+ private val HR_REGEX = Regex("^(---|\\*\\*\\*|___)\\s*$")
+ private val BLOCKQUOTE_REGEX = Regex("^>\\s*(.*)$")
+
+ // Inline regex tokens
+ private val INLINE_TOKEN_REGEX = Regex(
+ "(\\[[^\\]]+]\\([^)]+\\))|" + // [text](url)
+ "(https?://[^\\s<>\"]+)|" + // raw url
+ "(`[^`]+`)|" + // `code`
+ "(\\*\\*[^\\*]+\\*\\*)|" + // **bold**
+ "(__[^_]+__)|" + // __bold__
+ "(\\*[^*]+\\*)|" + // *italic*
+ "(_[^_]+_)|" + // _italic_
+ "(@[a-zA-Z0-9_\\-]+)" // @mention
+ )
+
+ fun parseBlocks(markdown: String): List {
+ val lines = markdown.lines()
+ val blocks = mutableListOf()
+ var inCodeBlock = false
+ val codeBlockLines = mutableListOf()
+ var codeBlockLang = ""
+
+ for (line in lines) {
+ val trimmed = line.trimEnd()
+
+ if (trimmed.startsWith("```")) {
+ if (inCodeBlock) {
+ blocks.add(MarkdownBlock.CodeBlock(codeBlockLines.joinToString("\n"), codeBlockLang))
+ codeBlockLines.clear()
+ inCodeBlock = false
+ codeBlockLang = ""
+ } else {
+ inCodeBlock = true
+ codeBlockLang = trimmed.removePrefix("```").trim()
+ }
+ continue
+ }
+
+ if (inCodeBlock) {
+ codeBlockLines.add(line)
+ continue
+ }
+
+ val trimmedLine = trimmed.trim()
+ if (trimmedLine.isEmpty()) {
+ continue
+ }
+
+ val hrMatch = HR_REGEX.matchEntire(trimmedLine)
+ if (hrMatch != null) {
+ blocks.add(MarkdownBlock.Divider)
+ continue
+ }
+
+ val headingMatch = HEADING_REGEX.matchEntire(trimmedLine)
+ if (headingMatch != null) {
+ val level = headingMatch.groupValues[1].length
+ val text = headingMatch.groupValues[2].trim()
+ blocks.add(MarkdownBlock.Heading(level, text))
+ continue
+ }
+
+ val bulletMatch = BULLET_REGEX.matchEntire(trimmedLine)
+ if (bulletMatch != null) {
+ val marker = bulletMatch.groupValues[1]
+ val text = bulletMatch.groupValues[2].trim()
+ blocks.add(MarkdownBlock.ListItem(marker = marker, text = text, isOrdered = false))
+ continue
+ }
+
+ val orderedMatch = ORDERED_REGEX.matchEntire(trimmedLine)
+ if (orderedMatch != null) {
+ val marker = orderedMatch.groupValues[1]
+ val text = orderedMatch.groupValues[2].trim()
+ blocks.add(MarkdownBlock.ListItem(marker = marker, text = text, isOrdered = true))
+ continue
+ }
+
+ val bqMatch = BLOCKQUOTE_REGEX.matchEntire(trimmedLine)
+ if (bqMatch != null) {
+ blocks.add(MarkdownBlock.Blockquote(bqMatch.groupValues[1].trim()))
+ continue
+ }
+
+ blocks.add(MarkdownBlock.Paragraph(trimmedLine))
+ }
+
+ if (inCodeBlock) {
+ blocks.add(MarkdownBlock.CodeBlock(codeBlockLines.joinToString("\n"), codeBlockLang))
+ }
+
+ return blocks
+ }
+
+ /**
+ * Whether a URL out of a release body may be handed to [LinkAnnotation.Url].
+ *
+ * Changelog text is fetched from the GitHub release API, so a tampered,
+ * typosquatted or compromised body can name any scheme. The default
+ * `AndroidUriHandler` dispatches whatever it is through `ACTION_VIEW`, which turns
+ * one tap in the update dialog into an arbitrary implicit intent. Restricted to
+ * http(s), and deliberately fails closed: anything not matching exactly, including
+ * odd casing or leading whitespace, renders as plain text instead.
+ *
+ * Both link branches in [parseInline] go through this so they cannot drift apart
+ * again -- the raw-URL branch was already restricted while the Markdown-link
+ * branch was not.
+ */
+ internal fun isDispatchableUrl(url: String): Boolean =
+ url.startsWith("http://") || url.startsWith("https://")
+
+ fun parseInline(
+ text: String,
+ linkColor: Color = Color(0xFF1E88E5),
+ codeBackground: Color = Color.LightGray.copy(alpha = 0.3f),
+ onSurfaceColor: Color = Color.Unspecified,
+ ): AnnotatedString = buildAnnotatedString {
+ var lastIndex = 0
+ val matches = INLINE_TOKEN_REGEX.findAll(text)
+
+ for (match in matches) {
+ if (match.range.first > lastIndex) {
+ append(text.substring(lastIndex, match.range.first))
+ }
+
+ val token = match.value
+ when {
+ // Markdown link: [text](url)
+ token.startsWith("[") && token.contains("](") && token.endsWith(")") -> {
+ val label = token.substringAfter("[").substringBefore("](")
+ val url = token.substringAfter("](").substringBeforeLast(")")
+ if (isDispatchableUrl(url)) {
+ withLink(
+ LinkAnnotation.Url(
+ url = url,
+ styles = TextLinkStyles(
+ style = SpanStyle(
+ color = linkColor,
+ textDecoration = TextDecoration.Underline,
+ fontWeight = FontWeight.Medium
+ )
+ )
+ )
+ ) {
+ append(label)
+ }
+ } else {
+ append(label)
+ }
+ }
+
+ // Raw URL
+ isDispatchableUrl(token) -> {
+ withLink(
+ LinkAnnotation.Url(
+ url = token,
+ styles = TextLinkStyles(
+ style = SpanStyle(
+ color = linkColor,
+ textDecoration = TextDecoration.Underline
+ )
+ )
+ )
+ ) {
+ append(token)
+ }
+ }
+
+ // Inline code: `code`
+ token.startsWith("`") && token.endsWith("`") && token.length >= 2 -> {
+ val codeText = token.substring(1, token.length - 1)
+ withStyle(
+ SpanStyle(
+ fontFamily = FontFamily.Monospace,
+ background = codeBackground,
+ fontSize = 12.sp
+ )
+ ) {
+ append(" $codeText ")
+ }
+ }
+
+ // Bold: **text** or __text__
+ (token.startsWith("**") && token.endsWith("**") && token.length >= 4) ||
+ (token.startsWith("__") && token.endsWith("__") && token.length >= 4) -> {
+ val boldText = token.substring(2, token.length - 2)
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(boldText)
+ }
+ }
+
+ // Italic: *text* or _text_
+ (token.startsWith("*") && token.endsWith("*") && token.length >= 2) ||
+ (token.startsWith("_") && token.endsWith("_") && token.length >= 2) -> {
+ val italicText = token.substring(1, token.length - 1)
+ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
+ append(italicText)
+ }
+ }
+
+ // GitHub mention: @user
+ token.startsWith("@") -> {
+ val username = token.removePrefix("@")
+ withLink(
+ LinkAnnotation.Url(
+ url = "https://github.com/$username",
+ styles = TextLinkStyles(
+ style = SpanStyle(
+ color = linkColor,
+ fontWeight = FontWeight.SemiBold
+ )
+ )
+ )
+ ) {
+ append(token)
+ }
+ }
+
+ else -> {
+ append(token)
+ }
+ }
+
+ lastIndex = match.range.last + 1
+ }
+
+ if (lastIndex < text.length) {
+ append(text.substring(lastIndex))
+ }
+ }
+}
+
+@Composable
+fun MarkdownContent(
+ markdown: String,
+ modifier: Modifier = Modifier,
+ linkColor: Color = MaterialTheme.colorScheme.primary,
+ codeBackground: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
+) {
+ val blocks = remember(markdown) { MarkdownParser.parseBlocks(markdown) }
+
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ blocks.forEach { block ->
+ when (block) {
+ is MarkdownBlock.Heading -> {
+ val (style, topPad) = when (block.level) {
+ 1 -> MaterialTheme.typography.titleMedium to 8.dp
+ 2 -> MaterialTheme.typography.titleSmall to 6.dp
+ else -> MaterialTheme.typography.labelLarge to 4.dp
+ }
+ Text(
+ text = MarkdownParser.parseInline(block.text, linkColor, codeBackground),
+ style = style,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(top = topPad)
+ )
+ }
+
+ is MarkdownBlock.ListItem -> {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ if (block.isOrdered) {
+ Text(
+ text = block.marker,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ lineHeight = 18.sp
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .padding(top = 7.dp)
+ .size(4.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary)
+ )
+ }
+ Text(
+ text = MarkdownParser.parseInline(block.text, linkColor, codeBackground),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ lineHeight = 18.sp,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
+ is MarkdownBlock.Paragraph -> {
+ Text(
+ text = MarkdownParser.parseInline(block.text, linkColor, codeBackground),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ lineHeight = 18.sp
+ )
+ }
+
+ is MarkdownBlock.CodeBlock -> {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .background(codeBackground)
+ .padding(8.dp)
+ .horizontalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = block.code,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ lineHeight = 16.sp
+ )
+ }
+ }
+
+ is MarkdownBlock.Blockquote -> {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(4.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .width(3.dp)
+ .size(24.dp)
+ .background(MaterialTheme.colorScheme.primary)
+ )
+ Text(
+ text = MarkdownParser.parseInline(block.text, linkColor, codeBackground),
+ style = MaterialTheme.typography.bodySmall,
+ fontStyle = FontStyle.Italic,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ is MarkdownBlock.Divider -> {
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 4.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/MaterialToggleSwitch.kt b/app/src/main/java/com/inputleaf/android/ui/components/MaterialToggleSwitch.kt
deleted file mode 100644
index 1479cfec..00000000
--- a/app/src/main/java/com/inputleaf/android/ui/components/MaterialToggleSwitch.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.inputleaf.android.ui.components
-
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.offset
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.dp
-
-@Composable
-fun MaterialToggleSwitch(
- checked: Boolean,
- onCheckedChange: (Boolean) -> Unit,
- modifier: Modifier = Modifier,
- enabled: Boolean = true
-) {
- val thumbOffset by animateFloatAsState(
- targetValue = if (checked) 20f else 2f,
- animationSpec = tween(durationMillis = 300),
- label = "thumb_offset"
- )
-
- val scheme = MaterialTheme.colorScheme
- Box(
- modifier = modifier
- .width(52.dp)
- .height(32.dp)
- .clip(RoundedCornerShape(16.dp))
- .background(
- if (checked) Brush.linearGradient(
- listOf(scheme.primary, scheme.primary.copy(alpha = 0.85f)),
- ) else Brush.linearGradient(
- listOf(scheme.surfaceVariant, scheme.outlineVariant.copy(alpha = 0.5f)),
- ),
- )
- .clickable(
- enabled = enabled,
- interactionSource = remember { MutableInteractionSource() },
- indication = null
- ) {
- onCheckedChange(!checked)
- },
- contentAlignment = Alignment.CenterStart
- ) {
- Box(
- modifier = Modifier
- .offset(x = thumbOffset.dp)
- .size(28.dp)
- .shadow(
- elevation = if (checked) 6.dp else 3.dp,
- shape = CircleShape,
- ambientColor = if (checked) scheme.primary.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.2f)
- )
- .clip(CircleShape)
- .background(Color.White)
- )
- }
-}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/UpdateDialogs.kt b/app/src/main/java/com/inputleaf/android/ui/components/UpdateDialogs.kt
index dc9e9855..6437999f 100644
--- a/app/src/main/java/com/inputleaf/android/ui/components/UpdateDialogs.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/components/UpdateDialogs.kt
@@ -82,18 +82,13 @@ fun UpdateAvailableDialog(
Box(
modifier = Modifier
.fillMaxWidth()
- .heightIn(max = 200.dp)
+ .heightIn(max = 220.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(12.dp)
.verticalScroll(rememberScrollState())
) {
- Text(
- text = changelog,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- lineHeight = 18.sp
- )
+ MarkdownContent(markdown = changelog)
}
Spacer(modifier = Modifier.height(12.dp))
}
diff --git a/app/src/main/java/com/inputleaf/android/update/ChangelogProvider.kt b/app/src/main/java/com/inputleaf/android/update/ChangelogProvider.kt
deleted file mode 100644
index df5f9992..00000000
--- a/app/src/main/java/com/inputleaf/android/update/ChangelogProvider.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.inputleaf.android.update
-
-data class VersionChangelog(
- val versionName: String,
- val versionCode: Int,
- val highlights: List
-)
-
-object ChangelogProvider {
-
- val RELEASES = listOf(
- VersionChangelog(
- versionName = "1.4.1",
- versionCode = 7,
- highlights = listOf(
- "Added About & Community section with developer portfolio, GitHub, LinkedIn, and contributor credits.",
- "Added update checker with intelligent F-Droid and GitHub release redirection.",
- "Added 'What's New' changelog display upon upgrading.",
- "Enhanced input injection performance and connection stability."
- )
- ),
- VersionChangelog(
- versionName = "1.4.0",
- versionCode = 6,
- highlights = listOf(
- "Added TLS-secured connections with TOFU certificate pinning.",
- "Added client certificate management and custom fingerprints.",
- "Modernized Material 3 UI with enhanced connection cards and favorites."
- )
- )
- )
-
- fun getChangelog(versionName: String): VersionChangelog {
- val clean = versionName.removePrefix("v").removePrefix("V")
- return RELEASES.firstOrNull { it.versionName == clean } ?: RELEASES.first()
- }
-}
diff --git a/app/src/main/java/com/inputleaf/android/update/UpdateService.kt b/app/src/main/java/com/inputleaf/android/update/UpdateService.kt
index 5578614a..9a4d8538 100644
--- a/app/src/main/java/com/inputleaf/android/update/UpdateService.kt
+++ b/app/src/main/java/com/inputleaf/android/update/UpdateService.kt
@@ -30,6 +30,12 @@ sealed interface UpdateCheckResult {
data class Error(val message: String) : UpdateCheckResult
}
+data class VersionChangelog(
+ val versionName: String,
+ val versionCode: Int,
+ val highlights: List,
+)
+
object UpdateService {
internal const val GITHUB_API_LATEST_RELEASE =
@@ -38,6 +44,41 @@ object UpdateService {
internal const val GITHUB_RELEASES_WEB_URL =
"https://github.com/anasvhora284/input-leaf/releases/latest"
+ private val RELEASES = listOf(
+ VersionChangelog(
+ versionName = "1.4.2",
+ versionCode = 8,
+ highlights = listOf(
+ "Show the mouse cursor when using Accessibility Service without draw-over-apps permission.",
+ "Use a real HID keyboard (Shizuku) in Shizuku and Accessibility modes, attached only while the cursor is on this device.",
+ ),
+ ),
+ VersionChangelog(
+ versionName = "1.4.1",
+ versionCode = 7,
+ highlights = listOf(
+ "Added About & Community section with developer portfolio, GitHub, LinkedIn, and contributor credits.",
+ "Added update checker with intelligent F-Droid and GitHub release redirection.",
+ "Added 'What's New' changelog display upon upgrading.",
+ "Enhanced input injection performance and connection stability.",
+ ),
+ ),
+ VersionChangelog(
+ versionName = "1.4.0",
+ versionCode = 6,
+ highlights = listOf(
+ "Added TLS-secured connections with TOFU certificate pinning.",
+ "Added client certificate management and custom fingerprints.",
+ "Modernized Material 3 UI with enhanced connection cards and favorites.",
+ ),
+ ),
+ )
+
+ fun getChangelog(versionName: String): VersionChangelog {
+ val clean = versionName.removePrefix("v").removePrefix("V")
+ return RELEASES.firstOrNull { it.versionName == clean } ?: RELEASES.first()
+ }
+
fun getInstallSource(context: Context): InstallSource {
val installer = readInstallerPackageName(context)
return resolveInstallSource(installer)
@@ -194,10 +235,10 @@ internal fun resolveInstallSource(installerPackage: String?): InstallSource {
}
internal fun versionNameFrom(packageInfo: PackageInfo?): String =
- packageInfo?.versionName ?: "1.4.1"
+ packageInfo?.versionName ?: "1.4.2"
internal fun versionCodeFrom(packageInfo: PackageInfo?, sdkInt: Int = Build.VERSION.SDK_INT): Long {
- if (packageInfo == null) return 7L
+ if (packageInfo == null) return 8L
return if (sdkInt >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
diff --git a/app/src/main/java/com/inputleaf/android/util/DeviceIdentity.kt b/app/src/main/java/com/inputleaf/android/util/DeviceIdentity.kt
index 8f43b957..70fa9d9f 100644
--- a/app/src/main/java/com/inputleaf/android/util/DeviceIdentity.kt
+++ b/app/src/main/java/com/inputleaf/android/util/DeviceIdentity.kt
@@ -2,49 +2,22 @@ package com.inputleaf.android.util
import android.os.Build
import androidx.compose.ui.graphics.Color
+import android.provider.Settings
import com.inputleaf.android.R
-import com.jaredrummler.android.device.DeviceName
object DeviceIdentity {
- fun getMarketingName(): String {
- return try {
- val name = DeviceName.getDeviceName()
- if (isUnresolvedName(name)) {
- formatFallbackName()
- } else {
- name
- }
- } catch (e: Exception) {
- formatFallbackName()
- }
- }
+ fun getMarketingName(): String = formatFallbackName()
fun requestMarketingName(context: android.content.Context, onResult: (String) -> Unit) {
- try {
- // 1. Try to get the user-set or default system device name (e.g. "OnePlus Nord 4")
- val systemName = android.provider.Settings.Global.getString(
- context.contentResolver,
- android.provider.Settings.Global.DEVICE_NAME
- )
-
- if (!isUnresolvedName(systemName) && systemName != null) {
- onResult(systemName)
- return
- }
-
- // 2. Fallback to external library (async)
- DeviceName.with(context).request { info, _ ->
- val name = info?.marketName ?: info?.model ?: info?.codename
- if (!isUnresolvedName(name) && name != null) {
- onResult(name)
- } else {
- onResult(getMarketingName())
- }
- }
- } catch (e: Exception) {
- onResult(getMarketingName())
- }
+ val systemName = Settings.Global.getString(
+ context.contentResolver,
+ Settings.Global.DEVICE_NAME,
+ )
+ onResult(
+ if (!isUnresolvedName(systemName) && systemName != null) systemName
+ else formatFallbackName(),
+ )
}
private fun isUnresolvedName(name: String?): Boolean {
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 016f86c1..1f1f19c6 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -4,11 +4,4 @@
- @color/splash_background
- @color/splash_background
-
-
diff --git a/app/src/test/java/com/inputleaf/android/inject/InputInjectorTest.kt b/app/src/test/java/com/inputleaf/android/inject/InputInjectorTest.kt
new file mode 100644
index 00000000..8f3be9af
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/inject/InputInjectorTest.kt
@@ -0,0 +1,29 @@
+package com.inputleaf.android.inject
+
+import com.google.common.truth.Truth.assertThat
+import com.inputleaf.android.model.InputLeapEvent
+import org.junit.Test
+
+class InputInjectorTest {
+ @Test
+ fun `default interface methods have expected fallback behavior`() {
+ val injector = object : InputInjector {
+ override suspend fun connect(): Boolean = true
+ override fun send(event: InputLeapEvent) {}
+ override fun disconnect() {}
+ override fun isAvailable(): Boolean = true
+ override val name: String = "TestInjector"
+ }
+
+ injector.setHidKeyboardAttached(true)
+ injector.setHidMouseAttached(true)
+ assertThat(injector.usesNativePointer()).isFalse()
+ assertThat(injector.nativePointerState()).isEqualTo(NativePointerState.NONE)
+ assertThat(injector.expectsNativePointer()).isFalse()
+ injector.setOnNativePointerStateChanged(null)
+ injector.updateScreenSize(1080, 2400)
+ injector.updatePointerSpeed(2)
+ injector.onHidMouseEnter(100, 200)
+ injector.onHidMouseLeave()
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/inject/InputMethodResolverTest.kt b/app/src/test/java/com/inputleaf/android/inject/InputMethodResolverTest.kt
deleted file mode 100644
index 66a12358..00000000
--- a/app/src/test/java/com/inputleaf/android/inject/InputMethodResolverTest.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.inputleaf.android.inject
-
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-
-class InputMethodResolverTest {
-
- @Test
- fun `resolve auto prefers shizuku when both are available`() {
- val result = InputMethodResolver.resolve(
- preferredMethod = "auto",
- isShizukuAvailable = true,
- isAccessibilityAvailable = true
- )
- assertThat(result).isEqualTo(ResolvedMethod.SHIZUKU)
- }
-
- @Test
- fun `resolve auto falls back to accessibility when shizuku unavailable`() {
- val result = InputMethodResolver.resolve(
- preferredMethod = "auto",
- isShizukuAvailable = false,
- isAccessibilityAvailable = true
- )
- assertThat(result).isEqualTo(ResolvedMethod.ACCESSIBILITY)
- }
-
- @Test
- fun `resolve auto returns none when neither is available`() {
- val result = InputMethodResolver.resolve(
- preferredMethod = "auto",
- isShizukuAvailable = false,
- isAccessibilityAvailable = false
- )
- assertThat(result).isEqualTo(ResolvedMethod.NONE)
- }
-
- @Test
- fun `resolve shizuku returns shizuku when available`() {
- val result = InputMethodResolver.resolve(
- preferredMethod = "shizuku",
- isShizukuAvailable = true,
- isAccessibilityAvailable = true
- )
- assertThat(result).isEqualTo(ResolvedMethod.SHIZUKU)
- }
-
- @Test
- fun `resolve shizuku returns none when unavailable`() {
- val result = InputMethodResolver.resolve(
- preferredMethod = "shizuku",
- isShizukuAvailable = false,
- isAccessibilityAvailable = true
- )
- assertThat(result).isEqualTo(ResolvedMethod.NONE)
- }
-
- @Test
- fun `resolve accessibility returns accessibility when available`() {
- val result = InputMethodResolver.resolve(
- preferredMethod = "accessibility",
- isShizukuAvailable = true,
- isAccessibilityAvailable = true
- )
- assertThat(result).isEqualTo(ResolvedMethod.ACCESSIBILITY)
- }
-
- @Test
- fun `resolve accessibility returns none when unavailable`() {
- val result = InputMethodResolver.resolve(
- preferredMethod = "accessibility",
- isShizukuAvailable = true,
- isAccessibilityAvailable = false
- )
- assertThat(result).isEqualTo(ResolvedMethod.NONE)
- }
-
- @Test
- fun `resolve invalid method returns none`() {
- val result = InputMethodResolver.resolve(
- preferredMethod = "unknown_method",
- isShizukuAvailable = true,
- isAccessibilityAvailable = true
- )
- assertThat(result).isEqualTo(ResolvedMethod.NONE)
- }
-}
diff --git a/app/src/test/java/com/inputleaf/android/model/InputLeapEventTest.kt b/app/src/test/java/com/inputleaf/android/model/InputLeapEventTest.kt
new file mode 100644
index 00000000..bde16bd2
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/model/InputLeapEventTest.kt
@@ -0,0 +1,16 @@
+package com.inputleaf.android.model
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class InputLeapEventTest {
+ @Test
+ fun `infoAck event is instantiable and has equality`() {
+ val event1 = InputLeapEvent.InfoAck()
+ val event2 = InputLeapEvent.InfoAck(Unit)
+ assertThat(event1).isEqualTo(event2)
+ assertThat(event1.dummy).isEqualTo(Unit)
+ assertThat(event1.hashCode()).isEqualTo(event2.hashCode())
+ assertThat(event1.toString()).contains("InfoAck")
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt b/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt
index c4831bc8..a2e186e0 100644
--- a/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt
+++ b/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt
@@ -27,6 +27,7 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLException
import javax.net.ssl.SSLServerSocket
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManager
@@ -775,6 +776,149 @@ class InputLeapConnectionTest {
}
}
+ @Test fun `sendDataInfo and sendInfoAck send valid frames and CIAK maps to InfoAck`(): Unit = runBlocking {
+ val sendEvent = CompletableDeferred()
+ val receivedFrames = CompletableDeferred()
+ LoopbackServer { socket, _ ->
+ performServerHandshake(socket)
+ sendEvent.awaitBlocking()
+ writeFrame(DataOutputStream(socket.outputStream), "CIAK".toByteArray())
+ val dinf = readFrame(DataInputStream(socket.inputStream))
+ assertThat(String(dinf, 0, 4)).isEqualTo("DINF")
+ val ciak = readFrame(DataInputStream(socket.inputStream))
+ assertThat(String(ciak, 0, 4)).isEqualTo("CIAK")
+ val calv = readFrame(DataInputStream(socket.inputStream))
+ assertThat(String(calv, 0, 4)).isEqualTo("CALV")
+ receivedFrames.complete(Unit)
+ socket.inputStream.read()
+ }.use { server ->
+ connection(
+ server.port,
+ transportPolicy = ConnectionTransportPolicy.PLAIN_ONLY,
+ preferredTransport = ServerTransport.PLAIN,
+ ).useConnection { connection ->
+ assertThat(connection.connect("android", 1920, 1080))
+ .isInstanceOf(ConnectResult.Ok::class.java)
+
+ val mappedAck = async(start = CoroutineStart.UNDISPATCHED) {
+ connection.events.first { it is InputLeapEvent.InfoAck }
+ }
+
+ sendEvent.complete(Unit)
+
+ connection.sendDataInfo(1920, 1080, 50, 60)
+ connection.sendInfoAck()
+ connection.sendKeepAlive()
+ connection.clearHandshakeTimeout()
+
+ assertThat(withTimeout(TEST_TIMEOUT_MS) { mappedAck.await() })
+ .isEqualTo(InputLeapEvent.InfoAck())
+ withTimeout(TEST_TIMEOUT_MS) { receivedFrames.await() }
+ }
+
+ val unconnected = connection(
+ server.port,
+ transportPolicy = ConnectionTransportPolicy.PLAIN_ONLY,
+ )
+ unconnected.sendDataInfo(1920, 1080, 0, 0)
+ unconnected.sendKeepAlive()
+ unconnected.sendInfoAck()
+ unconnected.clearHandshakeTimeout()
+ }
+ }
+
+ @Test fun `pinned fingerprint mismatch logs warning and fails`(): Unit = runBlocking {
+ val identity = TestTlsIdentity.create()
+ TlsLoopbackServer(identity.context) { socket, _ ->
+ runCatching {
+ socket.startHandshake()
+ performServerHandshake(socket)
+ }
+ }.use { server ->
+ connection(
+ server.port,
+ transportPolicy = ConnectionTransportPolicy.TLS_ONLY,
+ pinnedFingerprint = "0".repeat(64),
+ onCertificate = { throw SSLException("Certificate fingerprint mismatch") },
+ ).useConnection { connection ->
+ val result = connection.connect("android", 1920, 1080)
+ assertThat(result).isInstanceOf(ConnectResult.Failed::class.java)
+ assertThat((result as ConnectResult.Failed).reason)
+ .isEqualTo(ConnectResult.FailureReason.CERTIFICATE_MISMATCH)
+ }
+ }
+ }
+
+ @Test fun `server requiring client certificate logs warning and fails`(): Unit = runBlocking {
+ LoopbackServer { socket, _ ->
+ runCatching {
+ val input = DataInputStream(socket.inputStream)
+ val firstByte = input.readUnsignedByte()
+ if (firstByte == 0x16) {
+ val restHeader = ByteArray(4)
+ input.readFully(restHeader)
+ val len = ((restHeader[2].toInt() and 0xFF) shl 8) or (restHeader[3].toInt() and 0xFF)
+ val body = ByteArray(len)
+ input.readFully(body)
+ val alert = byteArrayOf(0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x74)
+ socket.outputStream.write(alert)
+ socket.outputStream.flush()
+ }
+ }
+ }.use { server ->
+ connection(
+ server.port,
+ transportPolicy = ConnectionTransportPolicy.TLS_ONLY,
+ ).useConnection { connection ->
+ val result = connection.connect("android", 1920, 1080)
+ assertThat(result).isInstanceOf(ConnectResult.Failed::class.java)
+ assertThat((result as ConnectResult.Failed).reason)
+ .isEqualTo(ConnectResult.FailureReason.CLIENT_CERT_REQUIRED)
+ }
+ }
+ }
+
+ @Test fun `tls against plain server logs warning and fails when all transports fail`(): Unit = runBlocking {
+ LoopbackServer { socket, _ ->
+ writeFrame(DataOutputStream(socket.outputStream), helloBody())
+ }.use { server ->
+ connection(
+ server.port,
+ transportPolicy = ConnectionTransportPolicy.TLS_ONLY,
+ preferredTransport = ServerTransport.TLS,
+ ).useConnection { connection ->
+ val result = connection.connect("android", 1920, 1080)
+ assertThat(result).isInstanceOf(ConnectResult.Failed::class.java)
+ assertThat((result as ConnectResult.Failed).reason)
+ .isEqualTo(ConnectResult.FailureReason.TLS_AGAINST_PLAIN_SERVER)
+ }
+ }
+ }
+
+ @Test fun `incomplete handshake logs error and returns failure`(): Unit = runBlocking {
+ LoopbackServer { socket, _ ->
+ val out = DataOutputStream(socket.outputStream)
+ repeat(32) {
+ writeFrame(out, "COUT".toByteArray())
+ }
+ runCatching {
+ while (socket.inputStream.read() != -1) Unit
+ }
+ }.use { server ->
+ connection(
+ server.port,
+ transportPolicy = ConnectionTransportPolicy.PLAIN_ONLY,
+ preferredTransport = ServerTransport.PLAIN,
+ ).useConnection { connection ->
+ val result = connection.connect("android", 1920, 1080)
+ assertThat(result).isInstanceOf(ConnectResult.Failed::class.java)
+ val failed = result as ConnectResult.Failed
+ assertThat(failed.reason).isEqualTo(ConnectResult.FailureReason.HANDSHAKE)
+ assertThat(failed.detail).contains("Incomplete handshake")
+ }
+ }
+ }
+
private fun isExpectedPlainProbeTermination(failure: Exception): Boolean =
failure is EOFException ||
failure is SocketException ||
@@ -811,12 +955,6 @@ class InputLeapConnectionTest {
assertThat((result as ConnectResult.Failed).reason).isEqualTo(expectedReason)
}
- private object NoOpLogger : InputLeapConnection.Logger {
- override fun debug(message: String) = Unit
- override fun warn(message: String) = Unit
- override fun error(message: String) = Unit
- }
-
private fun connection(
port: Int,
transportPolicy: ConnectionTransportPolicy,
@@ -832,7 +970,6 @@ class InputLeapConnectionTest {
transportPolicy = transportPolicy,
clientCertificate = clientCertificate,
onCertificate = onCertificate,
- logger = NoOpLogger,
)
private suspend fun InputLeapConnection.useConnection(
diff --git a/app/src/test/java/com/inputleaf/android/network/TransportPolicyTest.kt b/app/src/test/java/com/inputleaf/android/network/TransportPolicyTest.kt
index 20d3a7b7..86895816 100644
--- a/app/src/test/java/com/inputleaf/android/network/TransportPolicyTest.kt
+++ b/app/src/test/java/com/inputleaf/android/network/TransportPolicyTest.kt
@@ -6,8 +6,7 @@ import org.junit.Test
class TransportPolicyTest {
@Test fun `TLS only never falls back to plain`() {
assertThat(
- TransportPolicy.order(
- policy = ConnectionTransportPolicy.TLS_ONLY,
+ ConnectionTransportPolicy.TLS_ONLY.order(
preferredTransport = ServerTransport.PLAIN,
)
).containsExactly(ServerTransport.TLS)
@@ -15,23 +14,33 @@ class TransportPolicyTest {
@Test fun `plain only never probes TLS`() {
assertThat(
- TransportPolicy.order(
- policy = ConnectionTransportPolicy.PLAIN_ONLY,
+ ConnectionTransportPolicy.PLAIN_ONLY.order(
preferredTransport = ServerTransport.TLS,
)
).containsExactly(ServerTransport.PLAIN)
}
+ @Test fun `order covers all policies and parameters`() {
+ for (policy in ConnectionTransportPolicy.entries) {
+ policy.order(null)
+ policy.order(ServerTransport.TLS)
+ policy.order(ServerTransport.PLAIN)
+ for (mode in ServerSecurityMode.entries) {
+ policy.order(null, mode)
+ policy.order(ServerTransport.TLS, mode)
+ policy.order(ServerTransport.PLAIN, mode)
+ }
+ }
+ }
+
@Test fun `auto tries the learned transport before its fallback`() {
assertThat(
- TransportPolicy.order(
- policy = ConnectionTransportPolicy.AUTO,
+ ConnectionTransportPolicy.AUTO.order(
preferredTransport = ServerTransport.TLS,
)
).containsExactly(ServerTransport.TLS, ServerTransport.PLAIN).inOrder()
assertThat(
- TransportPolicy.order(
- policy = ConnectionTransportPolicy.AUTO,
+ ConnectionTransportPolicy.AUTO.order(
preferredTransport = ServerTransport.PLAIN,
)
).containsExactly(ServerTransport.PLAIN, ServerTransport.TLS).inOrder()
@@ -39,8 +48,7 @@ class TransportPolicyTest {
@Test fun `auto uses TLS first even without a stored fingerprint`() {
assertThat(
- TransportPolicy.order(
- policy = ConnectionTransportPolicy.AUTO,
+ ConnectionTransportPolicy.AUTO.order(
preferredTransport = null,
)
).containsExactly(ServerTransport.TLS, ServerTransport.PLAIN).inOrder()
@@ -48,22 +56,19 @@ class TransportPolicyTest {
@Test fun `auto uses the probed server mode instead of a stale learned transport`() {
assertThat(
- TransportPolicy.order(
- policy = ConnectionTransportPolicy.AUTO,
+ ConnectionTransportPolicy.AUTO.order(
preferredTransport = ServerTransport.PLAIN,
detectedMode = ServerSecurityMode.TLS,
)
).containsExactly(ServerTransport.TLS)
assertThat(
- TransportPolicy.order(
- policy = ConnectionTransportPolicy.AUTO,
+ ConnectionTransportPolicy.AUTO.order(
preferredTransport = ServerTransport.TLS,
detectedMode = ServerSecurityMode.PLAIN,
)
).containsExactly(ServerTransport.PLAIN)
assertThat(
- TransportPolicy.order(
- policy = ConnectionTransportPolicy.AUTO,
+ ConnectionTransportPolicy.AUTO.order(
preferredTransport = null,
detectedMode = ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED,
)
diff --git a/app/src/test/java/com/inputleaf/android/protocol/KeysymTableTest.kt b/app/src/test/java/com/inputleaf/android/protocol/KeysymTableTest.kt
deleted file mode 100644
index 26b2be36..00000000
--- a/app/src/test/java/com/inputleaf/android/protocol/KeysymTableTest.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.inputleaf.android.protocol
-
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-
-class KeysymTableTest {
- @Test fun `maps letter a to HID usage 0x04`() {
- assertThat(KeysymTable.toHidUsage(0x0061)).isEqualTo(0x04)
- }
- @Test fun `maps Space to HID usage 0x2C`() {
- assertThat(KeysymTable.toHidUsage(0x0020)).isEqualTo(0x2C)
- }
- @Test fun `maps Return to HID usage 0x28`() {
- assertThat(KeysymTable.toHidUsage(0xFF0D)).isEqualTo(0x28)
- }
- @Test fun `maps F1 to HID usage 0x3A`() {
- assertThat(KeysymTable.toHidUsage(0xFFBE)).isEqualTo(0x3A)
- }
- @Test fun `returns null for unmapped keysym`() {
- assertThat(KeysymTable.toHidUsage(0x1FFFFFF)).isNull()
- }
-}
diff --git a/app/src/test/java/com/inputleaf/android/protocol/ProtocolWriterTest.kt b/app/src/test/java/com/inputleaf/android/protocol/ProtocolWriterTest.kt
index aaf945cc..3f019afd 100644
--- a/app/src/test/java/com/inputleaf/android/protocol/ProtocolWriterTest.kt
+++ b/app/src/test/java/com/inputleaf/android/protocol/ProtocolWriterTest.kt
@@ -109,6 +109,18 @@ class ProtocolWriterTest {
assertThat(ProtocolConstants.negotiateMinor(serverMinor = 4)).isEqualTo(4)
}
+ @Test fun `writes mid-session screen info update with mouse position`() {
+ val (writer, output) = writerWith()
+
+ writer.writeDataInfo(2414, 1080, 0, 0, 500, 300)
+
+ val decoded = decodeSingleFrame(output.toByteArray())
+ assertThat(decoded.declaredLength).isEqualTo(18)
+ assertThat(decoded.tag).isEqualTo("DINF")
+ assertThat(decodeShorts(decoded.payload))
+ .containsExactly(0, 0, 2414, 1080, 0, 500, 300).inOrder()
+ }
+
@Test fun `writes complete data information frame in protocol order`() {
val (writer, output) = writerWith()
diff --git a/app/src/test/java/com/inputleaf/android/service/CursorOverlayPolicyTest.kt b/app/src/test/java/com/inputleaf/android/service/CursorOverlayPolicyTest.kt
new file mode 100644
index 00000000..c2c86ad4
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/service/CursorOverlayPolicyTest.kt
@@ -0,0 +1,101 @@
+package com.inputleaf.android.service
+
+import com.google.common.truth.Truth.assertThat
+import com.inputleaf.android.inject.NativePointerState
+import org.junit.Test
+
+class CursorOverlayPolicyTest {
+
+ @Test
+ fun `native attaching hides overlay even if cursor setting is on`() {
+ assertThat(
+ CursorOverlayPolicy.shouldShowOverlay(
+ cursorSettingEnabled = true,
+ onScreen = true,
+ mouseEnabled = true,
+ native = NativePointerState.PENDING,
+ expectsNativePointer = true,
+ ),
+ ).isFalse()
+ }
+
+ @Test
+ fun `native attached hides overlay`() {
+ assertThat(
+ CursorOverlayPolicy.shouldShowOverlay(
+ cursorSettingEnabled = true,
+ onScreen = true,
+ mouseEnabled = true,
+ native = NativePointerState.ACTIVE,
+ expectsNativePointer = true,
+ ),
+ ).isFalse()
+ }
+
+ @Test
+ fun `expects native before attach completes hides overlay to avoid flash`() {
+ assertThat(
+ CursorOverlayPolicy.shouldShowOverlay(
+ cursorSettingEnabled = true,
+ onScreen = true,
+ mouseEnabled = true,
+ native = NativePointerState.NONE,
+ expectsNativePointer = true,
+ ),
+ ).isFalse()
+ }
+
+ @Test
+ fun `uhid failure shows fallback overlay when cursor setting is on`() {
+ assertThat(
+ CursorOverlayPolicy.shouldShowOverlay(
+ cursorSettingEnabled = true,
+ onScreen = true,
+ mouseEnabled = true,
+ native = NativePointerState.FALLBACK,
+ expectsNativePointer = true,
+ ),
+ ).isTrue()
+ }
+
+ @Test
+ fun `leave hides overlay regardless of native state`() {
+ for (native in NativePointerState.entries) {
+ assertThat(
+ CursorOverlayPolicy.shouldShowOverlay(
+ cursorSettingEnabled = true,
+ onScreen = false,
+ mouseEnabled = true,
+ native = native,
+ expectsNativePointer = true,
+ ),
+ ).isFalse()
+ }
+ }
+
+ @Test
+ fun `accessibility without native pointer shows overlay`() {
+ assertThat(
+ CursorOverlayPolicy.shouldShowOverlay(
+ cursorSettingEnabled = true,
+ onScreen = true,
+ mouseEnabled = true,
+ native = NativePointerState.NONE,
+ expectsNativePointer = false,
+ ),
+ ).isTrue()
+ }
+
+ @Test
+ fun `cursor setting off never shows overlay`() {
+ assertThat(
+ CursorOverlayPolicy.shouldShowOverlay(
+ cursorSettingEnabled = false,
+ onScreen = true,
+ mouseEnabled = true,
+ native = NativePointerState.FALLBACK,
+ expectsNativePointer = false,
+ ),
+ ).isFalse()
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/service/HidAttachmentControllerTest.kt b/app/src/test/java/com/inputleaf/android/service/HidAttachmentControllerTest.kt
new file mode 100644
index 00000000..72a95805
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/service/HidAttachmentControllerTest.kt
@@ -0,0 +1,69 @@
+package com.inputleaf.android.service
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+
+class HidAttachmentControllerTest {
+
+ @Test
+ fun `rapid leave then enter applies the last wanted state`() {
+ runBlocking {
+ val gate = HidAttachmentController()
+ val applied = mutableListOf()
+
+ gate.setWanted(true)
+ val first = async {
+ gate.applyLatest { wanted ->
+ if (wanted && applied.isEmpty()) {
+ Thread.sleep(40)
+ }
+ applied += wanted
+ }
+ }
+ delay(10)
+ gate.setWanted(false)
+ gate.setWanted(true)
+ val second = async {
+ gate.applyLatest { wanted -> applied += wanted }
+ }
+ first.await()
+ second.await()
+
+ assertThat(gate.wanted()).isTrue()
+ assertThat(applied.last()).isTrue()
+ assertThat(applied).contains(true)
+ }
+ }
+
+ @Test
+ fun `injector replacement reapplies wanted attach`() {
+ runBlocking {
+ val gate = HidAttachmentController()
+ val applied = mutableListOf()
+
+ gate.setWanted(true)
+ gate.applyLatest { applied += it }
+ assertThat(applied).containsExactly(true)
+
+ gate.noteInjectorChanged()
+ gate.applyLatest { applied += it }
+ assertThat(applied).containsExactly(true, true)
+ }
+ }
+
+ @Test
+ fun `stable leave applies detach`() {
+ runBlocking {
+ val gate = HidAttachmentController()
+ val applied = mutableListOf()
+ gate.setWanted(true)
+ gate.applyLatest { applied += it }
+ gate.setWanted(false)
+ gate.applyLatest { applied += it }
+ assertThat(applied).containsExactly(true, false).inOrder()
+ }
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/service/PointerSpeedTest.kt b/app/src/test/java/com/inputleaf/android/service/PointerSpeedTest.kt
new file mode 100644
index 00000000..af0e5e2f
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/service/PointerSpeedTest.kt
@@ -0,0 +1,14 @@
+package com.inputleaf.android.service
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class PointerSpeedTest {
+
+ @Test
+ fun `clampPointerSpeed enforces Android range`() {
+ assertThat(clampPointerSpeed(-10)).isEqualTo(POINTER_SPEED_MIN)
+ assertThat(clampPointerSpeed(0)).isEqualTo(0)
+ assertThat(clampPointerSpeed(10)).isEqualTo(POINTER_SPEED_MAX)
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/InputInjectorServiceClientDeathTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/InputInjectorServiceClientDeathTest.kt
new file mode 100644
index 00000000..7ec92851
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/InputInjectorServiceClientDeathTest.kt
@@ -0,0 +1,97 @@
+package com.inputleaf.android.shizuku
+
+import android.os.Binder
+import android.os.Build
+import com.google.common.truth.Truth.assertThat
+import com.inputleaf.android.shizuku.uhid.UhidChannel
+import com.inputleaf.android.shizuku.uhid.UhidProtocol
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * A client that dies must leave no HID device registered. The client's own disconnect()
+ * cannot achieve this: by then its binder calls throw DeadObjectException and are
+ * swallowed, so UHID_DESTROY is only written if the injector does it itself.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
+class InputInjectorServiceClientDeathTest {
+
+ @Test
+ fun `client death writes UHID_DESTROY for an attached mouse`() {
+ val out = ByteArrayOutputStream()
+ val service = InputInjectorService { UhidChannel.forTesting(out) }
+ service.attachClient(Binder())
+
+ assertThat(service.openVirtualMouse()).isTrue()
+ assertThat(typesIn(out.toByteArray())).contains(UhidProtocol.UHID_CREATE2)
+ assertThat(typesIn(out.toByteArray())).doesNotContain(UhidProtocol.UHID_DESTROY)
+
+ service.deathRecipientForTest().binderDied()
+
+ assertThat(typesIn(out.toByteArray())).contains(UhidProtocol.UHID_DESTROY)
+ }
+
+ @Test
+ fun `client death leaves no device open so a later injection is refused`() {
+ val service = InputInjectorService { UhidChannel.forTesting(ByteArrayOutputStream()) }
+ service.attachClient(Binder())
+ assertThat(service.openVirtualMouse()).isTrue()
+ assertThat(service.injectHidMouse(1, 0, 0, 0)).isTrue()
+
+ service.deathRecipientForTest().binderDied()
+
+ assertThat(service.injectHidMouse(1, 0, 0, 0)).isFalse()
+ }
+
+ @Test
+ fun `death handling is idempotent and safe with nothing attached`() {
+ val service = InputInjectorService { UhidChannel.forTesting(ByteArrayOutputStream()) }
+ service.attachClient(Binder())
+
+ service.deathRecipientForTest().binderDied()
+ service.deathRecipientForTest().binderDied()
+ service.destroy()
+ }
+
+ @Test
+ fun `a null token is ignored rather than crashing the injector`() {
+ val service = InputInjectorService { UhidChannel.forTesting(ByteArrayOutputStream()) }
+ service.attachClient(null)
+ assertThat(service.openVirtualMouse()).isTrue()
+ service.destroy()
+ }
+
+ @Test
+ fun `re-attaching a client replaces the previous watch`() {
+ val service = InputInjectorService { UhidChannel.forTesting(ByteArrayOutputStream()) }
+ service.attachClient(Binder())
+ service.attachClient(Binder())
+ assertThat(service.openVirtualMouse()).isTrue()
+
+ service.deathRecipientForTest().binderDied()
+
+ assertThat(service.injectHidMouse(1, 0, 0, 0)).isFalse()
+ }
+
+ private fun typesIn(packet: ByteArray): List {
+ val types = mutableListOf()
+ var offset = 0
+ while (offset + 4 <= packet.size) {
+ val type = ByteBuffer.wrap(packet, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int
+ types += type
+ offset += when (type) {
+ UhidProtocol.UHID_CREATE2 -> UhidProtocol.CREATE2_PACKET_SIZE
+ UhidProtocol.UHID_INPUT2 -> UhidProtocol.INPUT2_PACKET_SIZE
+ UhidProtocol.UHID_DESTROY -> UhidProtocol.EVENT_PACKET_SIZE
+ else -> 4
+ }
+ }
+ return types
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/InputInjectorServiceEnterTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/InputInjectorServiceEnterTest.kt
new file mode 100644
index 00000000..2c41d284
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/InputInjectorServiceEnterTest.kt
@@ -0,0 +1,170 @@
+package com.inputleaf.android.shizuku
+
+import android.os.Build
+import com.google.common.truth.Truth.assertThat
+import com.inputleaf.android.shizuku.uhid.HidMouse
+import com.inputleaf.android.shizuku.uhid.UhidChannel
+import com.inputleaf.android.shizuku.uhid.UhidProtocol
+import com.inputleaf.android.shizuku.uhid.UhidReadinessConfig
+import java.io.ByteArrayOutputStream
+import java.io.PipedInputStream
+import java.io.PipedOutputStream
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.util.concurrent.TimeUnit
+import kotlin.concurrent.thread
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
+class InputInjectorServiceEnterTest {
+
+ @Test
+ fun `enter then attach warps on the injector that owns the fd after OPEN`() {
+ val kernel = PipedOutputStream()
+ val input = PipedInputStream(kernel, 8192)
+ val output = ByteArrayOutputStream()
+ val channel = UhidChannel.forTesting(
+ output,
+ input,
+ UhidReadinessConfig(
+ openTimeoutMs = 400,
+ presenceTimeoutMs = 10,
+ presence = { _, _ -> true },
+ ),
+ )
+ val service = InputInjectorService { channel }
+
+ service.onHidMouseEnter(0, 100, 1079, 2413, 0)
+ assertThat(service.injectHidMouse(1, 0, 0, 0)).isFalse()
+
+ thread(isDaemon = true) {
+ Thread.sleep(25)
+ kernel.write(typeWord(UhidProtocol.UHID_OPEN))
+ kernel.flush()
+ }
+
+ val startedAt = System.nanoTime()
+ assertThat(service.openVirtualMouse()).isTrue()
+ val elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)
+ assertThat(elapsedMs).isLessThan(800)
+
+ val packet = output.toByteArray()
+ assertThat(typeAt(packet, 0)).isEqualTo(UhidProtocol.UHID_CREATE2)
+ val input2Offsets = input2Offsets(packet)
+ assertThat(input2Offsets).isNotEmpty()
+ assertThat(input2Offsets.first()).isGreaterThan(0)
+ assertThat(input2Offsets.any { mouseDx(packet, it) == HidMouse.MIN_DELTA }).isTrue()
+
+ service.closeVirtualMouse()
+ kernel.close()
+ }
+
+ @Test
+ fun `leave before attach drops enter so CREATE2 does not warp`() {
+ val output = ByteArrayOutputStream()
+ val channel = UhidChannel.forTesting(output)
+ val service = InputInjectorService { channel }
+
+ service.onHidMouseEnter(0, 0, 1079, 2413, 0)
+ service.onHidMouseLeave()
+ assertThat(service.openVirtualMouse()).isTrue()
+
+ val packet = output.toByteArray()
+ assertThat(typeAt(packet, 0)).isEqualTo(UhidProtocol.UHID_CREATE2)
+ assertThat(input2Offsets(packet)).isEmpty()
+
+ service.closeVirtualMouse()
+ }
+
+ @Test
+ fun `compensation writes to the same channel that received CREATE2`() {
+ val mouseOut = ByteArrayOutputStream()
+ val otherOut = ByteArrayOutputStream()
+ val mouseChannel = UhidChannel.forTesting(mouseOut)
+ UhidChannel.forTesting(otherOut).use { other ->
+ other.createDevice("other", byteArrayOf(1), uniq = "other")
+ }
+ val service = InputInjectorService { mouseChannel }
+
+ service.onHidMouseEnter(1079, 2413, 1079, 2413, 0)
+ assertThat(service.openVirtualMouse()).isTrue()
+
+ assertThat(input2Offsets(otherOut.toByteArray())).isEmpty()
+ val mousePacket = mouseOut.toByteArray()
+ assertThat(typeAt(mousePacket, 0)).isEqualTo(UhidProtocol.UHID_CREATE2)
+ val input2 = input2Offsets(mousePacket)
+ assertThat(input2).isNotEmpty()
+ assertThat(input2.any { mouseDx(mousePacket, it) == HidMouse.MAX_DELTA }).isTrue()
+
+ service.closeVirtualMouse()
+ }
+
+ @Test
+ fun `warm daemon reports no warp on an idempotent reopen so the client snaps itself`() {
+ val output = ByteArrayOutputStream()
+ val channel = UhidChannel.forTesting(output)
+ val service = InputInjectorService { channel }
+
+ service.onHidMouseEnter(1079, 2413, 1079, 2413, 0)
+ assertThat(service.openVirtualMouse()).isTrue()
+ // The create branch warped, so the client must not also snap.
+ assertThat(service.consumeEnterWarpApplied()).isTrue()
+ // Reading clears it; a second read is not a second warp.
+ assertThat(service.consumeEnterWarpApplied()).isFalse()
+
+ // A root daemon that survived an app rebind still has the mouse open. This
+ // open emits no INPUT2, so the client has to send its own snap.
+ val beforeReopen = output.toByteArray().size
+ service.onHidMouseEnter(0, 0, 1079, 2413, 0)
+ assertThat(service.openVirtualMouse()).isTrue()
+ assertThat(output.toByteArray().size).isEqualTo(beforeReopen)
+ assertThat(service.consumeEnterWarpApplied()).isFalse()
+
+ service.closeVirtualMouse()
+ }
+
+ @Test
+ fun `an open that warped nothing reports false`() {
+ val output = ByteArrayOutputStream()
+ val service = InputInjectorService { UhidChannel.forTesting(output) }
+
+ assertThat(service.openVirtualMouse()).isTrue()
+ assertThat(input2Offsets(output.toByteArray())).isEmpty()
+ assertThat(service.consumeEnterWarpApplied()).isFalse()
+
+ service.closeVirtualMouse()
+ }
+
+ private fun typeWord(type: Int): ByteArray =
+ ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(type).array()
+
+ private fun typeAt(packet: ByteArray, offset: Int): Int =
+ ByteBuffer.wrap(packet, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int
+
+ private fun input2Offsets(packet: ByteArray): List {
+ val offsets = mutableListOf()
+ var offset = 0
+ while (offset + 4 <= packet.size) {
+ when (typeAt(packet, offset)) {
+ UhidProtocol.UHID_CREATE2 -> offset += UhidProtocol.CREATE2_PACKET_SIZE
+ UhidProtocol.UHID_INPUT2 -> {
+ offsets += offset
+ offset += UhidProtocol.INPUT2_PACKET_SIZE
+ }
+ UhidProtocol.UHID_DESTROY -> offset += UhidProtocol.EVENT_PACKET_SIZE
+ else -> offset += 4
+ }
+ }
+ return offsets
+ }
+
+ private fun mouseDx(packet: ByteArray, input2At: Int): Int {
+ val lo = packet[input2At + 6 + 1].toInt() and 0xFF
+ val hi = packet[input2At + 6 + 2].toInt()
+ return (hi shl 8) or lo
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidKeyboardTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidKeyboardTest.kt
new file mode 100644
index 00000000..0da3a586
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidKeyboardTest.kt
@@ -0,0 +1,188 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class EvdevToHidTest {
+
+ @Test
+ fun `letters map from qwerty order to alphabetical HID order`() {
+ assertThat(EvdevToHid.usage(30)).isEqualTo(0x04) // KEY_A
+ assertThat(EvdevToHid.usage(48)).isEqualTo(0x05) // KEY_B
+ assertThat(EvdevToHid.usage(46)).isEqualTo(0x06) // KEY_C
+ assertThat(EvdevToHid.usage(44)).isEqualTo(0x1D) // KEY_Z
+ assertThat(EvdevToHid.usage(16)).isEqualTo(0x14) // KEY_Q
+ assertThat(EvdevToHid.usage(25)).isEqualTo(0x13) // KEY_P
+ }
+
+ @Test
+ fun `digits map with zero at the end of the HID range`() {
+ assertThat(EvdevToHid.usage(2)).isEqualTo(0x1E) // KEY_1
+ assertThat(EvdevToHid.usage(10)).isEqualTo(0x26) // KEY_9
+ assertThat(EvdevToHid.usage(11)).isEqualTo(0x27) // KEY_0
+ }
+
+ @Test
+ fun `punctuation is mapped - the old KeysymToHid dropped all of it`() {
+ assertThat(EvdevToHid.usage(12)).isEqualTo(0x2D) // MINUS
+ assertThat(EvdevToHid.usage(13)).isEqualTo(0x2E) // EQUAL
+ assertThat(EvdevToHid.usage(39)).isEqualTo(0x33) // SEMICOLON
+ assertThat(EvdevToHid.usage(51)).isEqualTo(0x36) // COMMA
+ assertThat(EvdevToHid.usage(53)).isEqualTo(0x38) // SLASH
+ assertThat(EvdevToHid.usage(43)).isEqualTo(0x31) // BACKSLASH
+ }
+
+ @Test
+ fun `function and navigation keys are mapped`() {
+ assertThat(EvdevToHid.usage(59)).isEqualTo(0x3A) // F1
+ assertThat(EvdevToHid.usage(68)).isEqualTo(0x43) // F10
+ assertThat(EvdevToHid.usage(87)).isEqualTo(0x44) // F11
+ assertThat(EvdevToHid.usage(88)).isEqualTo(0x45) // F12
+ assertThat(EvdevToHid.usage(103)).isEqualTo(0x52) // UP
+ assertThat(EvdevToHid.usage(108)).isEqualTo(0x51) // DOWN
+ assertThat(EvdevToHid.usage(102)).isEqualTo(0x4A) // HOME
+ }
+
+ @Test
+ fun `keypad digits map from evdev layout order`() {
+ assertThat(EvdevToHid.usage(79)).isEqualTo(0x59) // KP1
+ assertThat(EvdevToHid.usage(75)).isEqualTo(0x5C) // KP4
+ assertThat(EvdevToHid.usage(71)).isEqualTo(0x5F) // KP7
+ assertThat(EvdevToHid.usage(73)).isEqualTo(0x61) // KP9
+ assertThat(EvdevToHid.usage(82)).isEqualTo(0x62) // KP0
+ }
+
+ @Test
+ fun `modifiers are flagged and carry distinct bits`() {
+ assertThat(EvdevToHid.isModifier(29)).isTrue() // LEFTCTRL
+ assertThat(EvdevToHid.isModifier(42)).isTrue() // LEFTSHIFT
+ assertThat(EvdevToHid.isModifier(30)).isFalse() // KEY_A
+
+ val bits = listOf(29, 42, 56, 125, 97, 54, 100, 126).map { EvdevToHid.modifierBit(it) }
+ assertThat(bits).containsExactly(0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80)
+ assertThat(bits.toSet()).hasSize(8)
+ }
+
+ @Test
+ fun `unmapped codes return null so the caller can fall back`() {
+ assertThat(EvdevToHid.usage(0)).isNull()
+ assertThat(EvdevToHid.usage(9999)).isNull()
+ }
+
+ @Test
+ fun `every usage is unique`() {
+ val codes = (1..255).mapNotNull { EvdevToHid.usage(it) }
+ assertThat(codes).hasSize(codes.toSet().size)
+ }
+}
+
+class HidKeyboardTest {
+
+ private class RecordingSink : HidReportSink {
+ val reports = mutableListOf()
+ override fun sendReport(report: ByteArray): Boolean {
+ reports += report.copyOf()
+ return true
+ }
+ }
+
+ @Test
+ fun `descriptor is a Generic Desktop Keyboard application collection`() {
+ assertThat(HidKeyboard.DESCRIPTOR.take(6).toList()).containsExactly(
+ 0x05.toByte(), 0x01.toByte(),
+ 0x09.toByte(), 0x06.toByte(),
+ 0xA1.toByte(), 0x01.toByte(),
+ ).inOrder()
+ }
+
+ @Test
+ fun `a key press occupies a slot and release frees it`() {
+ val sink = RecordingSink()
+ val keyboard = HidKeyboard(sink)
+
+ assertThat(keyboard.key(30, isDown = true)).isTrue() // KEY_A
+ assertThat(keyboard.key(30, isDown = false)).isTrue()
+
+ assertThat(sink.reports).hasSize(2)
+ assertThat(sink.reports[0].size).isEqualTo(HidKeyboard.REPORT_SIZE)
+ assertThat(sink.reports[0][2].toInt()).isEqualTo(0x04)
+ assertThat(sink.reports[1][2].toInt()).isEqualTo(0)
+ }
+
+ @Test
+ fun `modifiers set bits in byte zero, not a key slot`() {
+ val sink = RecordingSink()
+ val keyboard = HidKeyboard(sink)
+
+ keyboard.key(42, isDown = true) // LEFTSHIFT
+ keyboard.key(30, isDown = true) // KEY_A
+
+ val last = sink.reports.last()
+ assertThat(last[0].toInt()).isEqualTo(0x02)
+ assertThat(last[2].toInt()).isEqualTo(0x04)
+ }
+
+ @Test
+ fun `the reserved byte stays zero`() {
+ val sink = RecordingSink()
+
+ HidKeyboard(sink).key(30, isDown = true)
+
+ assertThat(sink.reports.single()[1].toInt()).isEqualTo(0)
+ }
+
+ @Test
+ fun `six keys fit and a seventh is refused rather than corrupting the report`() {
+ val sink = RecordingSink()
+ val keyboard = HidKeyboard(sink)
+ val keys = listOf(30, 48, 46, 32, 18, 33) // a b c d e f
+
+ keys.forEach { assertThat(keyboard.key(it, isDown = true)).isTrue() }
+ assertThat(keyboard.key(34, isDown = true)).isFalse() // KEY_G, 7th
+
+ val last = sink.reports.last()
+ assertThat(last.drop(2).count { it.toInt() != 0 }).isEqualTo(HidKeyboard.MAX_KEYS)
+ }
+
+ @Test
+ fun `a repeated press does not double-occupy a slot`() {
+ val sink = RecordingSink()
+ val keyboard = HidKeyboard(sink)
+
+ keyboard.key(30, isDown = true)
+ keyboard.key(30, isDown = true)
+
+ assertThat(sink.reports.last().drop(2).count { it.toInt() != 0 }).isEqualTo(1)
+ }
+
+ @Test
+ fun `unmapped keys are refused so the caller falls back`() {
+ val sink = RecordingSink()
+
+ assertThat(HidKeyboard(sink).key(9999, isDown = true)).isFalse()
+ assertThat(sink.reports).isEmpty()
+ }
+
+ @Test
+ fun `releaseAll clears modifiers and keys so nothing sticks down`() {
+ val sink = RecordingSink()
+ val keyboard = HidKeyboard(sink)
+ keyboard.key(42, isDown = true) // LEFTSHIFT
+ keyboard.key(30, isDown = true) // KEY_A
+ sink.reports.clear()
+
+ keyboard.releaseAll()
+
+ val report = sink.reports.single()
+ assertThat(report.all { it.toInt() == 0 }).isTrue()
+ }
+
+ @Test
+ fun `releaseAll on an idle keyboard sends nothing`() {
+ val sink = RecordingSink()
+
+ HidKeyboard(sink).releaseAll()
+
+ assertThat(sink.reports).isEmpty()
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseEnterWarpTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseEnterWarpTest.kt
new file mode 100644
index 00000000..1e35f202
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseEnterWarpTest.kt
@@ -0,0 +1,109 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class HidMouseEnterWarpTest {
+
+ @Test
+ fun `enter is retained until apply on the injector that owns the mouse`() {
+ val warp = HidMouseEnterWarp { 1L }
+ val sink = RecordingSink()
+ val mouse = HidMouse(sink)
+
+ warp.onEnter(x = 0, y = 100, maxX = 1079, maxY = 2413, pointerSpeed = 0)
+ assertThat(warp.pending).isNotNull()
+ assertThat(sink.reports).isEmpty()
+
+ val plans = warp.applyIfPending(mouse)
+ assertThat(plans.any { !it.isNoOp }).isTrue()
+ assertThat(sink.reports).isNotEmpty()
+ assertThat(warp.pending).isNull()
+ assertThat(sink.reports.any { shortLe(it, 1) == HidMouse.MIN_DELTA }).isTrue()
+ }
+
+ @Test
+ fun `apply without enter does not emit HID`() {
+ val warp = HidMouseEnterWarp { 1L }
+ val sink = RecordingSink()
+ assertThat(warp.applyIfPending(HidMouse(sink))).isEmpty()
+ assertThat(sink.reports).isEmpty()
+ }
+
+ @Test
+ fun `failed HID write keeps enter coords`() {
+ val warp = HidMouseEnterWarp { 1L }
+ warp.onEnter(x = 1079, y = 0, maxX = 1079, maxY = 2413, pointerSpeed = 0)
+ val mouse = HidMouse(object : HidReportSink {
+ override fun sendReport(report: ByteArray): Boolean = false
+ })
+
+ val plans = warp.applyIfPending(mouse)
+ assertThat(plans.any { !it.isNoOp }).isTrue()
+ assertThat(warp.pending).isNotNull()
+ assertThat(warp.pending!!.x).isEqualTo(1079)
+ }
+
+ @Test
+ fun `leave drops stored enter so a later attach cannot reuse it`() {
+ val warp = HidMouseEnterWarp { 1L }
+ val sink = RecordingSink()
+ warp.onEnter(x = 0, y = 0, maxX = 1079, maxY = 2413, pointerSpeed = 0)
+ warp.onLeave()
+ assertThat(warp.applyIfPending(HidMouse(sink))).isEmpty()
+ assertThat(sink.reports).isEmpty()
+ }
+
+ @Test
+ fun `later enter replaces coords before attach`() {
+ val warp = HidMouseEnterWarp { 1L }
+ warp.onEnter(x = 0, y = 0, maxX = 1079, maxY = 2413, pointerSpeed = 0)
+ warp.onEnter(x = 1079, y = 2413, maxX = 1079, maxY = 2413, pointerSpeed = 0)
+ val sink = RecordingSink()
+ warp.applyIfPending(HidMouse(sink))
+ assertThat(sink.reports.any { shortLe(it, 1) == HidMouse.MAX_DELTA }).isTrue()
+ assertThat(sink.reports.any { shortLe(it, 3) == HidMouse.MAX_DELTA }).isTrue()
+ }
+
+ @Test
+ fun `new HID mouse origin is display center so interior enter snap is non-zero`() {
+ val warp = HidMouseEnterWarp { 1L }
+ val sink = RecordingSink()
+ warp.onEnter(x = 100, y = 200, maxX = 1079, maxY = 2413, pointerSpeed = 0)
+
+ val plans = warp.applyIfPending(HidMouse(sink))
+
+ assertThat(plans.any { !it.isNoOp }).isTrue()
+ assertThat(sink.reports).isNotEmpty()
+ val cookedIfSeededAtEnter = MouseEdgeAnchor.planSnap(
+ MouseEdgeAnchor.Input(
+ targetX = 100,
+ targetY = 200,
+ cookedX = 100,
+ cookedY = 200,
+ maxX = 1079,
+ maxY = 2413,
+ flags = MouseEdgeAnchor.Flags(),
+ preferTangentialFirst = true,
+ lastWasEdgePulse = false,
+ dtMs = 0L,
+ ),
+ settingsSpeed = 0,
+ )
+ assertThat(cookedIfSeededAtEnter.all { it.isNoOp }).isTrue()
+ }
+
+ private fun shortLe(report: ByteArray, offset: Int): Int {
+ val lo = report[offset].toInt() and 0xFF
+ val hi = report[offset + 1].toInt()
+ return (hi shl 8) or lo
+ }
+
+ private class RecordingSink : HidReportSink {
+ val reports = mutableListOf()
+ override fun sendReport(report: ByteArray): Boolean {
+ reports += report.copyOf()
+ return true
+ }
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseStateTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseStateTest.kt
new file mode 100644
index 00000000..3329d4ba
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseStateTest.kt
@@ -0,0 +1,279 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class HidMouseStateTest {
+
+ @Test
+ fun `attaching is not yet attached but is attached-or-attaching`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.beginAttach()
+ assertThat(state.attached).isFalse()
+ assertThat(state.isAttachedOrAttaching()).isTrue()
+ assertThat(state.phase).isEqualTo(HidMouseState.AttachPhase.ATTACHING)
+ }
+
+ @Test
+ fun `pending enter is retained and consumed after attach completes`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.updatePointerTarget(520, 0)
+ state.beginAttach()
+ assertThat(state.phase).isEqualTo(HidMouseState.AttachPhase.ATTACHING)
+ assertThat(state.peekPendingSnap()).isEqualTo(Pair(520, 0))
+
+ state.completeAttach(newDevice = true)
+ assertThat(state.attached).isTrue()
+ assertThat(state.consumePendingSnap()).isEqualTo(Pair(520, 0))
+ }
+
+ @Test
+ fun `DMMV during attach updates latest pending target`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.updatePointerTarget(520, 0)
+ state.beginAttach()
+ assertThat(state.queueTargetWhileAttaching(600, 100)).isTrue()
+ assertThat(state.peekPendingSnap()).isEqualTo(Pair(600, 100))
+ }
+
+ @Test
+ fun `idempotent attach does not reseed cooked estimate`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.completeAttach(newDevice = true)
+ val before = state.cookedPosition()
+ state.applySuccessfulMove(
+ MousePointerCompensation.planHidMove(100, 0, 0, dtMs = 0L),
+ nowMs = 1L,
+ )
+ val afterMove = state.cookedPosition()
+ state.completeAttach(newDevice = false)
+ assertThat(state.cookedPosition()).isEqualTo(afterMove)
+ assertThat(state.cookedPosition()).isNotEqualTo(before)
+ }
+
+ @Test
+ fun `resize while attached scales cooked and protocol coords`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.completeAttach(newDevice = true)
+ state.updatePointerTarget(540, 1200)
+ state.resizeDisplay(newMaxX = 1919, newMaxY = 1079)
+ assertThat(state.protocolX).isEqualTo(960)
+ assertThat(state.cookedX).isEqualTo(958)
+ assertThat(state.edgeFlags).isEqualTo(MouseEdgeAnchor.Flags())
+ }
+
+ @Test
+ fun `movement timing preserved across pointer speed updates`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.completeAttach(newDevice = true)
+ state.applySuccessfulMove(
+ MousePointerCompensation.planHidMove(100, 50, 0, dtMs = 0L),
+ nowMs = 50L,
+ )
+ val before = state.cookedPosition()
+ assertThat(state.movementDtMs(nowMs = 100L)).isEqualTo(50L)
+ assertThat(state.cookedPosition()).isEqualTo(before)
+ }
+
+ @Test
+ fun `successful move advances cooked estimate only when applied`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.completeAttach(newDevice = true)
+ val plan = MousePointerCompensation.planHidMove(0, -200, 0, dtMs = 0L)
+ state.applySuccessfulMove(plan, nowMs = 100L)
+ val (_, cookedY) = state.cookedPosition()
+ assertThat(cookedY).isEqualTo(1206 - 200)
+ }
+
+ @Test
+ fun `applySuccessfulPlan updates cooked flags and last pulse on success only`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.completeAttach(newDevice = true)
+ state.clearCenterSeed()
+ state.updatePointerTarget(0, 200)
+ val plan = MouseEdgeAnchor.plan(state.plannerInput(nowMs = 5L), 0)
+ assertThat(plan.isEdgePulse).isTrue()
+ assertThat(state.edgeFlags.minX).isFalse()
+ state.applySuccessfulPlan(plan, nowMs = 5L)
+ assertThat(state.cookedX).isEqualTo(0)
+ assertThat(state.edgeFlags.minX).isTrue()
+ assertThat(state.lastWasEdgePulse).isTrue()
+ }
+
+ @Test
+ fun `stable leave discards pending so a later attach cannot reuse it`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.onEnter(0, 0)
+ state.completeAttach(newDevice = true)
+ state.snap()
+ assertThat(state.cookedX).isEqualTo(0)
+ assertThat(state.edgeFlags.minX).isTrue()
+
+ state.markLeave()
+ state.detach()
+ assertThat(state.phase).isEqualTo(HidMouseState.AttachPhase.DETACHED)
+ assertThat(state.peekPendingSnap()).isNull()
+ assertThat(state.edgeFlags).isEqualTo(MouseEdgeAnchor.Flags())
+ assertThat(state.needsCenterSeed()).isFalse()
+ }
+
+ @Test
+ fun `stable leave then enter at a different edge snaps from fresh center`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.onEnter(0, 0)
+ state.completeAttach(newDevice = true)
+ state.snap()
+ assertThat(state.cookedPosition()).isEqualTo(Pair(0, 0))
+
+ state.markLeave()
+ state.detach()
+ assertThat(state.peekPendingSnap()).isNull()
+
+ state.onEnter(1079, 2413)
+ state.beginAttach()
+ state.completeAttach(newDevice = true)
+ assertThat(state.cookedPosition()).isEqualTo(Pair(state.centerX(), state.centerY()))
+ assertThat(state.peekPendingSnap()).isEqualTo(Pair(1079, 2413))
+ assertThat(state.edgeFlags).isEqualTo(MouseEdgeAnchor.Flags())
+ assertThat(state.needsCenterSeed()).isTrue()
+
+ val plans = state.snap()
+ assertThat(plans.any { it.isEdgePulse && it.hidX == HidMouse.MAX_DELTA }).isTrue()
+ assertThat(plans.any { it.isEdgePulse && it.hidY == HidMouse.MAX_DELTA }).isTrue()
+ assertThat(state.cookedPosition()).isEqualTo(Pair(1079, 2413))
+ assertThat(state.peekPendingSnap()).isNull()
+ }
+
+ @Test
+ fun `enter after leave but before detach keeps the new target`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.onEnter(0, 50)
+ state.completeAttach(newDevice = true)
+ state.snap()
+
+ state.markLeave()
+ state.onEnter(1079, 2000)
+ state.detach()
+ assertThat(state.peekPendingSnap()).isEqualTo(Pair(1079, 2000))
+ assertThat(state.protocolX).isEqualTo(1079)
+ assertThat(state.protocolY).isEqualTo(2000)
+
+ state.beginAttach()
+ state.completeAttach(newDevice = true)
+ assertThat(state.cookedPosition()).isEqualTo(Pair(state.centerX(), state.centerY()))
+ assertThat(state.peekPendingSnap()).isEqualTo(Pair(1079, 2000))
+ state.snap()
+ assertThat(state.cookedX).isEqualTo(1079)
+ assertThat(state.cookedY).isAtLeast(1500)
+ }
+
+ @Test
+ fun `rapid re-enter while attached plans from current cooked not center`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.onEnter(0, 0)
+ state.completeAttach(newDevice = true)
+ state.snap()
+ val leftTop = state.cookedPosition()
+ assertThat(leftTop).isEqualTo(Pair(0, 0))
+
+ state.markLeave()
+ state.onEnter(1079, 1800)
+ assertThat(state.attached).isTrue()
+ assertThat(state.cookedPosition()).isEqualTo(leftTop)
+ assertThat(state.needsCenterSeed()).isFalse()
+ assertThat(state.edgeFlags).isEqualTo(MouseEdgeAnchor.Flags())
+
+ val plans = MouseEdgeAnchor.planSnap(state.plannerInput(nowMs = 20L), 0)
+ assertThat(plans.any { it.hidX == HidMouse.MAX_DELTA }).isTrue()
+ plans.filter { !it.isNoOp }.forEach { state.applySuccessfulPlan(it, 20L) }
+ assertThat(state.cookedX).isEqualTo(1079)
+ assertThat(state.cookedY).isAtLeast(1000)
+ assertThat(state.cookedY).isNotEqualTo(0)
+ }
+
+ @Test
+ fun `DMMV during attach replaces a stale enter target`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.onEnter(0, 0)
+ state.completeAttach(newDevice = true)
+ state.snap()
+ state.markLeave()
+ state.detach()
+
+ state.onEnter(100, 0)
+ state.beginAttach()
+ assertThat(state.queueTargetWhileAttaching(900, 2200)).isTrue()
+ assertThat(state.peekPendingSnap()).isEqualTo(Pair(900, 2200))
+ state.completeAttach(newDevice = true)
+ assertThat(state.peekPendingSnap()).isEqualTo(Pair(900, 2200))
+ assertThat(state.protocolX).isEqualTo(900)
+ assertThat(state.protocolY).isEqualTo(2200)
+ }
+
+ @Test
+ fun `left top anchors rearm for a new right bottom session`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.onEnter(0, 0)
+ state.completeAttach(newDevice = true)
+ state.snap()
+ assertThat(state.edgeFlags.minX).isTrue()
+ assertThat(state.edgeFlags.minY).isTrue()
+ assertThat(state.edgeFlags.maxX).isFalse()
+ assertThat(state.edgeFlags.maxY).isFalse()
+
+ state.markLeave()
+ state.detach()
+ state.onEnter(1079, 2413)
+ state.beginAttach()
+ state.completeAttach(newDevice = true)
+ assertThat(state.edgeFlags).isEqualTo(MouseEdgeAnchor.Flags())
+ val plans = MouseEdgeAnchor.planSnap(state.plannerInput(nowMs = 1L), 0)
+ assertThat(plans.any { it.hidX == HidMouse.MAX_DELTA }).isTrue()
+ assertThat(plans.any { it.hidY == HidMouse.MAX_DELTA }).isTrue()
+ assertThat(plans.none { it.hidX == HidMouse.MIN_DELTA }).isTrue()
+ assertThat(plans.none { it.hidY == HidMouse.MIN_DELTA }).isTrue()
+ }
+
+ @Test
+ fun `rotation between sessions uses new bounds for the next enter`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.onEnter(0, 0)
+ state.completeAttach(newDevice = true)
+ state.snap()
+ state.markLeave()
+ state.detach()
+
+ state.resizeDisplay(newMaxX = 2413, newMaxY = 1079)
+ assertThat(state.cookedPosition()).isEqualTo(Pair(1206, 539))
+ assertThat(state.edgeFlags).isEqualTo(MouseEdgeAnchor.Flags())
+ assertThat(state.peekPendingSnap()).isNull()
+
+ state.onEnter(2413, 1079)
+ state.beginAttach()
+ state.completeAttach(newDevice = true)
+ assertThat(state.cookedPosition()).isEqualTo(Pair(1206, 539))
+ assertThat(state.peekPendingSnap()).isEqualTo(Pair(2413, 1079))
+ state.snap()
+ assertThat(state.cookedPosition()).isEqualTo(Pair(2413, 1079))
+ }
+
+ private fun HidMouseState.snap(nowMs: Long = 1L): List {
+ val plans = MouseEdgeAnchor.planSnap(plannerInput(nowMs), 0)
+ for (plan in plans) {
+ if (!plan.isNoOp) applySuccessfulPlan(plan, nowMs)
+ }
+ val target = peekPendingSnap() ?: return plans
+ val (errX, errY) = errorToTarget(target.first, target.second)
+ val stillEdge = MouseEdgeAnchor.needsEdgePulse(
+ edgeFlags,
+ target.first,
+ target.second,
+ maxXExclusive(),
+ maxYExclusive(),
+ )
+ if (errX == 0 && errY == 0 && !stillEdge) {
+ consumePendingSnap()
+ }
+ return plans
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseTest.kt
new file mode 100644
index 00000000..2aa76272
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseTest.kt
@@ -0,0 +1,85 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class HidMouseTest {
+
+ private class RecordingSink : HidReportSink {
+ val reports = mutableListOf()
+ var failNext = false
+ override fun sendReport(report: ByteArray): Boolean {
+ if (failNext) return false
+ reports += report.copyOf()
+ return true
+ }
+ }
+
+ @Test
+ fun `descriptor is a Generic Desktop Mouse application collection`() {
+ assertThat(HidMouse.DESCRIPTOR.take(6).toList()).containsExactly(
+ 0x05.toByte(), 0x01.toByte(),
+ 0x09.toByte(), 0x02.toByte(),
+ 0xA1.toByte(), 0x01.toByte(),
+ ).inOrder()
+ }
+
+ @Test
+ fun `descriptor includes three buttons and relative X Y and wheel`() {
+ val hex = HidMouse.DESCRIPTOR.joinToString("") { "%02X".format(it) }
+ assertThat(hex).contains("0509") // Usage Page (Button)
+ assertThat(hex).contains("1901") // Usage Minimum (1)
+ assertThat(hex).contains("2903") // Usage Maximum (3)
+ assertThat(hex).contains("0930") // Usage (X)
+ assertThat(hex).contains("0931") // Usage (Y)
+ assertThat(hex).contains("0938") // Usage (Wheel)
+ assertThat(hex).contains("8106") // Input (Data,Var,Rel)
+ }
+
+ @Test
+ fun `move encodes buttons dx dy and wheel as a six byte report`() {
+ val sink = RecordingSink()
+ val mouse = HidMouse(sink)
+
+ assertThat(mouse.move(dx = 100, dy = -50, buttons = 0b101, wheel = 1)).isTrue()
+
+ assertThat(sink.reports).hasSize(1)
+ assertThat(sink.reports[0].size).isEqualTo(HidMouse.REPORT_SIZE)
+ assertThat(sink.reports[0].toList()).containsExactly(
+ 0x05.toByte(),
+ 0x64.toByte(), 0x00.toByte(),
+ 0xCE.toByte(), 0xFF.toByte(),
+ 0x01.toByte(),
+ ).inOrder()
+ }
+
+ @Test
+ fun `move encodes negative wheel as signed byte`() {
+ val sink = RecordingSink()
+ val mouse = HidMouse(sink)
+
+ mouse.move(dx = 0, dy = 0, buttons = 0, wheel = -1)
+
+ assertThat(sink.reports.single()[5].toInt() and 0xFF).isEqualTo(0xFF)
+ }
+
+ @Test
+ fun `move returns false when sink write fails`() {
+ val sink = RecordingSink()
+ sink.failNext = true
+ val mouse = HidMouse(sink)
+ assertThat(mouse.move(dx = 1, dy = 0, buttons = 0, wheel = 0)).isFalse()
+ assertThat(sink.reports).isEmpty()
+ }
+
+ @Test
+ fun `releaseAll sends a zero report`() {
+ val sink = RecordingSink()
+ val mouse = HidMouse(sink)
+
+ mouse.releaseAll()
+
+ val report = sink.reports.single()
+ assertThat(report.all { it.toInt() == 0 }).isTrue()
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresenceTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresenceTest.kt
new file mode 100644
index 00000000..33e3a6f1
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresenceTest.kt
@@ -0,0 +1,36 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import java.io.File
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class HidSysfsPresenceTest {
+
+ @get:Rule
+ val tmp = TemporaryFolder()
+
+ @Test
+ fun `matches uniq under an event node`() {
+ val event = File(tmp.root, "event3").apply { mkdir() }
+ val device = File(event, "device").apply { mkdir() }
+ File(device, "name").writeText("Input Leaf Keyboard HID\n")
+ File(device, "uniq").writeText("inputleaf-kbd\n")
+
+ assertThat(
+ HidSysfsPresence.present(
+ name = "Input Leaf Keyboard HID",
+ uniq = "inputleaf-kbd",
+ root = tmp.root,
+ ),
+ ).isTrue()
+ assertThat(
+ HidSysfsPresence.present(
+ name = "other",
+ uniq = "inputleaf-mouse",
+ root = tmp.root,
+ ),
+ ).isFalse()
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/MouseEdgeAnchorTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/MouseEdgeAnchorTest.kt
new file mode 100644
index 00000000..69c52634
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/MouseEdgeAnchorTest.kt
@@ -0,0 +1,494 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.roundToInt
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+class MouseEdgeAnchorTest {
+
+ @Before
+ fun disableOplusRemap() {
+ MousePointerCompensation.setOplusFamilyRemapForTest(false)
+ }
+
+ @After
+ fun resetVendorPolicy() {
+ MousePointerCompensation.resetVendorPolicyForTest()
+ }
+
+ @Test
+ fun `bounds treat width-1 and height-1 as inclusive maxima`() {
+ assertThat(MouseEdgeAnchor.atMin(0)).isTrue()
+ assertThat(MouseEdgeAnchor.atMin(1)).isFalse()
+ assertThat(MouseEdgeAnchor.atMax(1079, 1079)).isTrue()
+ assertThat(MouseEdgeAnchor.atMax(1078, 1079)).isFalse()
+ assertThat(MouseEdgeAnchor.atMax(2413, 2413)).isTrue()
+ assertThat(MouseEdgeAnchor.atMax(2412, 2413)).isFalse()
+ }
+
+ @Test
+ fun `gain mismatch left to right still clamps physical x via edge pulse`() {
+ val maxX = 1079
+ val maxY = 2413
+ val predictedGain = MousePointerCompensation.firstSegmentGain(0)
+ val actualGain = predictedGain * 0.7
+ val sim = Sim(cookedX = 0, cookedY = 200, physicalX = 0, physicalY = 200, maxX = maxX, maxY = maxY)
+ sim.flags = MouseEdgeAnchor.Flags(minX = true)
+
+ var sawRightPulse = false
+ for (x in 0..maxX step 40) {
+ val target = x.coerceAtMost(maxX)
+ val hidX = step(sim, target, 200, actualGain, dtMs = if (x == 0) 0L else 16L).hidX
+ if (hidX == HidMouse.MAX_DELTA) sawRightPulse = true
+ }
+ val finalHidX = step(sim, maxX, 200, actualGain, dtMs = 16L).hidX
+ if (finalHidX == HidMouse.MAX_DELTA) sawRightPulse = true
+
+ assertThat(sawRightPulse).isTrue()
+ assertThat(sim.cookedX).isEqualTo(maxX)
+ assertThat(sim.physicalX).isEqualTo(maxX)
+ assertThat(sim.physicalX).isGreaterThan(((maxX * 0.7).toInt()))
+ }
+
+ @Test
+ fun `gain mismatch top to bottom still clamps physical y via edge pulse`() {
+ val maxX = 1079
+ val maxY = 2413
+ val predictedGain = MousePointerCompensation.firstSegmentGain(0)
+ val actualGain = predictedGain * 0.7
+ val sim = Sim(cookedX = 400, cookedY = 0, physicalX = 400, physicalY = 0, maxX = maxX, maxY = maxY)
+ sim.flags = MouseEdgeAnchor.Flags(minY = true)
+
+ var sawBottomPulse = false
+ for (y in 0..maxY step 80) {
+ val target = y.coerceAtMost(maxY)
+ val hidY = step(sim, 400, target, actualGain, dtMs = if (y == 0) 0L else 16L).hidY
+ if (hidY == HidMouse.MAX_DELTA) sawBottomPulse = true
+ }
+ val finalHidY = step(sim, 400, maxY, actualGain, dtMs = 16L).hidY
+ if (finalHidY == HidMouse.MAX_DELTA) sawBottomPulse = true
+
+ assertThat(sawBottomPulse).isTrue()
+ assertThat(sim.cookedY).isEqualTo(maxY)
+ assertThat(sim.physicalY).isEqualTo(maxY)
+ }
+
+ @Test
+ fun `without edge pulse predicted max hides physical undershoot`() {
+ val maxX = 1079
+ val predictedGain = MousePointerCompensation.firstSegmentGain(0)
+ val actualGain = predictedGain * 0.7
+ var cooked = 0
+ var physical = 0
+ for (x in 40..maxX step 40) {
+ val err = x - cooked
+ val plan = MousePointerCompensation.planHidMove(err, 0, 0, dtMs = 0L)
+ if (plan.isNoOp) continue
+ val (dx, _) = plan.cookedDelta()
+ cooked = (cooked + dx).coerceIn(0, maxX)
+ physical = (physical + (plan.hidX * actualGain).roundToInt()).coerceIn(0, maxX)
+ }
+ while (cooked < maxX) {
+ val plan = MousePointerCompensation.planHidMove(maxX - cooked, 0, 0, dtMs = 0L)
+ if (plan.isNoOp) break
+ val (dx, _) = plan.cookedDelta()
+ cooked = (cooked + dx).coerceIn(0, maxX)
+ physical = (physical + (plan.hidX * actualGain).roundToInt()).coerceIn(0, maxX)
+ }
+ val stuck = MousePointerCompensation.planHidMove(maxX - cooked, 0, 0, dtMs = 0L)
+ assertThat(cooked).isAtLeast(maxX - 5)
+ assertThat(stuck.isNoOp).isTrue()
+ assertThat(physical).isLessThan(cooked)
+ assertThat(physical).isLessThan(maxX)
+ }
+
+ @Test
+ fun `boundary pulse emits once then re-arms after moving inward`() {
+ val reports = mutableListOf()
+ var flags = MouseEdgeAnchor.Flags()
+ var cookedX = 1000
+ val maxX = 1079
+
+ fun emit(targetX: Int) {
+ val plan = MouseEdgeAnchor.plan(input(targetX, 200, cookedX, 200, maxX, 2413, flags), 0)
+ reports += plan.hidX
+ flags = plan.flagsAfter
+ cookedX = plan.cookedXAfter
+ }
+
+ emit(1079)
+ assertThat(reports.last()).isEqualTo(HidMouse.MAX_DELTA)
+ emit(1079)
+ assertThat(reports.last()).isEqualTo(0)
+ emit(1079)
+ assertThat(reports.last()).isEqualTo(0)
+ emit(1000)
+ assertThat(reports.last()).isNotEqualTo(HidMouse.MAX_DELTA)
+ emit(1079)
+ assertThat(reports.last()).isEqualTo(HidMouse.MAX_DELTA)
+ assertThat(reports.count { it == HidMouse.MAX_DELTA }).isEqualTo(2)
+ }
+
+ @Test
+ fun `edge pulse does not include tangential hid`() {
+ val plan = MouseEdgeAnchor.plan(
+ input(
+ targetX = 1079,
+ targetY = 800,
+ cookedX = 500,
+ cookedY = 100,
+ maxX = 1079,
+ maxY = 2413,
+ ),
+ 0,
+ )
+ assertThat(plan.isEdgePulse).isTrue()
+ assertThat(plan.hidX).isEqualTo(HidMouse.MAX_DELTA)
+ assertThat(plan.hidY).isEqualTo(0)
+ assertThat(plan.cookedYAfter).isEqualTo(100)
+ assertThat(plan.cookedXAfter).isEqualTo(1079)
+ }
+
+ @Test
+ fun `left edge pulse keeps tangential y unchanged`() {
+ val plan = MouseEdgeAnchor.plan(
+ input(targetX = 0, targetY = 400, cookedX = 80, cookedY = 350, maxX = 1079, maxY = 2413),
+ 0,
+ )
+ assertThat(plan.hidX).isEqualTo(HidMouse.MIN_DELTA)
+ assertThat(plan.hidY).isEqualTo(0)
+ assertThat(plan.cookedYAfter).isEqualTo(350)
+ assertThat(plan.cookedXAfter).isEqualTo(0)
+ }
+
+ @Test
+ fun `corner targeting pulses both axes in one report`() {
+ val plan = MouseEdgeAnchor.plan(
+ input(targetX = 1079, targetY = 2413, cookedX = 500, cookedY = 1200, maxX = 1079, maxY = 2413),
+ 0,
+ )
+ assertThat(plan.isEdgePulse).isTrue()
+ assertThat(plan.hidX).isEqualTo(HidMouse.MAX_DELTA)
+ assertThat(plan.hidY).isEqualTo(HidMouse.MAX_DELTA)
+ assertThat(plan.cookedXAfter).isEqualTo(1079)
+ assertThat(plan.cookedYAfter).isEqualTo(2413)
+ assertThat(plan.flagsAfter.maxX).isTrue()
+ assertThat(plan.flagsAfter.maxY).isTrue()
+ }
+
+ @Test
+ fun `top-left corner pulses both negative axes`() {
+ val plan = MouseEdgeAnchor.plan(
+ input(targetX = 0, targetY = 0, cookedX = 200, cookedY = 200, maxX = 1079, maxY = 2413),
+ 0,
+ )
+ assertThat(plan.hidX).isEqualTo(HidMouse.MIN_DELTA)
+ assertThat(plan.hidY).isEqualTo(HidMouse.MIN_DELTA)
+ assertThat(plan.cookedXAfter).isEqualTo(0)
+ assertThat(plan.cookedYAfter).isEqualTo(0)
+ }
+
+ @Test
+ fun `interior snap is a single compensated report`() {
+ val reports = MouseEdgeAnchor.planSnap(
+ input(
+ targetX = 400,
+ targetY = 500,
+ cookedX = 539,
+ cookedY = 1206,
+ maxX = 1079,
+ maxY = 2413,
+ preferTangentialFirst = true,
+ ),
+ 0,
+ )
+ assertThat(reports).hasSize(1)
+ assertThat(reports[0].isEdgePulse).isFalse()
+ assertThat(reports[0].isNoOp).isFalse()
+ }
+
+ @Test
+ fun `interior snap does not emit a second compensation burst`() {
+ val reports = MouseEdgeAnchor.planSnap(
+ input(
+ targetX = 400,
+ targetY = 500,
+ cookedX = 0,
+ cookedY = 0,
+ maxX = 1079,
+ maxY = 2413,
+ preferTangentialFirst = true,
+ ),
+ 0,
+ )
+ assertThat(reports).hasSize(1)
+ assertThat(reports[0].isEdgePulse).isFalse()
+ }
+
+ @Test
+ fun `corner snap from center saturates both max axes`() {
+ val reports = MouseEdgeAnchor.planSnap(
+ input(
+ targetX = 1079,
+ targetY = 2413,
+ cookedX = 539,
+ cookedY = 1206,
+ maxX = 1079,
+ maxY = 2413,
+ preferTangentialFirst = true,
+ ),
+ 0,
+ )
+ assertThat(reports.any { it.isEdgePulse && it.hidX == HidMouse.MAX_DELTA }).isTrue()
+ assertThat(reports.any { it.isEdgePulse && it.hidY == HidMouse.MAX_DELTA }).isTrue()
+ assertThat(reports.last().cookedXAfter).isEqualTo(1079)
+ assertThat(reports.last().cookedYAfter).isEqualTo(2413)
+ }
+
+ @Test
+ fun `attach at top edge compensates X then pulses Y`() {
+ val maxX = 1079
+ val maxY = 2413
+ val centerX = maxX / 2
+ val centerY = maxY / 2
+ val reports = MouseEdgeAnchor.planSnap(
+ input(
+ targetX = 100,
+ targetY = 0,
+ cookedX = centerX,
+ cookedY = centerY,
+ maxX = maxX,
+ maxY = maxY,
+ preferTangentialFirst = true,
+ ),
+ 0,
+ )
+ assertThat(reports).hasSize(2)
+ assertThat(reports[0].isEdgePulse).isFalse()
+ assertThat(reports[0].hidY).isEqualTo(0)
+ assertThat(reports[0].hidX).isNotEqualTo(0)
+ assertThat(reports[1].isEdgePulse).isTrue()
+ assertThat(reports[1].hidX).isEqualTo(0)
+ assertThat(reports[1].hidY).isEqualTo(HidMouse.MIN_DELTA)
+ assertThat(reports[1].cookedYAfter).isEqualTo(0)
+ assertThat(reports[1].cookedXAfter).isEqualTo(reports[0].cookedXAfter)
+ }
+
+ @Test
+ fun `attach at left edge compensates Y then pulses X`() {
+ val maxX = 1079
+ val maxY = 2413
+ val reports = MouseEdgeAnchor.planSnap(
+ input(
+ targetX = 0,
+ targetY = 200,
+ cookedX = maxX / 2,
+ cookedY = maxY / 2,
+ maxX = maxX,
+ maxY = maxY,
+ preferTangentialFirst = true,
+ ),
+ 0,
+ )
+ assertThat(reports).hasSize(2)
+ assertThat(reports[0].hidX).isEqualTo(0)
+ assertThat(reports[0].hidY).isNotEqualTo(0)
+ assertThat(reports[1].hidX).isEqualTo(HidMouse.MIN_DELTA)
+ assertThat(reports[1].hidY).isEqualTo(0)
+ }
+
+ @Test
+ fun `relative move uses protocol target not the relative delta for edges`() {
+ val flags = MouseEdgeAnchor.Flags(maxX = true)
+ val plan = MouseEdgeAnchor.plan(
+ input(
+ targetX = 1070,
+ targetY = 200,
+ cookedX = 1079,
+ cookedY = 200,
+ maxX = 1079,
+ maxY = 2413,
+ flags = flags,
+ ),
+ 0,
+ )
+ assertThat(plan.isEdgePulse).isFalse()
+ assertThat(plan.hidX).isNotEqualTo(HidMouse.MAX_DELTA)
+ assertThat(plan.flagsAfter.maxX).isFalse()
+ }
+
+ @Test
+ fun `failed write must not be modeled as success`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.completeAttach(newDevice = true)
+ state.clearCenterSeed()
+ state.updatePointerTarget(1079, 200)
+ val before = state.cookedPosition()
+ val flagsBefore = state.edgeFlags
+ val plan = MouseEdgeAnchor.plan(state.plannerInput(nowMs = 10L), 0)
+ assertThat(plan.isEdgePulse).isTrue()
+ // Write fails: do not apply.
+ assertThat(state.cookedPosition()).isEqualTo(before)
+ assertThat(state.edgeFlags).isEqualTo(flagsBefore)
+ assertThat(state.lastWasEdgePulse).isFalse()
+ }
+
+ @Test
+ fun `successful plan re-anchors only after apply`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.completeAttach(newDevice = true)
+ state.clearCenterSeed()
+ state.updatePointerTarget(1079, 200)
+ val plan = MouseEdgeAnchor.plan(state.plannerInput(nowMs = 10L), 0)
+ state.applySuccessfulPlan(plan, nowMs = 10L)
+ assertThat(state.cookedX).isEqualTo(1079)
+ assertThat(state.edgeFlags.maxX).isTrue()
+ assertThat(state.lastWasEdgePulse).isTrue()
+ assertThat(state.needsCenterSeed()).isFalse()
+ }
+
+ @Test
+ fun `resize resets edge-anchor flags so the new bounds can re-pulse`() {
+ val state = HidMouseState(maxX = 1079, maxY = 2413)
+ state.completeAttach(newDevice = true)
+ state.clearCenterSeed()
+ state.updatePointerTarget(1079, 200)
+ state.applySuccessfulPlan(MouseEdgeAnchor.plan(state.plannerInput(1L), 0), 1L)
+ assertThat(state.edgeFlags.maxX).isTrue()
+ state.resizeDisplay(newMaxX = 1919, newMaxY = 1079)
+ assertThat(state.edgeFlags).isEqualTo(MouseEdgeAnchor.Flags())
+ assertThat(state.lastWasEdgePulse).isFalse()
+ }
+
+ @Test
+ fun `post-pulse tangential uses last-segment gain inside the velocity horizon`() {
+ val afterPulse = MouseEdgeAnchor.plan(
+ input(
+ targetX = 1079,
+ targetY = 400,
+ cookedX = 1079,
+ cookedY = 200,
+ maxX = 1079,
+ maxY = 2413,
+ flags = MouseEdgeAnchor.Flags(maxX = true),
+ lastWasEdgePulse = true,
+ dtMs = 16L,
+ ),
+ 0,
+ )
+ val lastSeg = MousePointerCompensation.gainForHidSpeed(1_000_000.0, 0)
+ assertThat(afterPulse.isEdgePulse).isFalse()
+ assertThat(afterPulse.gain).isWithin(0.0001).of(lastSeg)
+ assertThat(afterPulse.hidX).isEqualTo(0)
+ assertThat(afterPulse.hidY).isNotEqualTo(0)
+
+ val afterHorizon = MouseEdgeAnchor.plan(
+ input(
+ targetX = 1079,
+ targetY = 400,
+ cookedX = 1079,
+ cookedY = 200,
+ maxX = 1079,
+ maxY = 2413,
+ flags = MouseEdgeAnchor.Flags(maxX = true),
+ lastWasEdgePulse = true,
+ dtMs = MouseEdgeAnchor.EDGE_PULSE_GAIN_HORIZON_MS,
+ ),
+ 0,
+ )
+ assertThat(afterHorizon.gain).isNotWithin(0.0001).of(lastSeg)
+ }
+
+ @Test
+ fun `one-pixel display pulses min only`() {
+ val plan = MouseEdgeAnchor.plan(
+ input(targetX = 0, targetY = 0, cookedX = 0, cookedY = 0, maxX = 0, maxY = 0),
+ 0,
+ )
+ assertThat(plan.hidX).isEqualTo(HidMouse.MIN_DELTA)
+ assertThat(plan.hidY).isEqualTo(HidMouse.MIN_DELTA)
+ assertThat(plan.flagsAfter.minX).isTrue()
+ assertThat(plan.flagsAfter.maxX).isFalse()
+ }
+
+ @Test
+ fun `planner no-op when already anchored at the same edge`() {
+ val plan = MouseEdgeAnchor.plan(
+ input(
+ targetX = 0,
+ targetY = 200,
+ cookedX = 0,
+ cookedY = 200,
+ maxX = 1079,
+ maxY = 2413,
+ flags = MouseEdgeAnchor.Flags(minX = true),
+ ),
+ 0,
+ )
+ assertThat(plan.isNoOp).isTrue()
+ assertThat(plan.isEdgePulse).isFalse()
+ }
+
+ private fun input(
+ targetX: Int,
+ targetY: Int,
+ cookedX: Int,
+ cookedY: Int,
+ maxX: Int,
+ maxY: Int,
+ flags: MouseEdgeAnchor.Flags = MouseEdgeAnchor.Flags(),
+ preferTangentialFirst: Boolean = false,
+ lastWasEdgePulse: Boolean = false,
+ dtMs: Long = 0L,
+ ) = MouseEdgeAnchor.Input(
+ targetX = targetX,
+ targetY = targetY,
+ cookedX = cookedX,
+ cookedY = cookedY,
+ maxX = maxX,
+ maxY = maxY,
+ flags = flags,
+ preferTangentialFirst = preferTangentialFirst,
+ lastWasEdgePulse = lastWasEdgePulse,
+ dtMs = dtMs,
+ )
+
+ private data class Sim(
+ var cookedX: Int,
+ var cookedY: Int,
+ var physicalX: Int,
+ var physicalY: Int,
+ val maxX: Int,
+ val maxY: Int,
+ var flags: MouseEdgeAnchor.Flags = MouseEdgeAnchor.Flags(),
+ var lastWasEdgePulse: Boolean = false,
+ )
+
+ private fun step(
+ sim: Sim,
+ targetX: Int,
+ targetY: Int,
+ actualGain: Double,
+ dtMs: Long,
+ ): MouseEdgeAnchor.Plan {
+ val plan = MouseEdgeAnchor.plan(
+ input(
+ targetX, targetY, sim.cookedX, sim.cookedY, sim.maxX, sim.maxY,
+ flags = sim.flags,
+ lastWasEdgePulse = sim.lastWasEdgePulse,
+ dtMs = dtMs,
+ ),
+ 0,
+ )
+ if (plan.isNoOp) return plan
+ sim.cookedX = plan.cookedXAfter
+ sim.cookedY = plan.cookedYAfter
+ sim.flags = plan.flagsAfter
+ sim.lastWasEdgePulse = plan.isEdgePulse
+ sim.physicalX = (sim.physicalX + (plan.hidX * actualGain).roundToInt()).coerceIn(0, sim.maxX)
+ sim.physicalY = (sim.physicalY + (plan.hidY * actualGain).roundToInt()).coerceIn(0, sim.maxY)
+ return plan
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensationTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensationTest.kt
new file mode 100644
index 00000000..50f20a1e
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensationTest.kt
@@ -0,0 +1,133 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.roundToInt
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+class MousePointerCompensationTest {
+
+ @Before
+ fun disableOplusRemapUnlessTestEnablesIt() {
+ MousePointerCompensation.setOplusFamilyRemapForTest(false)
+ }
+
+ @After
+ fun resetVendorPolicy() {
+ MousePointerCompensation.resetVendorPolicyForTest()
+ }
+
+ @Test
+ fun `first segment gain at settings speed zero matches AOSP formula`() {
+ // 0.64 * SENSITIVITY[0+7=10] / 10 * 3.19
+ assertThat(MousePointerCompensation.firstSegmentGain(0)).isWithin(0.0001).of(2.0416)
+ }
+
+ @Test
+ fun `oplus remap applied once for firstSegmentGain and gainForSpeedMmPerS`() {
+ MousePointerCompensation.setOplusFamilyRemapForTest(true)
+ val settingsSpeed = 0
+ val effective = MousePointerCompensation.effectivePointerSpeedForGain(settingsSpeed)
+ assertThat(effective).isEqualTo(-1)
+
+ val expected = MousePointerCompensation.segmentBaseGainFromEffectiveSpeed(effective)
+ // 0.64 * SENSITIVITY[-1+7=6=9] / 10 * 3.19 = 1.83744
+ assertThat(expected).isWithin(0.0001).of(1.83744)
+
+ assertThat(MousePointerCompensation.firstSegmentGain(settingsSpeed))
+ .isWithin(0.0001)
+ .of(expected)
+ assertThat(MousePointerCompensation.gainForSpeedMmPerS(0.0, settingsSpeed))
+ .isWithin(0.0001)
+ .of(expected)
+ }
+
+ @Test
+ fun `gainForSpeedMmPerS at zero mm per s equals first segment gain`() {
+ val speed = 3
+ val first = MousePointerCompensation.firstSegmentGain(speed)
+ val fromCurve = MousePointerCompensation.gainForSpeedMmPerS(0.0, speed)
+ assertThat(fromCurve).isWithin(0.0001).of(first)
+ }
+
+ @Test
+ fun `planHidMove uses single gain for hid and cooked prediction`() {
+ val plan = MousePointerCompensation.planHidMove(-100, 0, settingsSpeed = 0, dtMs = 0L)
+ assertThat(plan.gain).isWithin(0.0001).of(2.0416)
+ assertThat(plan.hidX).isEqualTo(-49)
+ assertThat(plan.cookedDelta().first).isEqualTo(-100)
+ }
+
+ @Test
+ fun `STOP_TIME forces first segment gain even with short prior interval`() {
+ val continuous = MousePointerCompensation.planHidMove(50, 0, 0, dtMs = 16L)
+ val afterStop = MousePointerCompensation.planHidMove(50, 0, 0, dtMs = MousePointerCompensation.STOP_TIME_MS)
+ assertThat(afterStop.gain).isWithin(0.0001).of(2.0416)
+ assertThat(continuous.gain).isNotWithin(0.0001).of(afterStop.gain)
+ }
+
+ @Test
+ fun `enter from center to top edge round trips under open loop model`() {
+ val centerY = 1200
+ val targetY = 0
+ val errY = targetY - centerY
+ val plan = MousePointerCompensation.planHidMove(0, errY, settingsSpeed = 0, dtMs = 0L)
+ val cookedDy = plan.cookedDelta().second
+ assertThat(centerY + cookedDy).isEqualTo(targetY)
+ }
+
+ @Test
+ fun `planHidMove returns no-op for zero error`() {
+ val plan = MousePointerCompensation.planHidMove(0, 0, 0, dtMs = 0L)
+ assertThat(plan.isNoOp).isTrue()
+ }
+
+ @Test
+ fun `scaleCoord preserves proportional position on rotation resize`() {
+ assertThat(MousePointerCompensation.scaleCoord(540, 1079, 2413)).isEqualTo(1207)
+ assertThat(MousePointerCompensation.scaleCoord(0, 1079, 2413)).isEqualTo(0)
+ }
+
+ @Test
+ fun `speed change keeps continuous gain when dt is within STOP_TIME`() {
+ val continuousAtOldSpeed = MousePointerCompensation.planHidMove(80, 0, settingsSpeed = 0, dtMs = 16L)
+ val continuousAtNewSpeed = MousePointerCompensation.planHidMove(80, 0, settingsSpeed = 3, dtMs = 16L)
+ assertThat(continuousAtNewSpeed.gain).isNotWithin(0.0001).of(MousePointerCompensation.firstSegmentGain(3))
+ assertThat(continuousAtNewSpeed.gain).isNotWithin(0.0001).of(continuousAtOldSpeed.gain)
+ }
+
+ @Test
+ fun `sub gain odd residuals produce no hid report and no jitter on repeat`() {
+ val highGain = 11.0
+ for (residual in intArrayOf(1, -1, 3, -3, 5, -5)) {
+ assertThat(MousePointerCompensation.screenDeltaToHid(residual, highGain)).isEqualTo(0)
+ }
+ for (residual in intArrayOf(1, -1)) {
+ val first = MousePointerCompensation.planHidMove(residual, 0, settingsSpeed = 0, dtMs = 16L)
+ val second = MousePointerCompensation.planHidMove(residual, 0, settingsSpeed = 0, dtMs = 16L)
+ assertThat(first.isNoOp).isTrue()
+ assertThat(second.isNoOp).isTrue()
+ assertThat(first.hidX).isEqualTo(second.hidX)
+ assertThat(first.hidY).isEqualTo(second.hidY)
+ }
+ }
+
+ @Test
+ fun `single plan emits one hid report worth of movement`() {
+ val plan = MousePointerCompensation.planHidMove(500, 0, settingsSpeed = 0, dtMs = 0L)
+ assertThat(plan.hidX).isEqualTo(245)
+ assertThat(plan.hidY).isEqualTo(0)
+ assertThat(plan.cookedDelta().first).isEqualTo(500)
+ }
+
+ @Test
+ fun `large error clamp leaves residual for a later event not a burst`() {
+ val errX = 150_000
+ val plan = MousePointerCompensation.planHidMove(errX, 0, settingsSpeed = 0, dtMs = 0L)
+ assertThat(plan.hidX).isEqualTo(32767)
+ val cookedDx = plan.cookedDelta().first
+ assertThat(cookedDx).isLessThan(errX)
+ assertThat(cookedDx).isEqualTo((32767 * plan.gain).roundToInt())
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidChannelReadinessTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidChannelReadinessTest.kt
new file mode 100644
index 00000000..8b7a6000
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidChannelReadinessTest.kt
@@ -0,0 +1,159 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import java.io.ByteArrayOutputStream
+import java.io.PipedInputStream
+import java.io.PipedOutputStream
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.util.concurrent.TimeUnit
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Readiness is driven through [UhidReadinessConfig]'s injectable `sleeper`/`nanoTime`
+ * rather than wall-clock timing, so no assertion depends on how fast the machine is.
+ * The kernel event is written into the pipe before createDevice instead of from a
+ * feeder thread that sleeps, which is what previously made these races.
+ */
+@RunWith(RobolectricTestRunner::class)
+class UhidChannelReadinessTest {
+
+ /** Virtual clock: `sleeper` advances it, and yields briefly so the reader thread runs. */
+ private class TestClock {
+ var nowNanos = 0L
+ private set
+
+ fun nanoTime(): Long = nowNanos
+
+ fun sleep(ms: Long) {
+ nowNanos += TimeUnit.MILLISECONDS.toNanos(ms)
+ Thread.sleep(1) // let the real reader thread drain the pipe
+ }
+
+ fun elapsedMs(): Long = TimeUnit.NANOSECONDS.toMillis(nowNanos)
+ }
+
+ @Test
+ fun `OPEN completes readiness without burning the whole timeout`() {
+ val clock = TestClock()
+ val kernel = PipedOutputStream()
+ val input = PipedInputStream(kernel, 8192)
+ kernel.write(typeWord(UhidProtocol.UHID_OPEN))
+ kernel.flush()
+
+ val channel = UhidChannel.forTesting(
+ ByteArrayOutputStream(),
+ input,
+ UhidReadinessConfig(
+ openTimeoutMs = 400,
+ presenceTimeoutMs = 150,
+ presence = { _, _ -> true },
+ sleeper = clock::sleep,
+ nanoTime = clock::nanoTime,
+ ),
+ )
+
+ channel.createDevice("Input Leaf Keyboard HID", byteArrayOf(1), uniq = "inputleaf-kbd")
+
+ assertThat(clock.elapsedMs()).isLessThan(400)
+ channel.close()
+ kernel.close()
+ }
+
+ @Test
+ fun `presence probe runs once OPEN arrives`() {
+ val clock = TestClock()
+ val kernel = PipedOutputStream()
+ val input = PipedInputStream(kernel, 8192)
+ kernel.write(typeWord(UhidProtocol.UHID_OPEN))
+ kernel.flush()
+
+ var presenceChecks = 0
+ val channel = UhidChannel.forTesting(
+ ByteArrayOutputStream(),
+ input,
+ UhidReadinessConfig(
+ openTimeoutMs = 400,
+ presenceTimeoutMs = 400,
+ presence = { name, uniq ->
+ presenceChecks++
+ name == "mouse" && uniq == "inputleaf-mouse"
+ },
+ sleeper = clock::sleep,
+ nanoTime = clock::nanoTime,
+ ),
+ )
+
+ channel.createDevice("mouse", byteArrayOf(1), uniq = "inputleaf-mouse")
+
+ assertThat(presenceChecks).isGreaterThan(0)
+ assertThat(clock.elapsedMs()).isLessThan(800)
+ channel.close()
+ kernel.close()
+ }
+
+ @Test
+ fun `no OPEN degrades within the bounded window instead of hanging`() {
+ // The ColorOS path: that OEM never emits OPEN, so creation must still finish.
+ val clock = TestClock()
+ val kernel = PipedOutputStream()
+ val input = PipedInputStream(kernel, 8192)
+ var presenceChecks = 0
+
+ val channel = UhidChannel.forTesting(
+ ByteArrayOutputStream(),
+ input,
+ UhidReadinessConfig(
+ openTimeoutMs = 300,
+ presenceTimeoutMs = 150,
+ presence = { _, _ -> presenceChecks++; true },
+ sleeper = clock::sleep,
+ nanoTime = clock::nanoTime,
+ ),
+ )
+
+ channel.createDevice("mouse", byteArrayOf(1), uniq = "inputleaf-mouse")
+
+ // Gave up on OPEN and never reached the presence stage.
+ assertThat(clock.elapsedMs()).isAtLeast(300)
+ assertThat(presenceChecks).isEqualTo(0)
+ channel.close()
+ kernel.close()
+ }
+
+ @Test
+ fun `a kernel START alone is not treated as readiness`() {
+ // START(2) means hid-core created the device, which is before EventHub attaches.
+ val clock = TestClock()
+ val kernel = PipedOutputStream()
+ val input = PipedInputStream(kernel, 8192)
+ kernel.write(typeWord(UhidProtocol.UHID_START))
+ kernel.write(ByteArray(UhidProtocol.payloadSize(UhidProtocol.UHID_START)))
+ kernel.flush()
+ var presenceChecks = 0
+
+ val channel = UhidChannel.forTesting(
+ ByteArrayOutputStream(),
+ input,
+ UhidReadinessConfig(
+ openTimeoutMs = 300,
+ presenceTimeoutMs = 150,
+ presence = { _, _ -> presenceChecks++; true },
+ sleeper = clock::sleep,
+ nanoTime = clock::nanoTime,
+ ),
+ )
+
+ channel.createDevice("mouse", byteArrayOf(1), uniq = "inputleaf-mouse")
+
+ assertThat(clock.elapsedMs()).isAtLeast(300)
+ assertThat(presenceChecks).isEqualTo(0)
+ channel.close()
+ kernel.close()
+ }
+
+ private fun typeWord(type: Int): ByteArray =
+ ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(type).array()
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidProtocolTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidProtocolTest.kt
new file mode 100644
index 00000000..8a2281cf
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidProtocolTest.kt
@@ -0,0 +1,155 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import java.io.ByteArrayInputStream
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import org.junit.Test
+
+class UhidProtocolTest {
+
+ @Test
+ fun `create2 packet matches the kernel struct layout`() {
+ // The committed uhid-server wrote rd_size as a u32 at 132, which lands inside
+ // phys[]. It must be a u16 at 260, after name[128] + phys[64] + uniq[64].
+ assertThat(UhidProtocol.CREATE2_HEADER_SIZE).isEqualTo(276)
+ assertThat(UhidProtocol.CREATE2_PACKET_SIZE).isEqualTo(4376)
+ assertThat(UhidProtocol.INPUT2_PACKET_SIZE).isEqualTo(4102)
+ assertThat(UhidProtocol.OFFSET_RD_SIZE).isEqualTo(260)
+ assertThat(UhidProtocol.OFFSET_BUS).isEqualTo(262)
+ assertThat(UhidProtocol.OFFSET_DESCRIPTOR).isEqualTo(280)
+ }
+
+ @Test
+ fun `create2 writes type, name, descriptor size and bus`() {
+ val descriptor = HidKeyboard.DESCRIPTOR
+ val packet = UhidProtocol.create2Packet(
+ "Input Leaf Keyboard HID",
+ descriptor,
+ vendor = 0x1209,
+ product = 1,
+ uniq = "inputleaf-kbd",
+ )
+ val buffer = ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN)
+
+ assertThat(packet.size).isEqualTo(UhidProtocol.CREATE2_PACKET_SIZE)
+ assertThat(buffer.getInt(0)).isEqualTo(UhidProtocol.UHID_CREATE2)
+ assertThat(String(packet, 4, "Input Leaf Keyboard HID".length)).isEqualTo("Input Leaf Keyboard HID")
+ assertThat(String(packet, UhidProtocol.OFFSET_UNIQ, "inputleaf-kbd".length)).isEqualTo("inputleaf-kbd")
+ assertThat(buffer.getShort(UhidProtocol.OFFSET_RD_SIZE).toInt()).isEqualTo(descriptor.size)
+ assertThat(buffer.getShort(UhidProtocol.OFFSET_BUS).toInt()).isEqualTo(UhidProtocol.BUS_USB)
+ assertThat(buffer.getInt(UhidProtocol.OFFSET_BUS + 2)).isEqualTo(0x1209)
+ assertThat(buffer.getInt(UhidProtocol.OFFSET_BUS + 6)).isEqualTo(1)
+ }
+
+ @Test
+ fun `keyboard and mouse uniq strings differ`() {
+ val keyboard = UhidProtocol.create2Packet("kb", byteArrayOf(1), vendor = 0x1209, product = 1, uniq = "inputleaf-kbd")
+ val mouse = UhidProtocol.create2Packet("ms", byteArrayOf(2), vendor = 0x1209, product = 2, uniq = "inputleaf-mouse")
+ val kbUniq = String(keyboard, UhidProtocol.OFFSET_UNIQ, "inputleaf-kbd".length)
+ val msUniq = String(mouse, UhidProtocol.OFFSET_UNIQ, "inputleaf-mouse".length)
+ assertThat(kbUniq).isNotEqualTo(msUniq)
+ }
+
+ @Test
+ fun `event types match the kernel uhid ABI`() {
+ // Literal numbers on purpose: asserting against the constants would only prove
+ // the file agrees with itself, which is how START=4 / OPEN=6 survived before.
+ // Source: enum uhid_event_type, uapi/linux/uhid.h.
+ assertThat(UhidProtocol.UHID_DESTROY).isEqualTo(1)
+ assertThat(UhidProtocol.UHID_START).isEqualTo(2)
+ assertThat(UhidProtocol.UHID_STOP).isEqualTo(3)
+ assertThat(UhidProtocol.UHID_OPEN).isEqualTo(4)
+ assertThat(UhidProtocol.UHID_CLOSE).isEqualTo(5)
+ assertThat(UhidProtocol.UHID_OUTPUT).isEqualTo(6)
+ assertThat(UhidProtocol.UHID_GET_REPORT).isEqualTo(9)
+ assertThat(UhidProtocol.UHID_CREATE2).isEqualTo(11)
+ assertThat(UhidProtocol.UHID_INPUT2).isEqualTo(12)
+ assertThat(UhidProtocol.UHID_SET_REPORT).isEqualTo(13)
+ }
+
+ @Test
+ fun `payload sizes match the packed structs in uhid_h`() {
+ assertThat(UhidProtocol.payloadSize(UhidProtocol.UHID_START)).isEqualTo(8)
+ assertThat(UhidProtocol.payloadSize(UhidProtocol.UHID_STOP)).isEqualTo(0)
+ assertThat(UhidProtocol.payloadSize(UhidProtocol.UHID_OPEN)).isEqualTo(0)
+ assertThat(UhidProtocol.payloadSize(UhidProtocol.UHID_CLOSE)).isEqualTo(0)
+ // The regression that mattered: OUTPUT was treated as zero-payload, so draining
+ // one would leave 4099 bytes in the stream and misframe every later read.
+ assertThat(UhidProtocol.payloadSize(UhidProtocol.UHID_OUTPUT)).isEqualTo(4099)
+ assertThat(UhidProtocol.payloadSize(UhidProtocol.UHID_GET_REPORT)).isEqualTo(6)
+ assertThat(UhidProtocol.payloadSize(UhidProtocol.UHID_INPUT2)).isEqualTo(4098)
+ }
+
+ @Test
+ fun `create2 copies the descriptor to the right offset`() {
+ val descriptor = byteArrayOf(1, 2, 3, 4, 5)
+ val packet = UhidProtocol.create2Packet("d", descriptor)
+
+ assertThat(packet.copyOfRange(280, 285).toList()).containsExactly(
+ 1.toByte(), 2.toByte(), 3.toByte(), 4.toByte(), 5.toByte(),
+ ).inOrder()
+ }
+
+ @Test
+ fun `create2 truncates an over-long name and keeps it NUL terminated`() {
+ val packet = UhidProtocol.create2Packet("x".repeat(400), byteArrayOf(1))
+ assertThat(packet.size).isEqualTo(UhidProtocol.CREATE2_PACKET_SIZE)
+ // name[128] occupies offsets 4..131, so the terminator is the last of those.
+ assertThat(packet[130]).isEqualTo('x'.code.toByte())
+ assertThat(packet[131]).isEqualTo(0.toByte())
+ }
+
+ @Test
+ fun `create2 rejects an oversized descriptor`() {
+ val tooBig = ByteArray(UhidProtocol.HID_MAX_DESCRIPTOR_SIZE + 1)
+ runCatching { UhidProtocol.create2Packet("d", tooBig) }
+ .onSuccess { error("expected a rejection") }
+ .onFailure { assertThat(it).isInstanceOf(IllegalArgumentException::class.java) }
+ }
+
+ @Test
+ fun `input2 writes type, size and payload`() {
+ val report = byteArrayOf(0x01, 0x02, 0x03)
+ val packet = UhidProtocol.input2Packet(report)
+ val buffer = ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN)
+
+ assertThat(packet.size).isEqualTo(UhidProtocol.INPUT2_PACKET_SIZE)
+ assertThat(buffer.getInt(0)).isEqualTo(UhidProtocol.UHID_INPUT2)
+ assertThat(buffer.getShort(4).toInt()).isEqualTo(3)
+ assertThat(packet.copyOfRange(6, 9).toList())
+ .containsExactly(0x01.toByte(), 0x02.toByte(), 0x03.toByte()).inOrder()
+ }
+
+ @Test
+ fun `destroy packet is a full event with DESTROY type`() {
+ val packet = UhidProtocol.destroyPacket()
+ assertThat(packet.size).isEqualTo(UhidProtocol.EVENT_PACKET_SIZE)
+ assertThat(ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).getInt(0))
+ .isEqualTo(UhidProtocol.UHID_DESTROY)
+ assertThat(packet.drop(4).all { it == 0.toByte() }).isTrue()
+ }
+
+ @Test
+ fun `waitForStart recognises the START event`() {
+ val stream = ByteArrayInputStream(typeWord(UhidProtocol.UHID_START))
+
+ assertThat(UhidProtocol.waitForStart(stream, timeoutMs = 100)).isTrue()
+ }
+
+ @Test
+ fun `waitForStart gives up when START never arrives`() {
+ var clock = 0L
+ val stream = ByteArrayInputStream(ByteArray(0))
+
+ val started = UhidProtocol.waitForStart(stream, timeoutMs = 50) {
+ clock += 20
+ clock
+ }
+
+ assertThat(started).isFalse()
+ }
+
+ private fun typeWord(type: Int): ByteArray =
+ ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(type).array()
+}
diff --git a/app/src/test/java/com/inputleaf/android/shizuku/uhid/WheelNotchAccumulatorTest.kt b/app/src/test/java/com/inputleaf/android/shizuku/uhid/WheelNotchAccumulatorTest.kt
new file mode 100644
index 00000000..da164818
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/WheelNotchAccumulatorTest.kt
@@ -0,0 +1,60 @@
+package com.inputleaf.android.shizuku.uhid
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class WheelNotchAccumulatorTest {
+
+ @Test
+ fun `a conforming 120-unit delta is one notch`() {
+ val acc = WheelNotchAccumulator()
+ assertThat(acc.accept(120)).isEqualTo(1)
+ assertThat(acc.accept(-120)).isEqualTo(-1)
+ }
+
+ @Test
+ fun `sub-notch deltas are banked rather than dropped`() {
+ val acc = WheelNotchAccumulator()
+ // The regression: each of these used to truncate to 0 and scroll nothing.
+ assertThat(acc.accept(60)).isEqualTo(0)
+ assertThat(acc.accept(60)).isEqualTo(1)
+ }
+
+ @Test
+ fun `banking works symmetrically for negative deltas`() {
+ val acc = WheelNotchAccumulator()
+ assertThat(acc.accept(-60)).isEqualTo(0)
+ assertThat(acc.accept(-60)).isEqualTo(-1)
+ }
+
+ @Test
+ fun `opposite partial deltas cancel instead of scrolling`() {
+ val acc = WheelNotchAccumulator()
+ assertThat(acc.accept(60)).isEqualTo(0)
+ assertThat(acc.accept(-60)).isEqualTo(0)
+ assertThat(acc.accept(120)).isEqualTo(1)
+ }
+
+ @Test
+ fun `a large delta yields every whole notch at once and keeps the rest`() {
+ val acc = WheelNotchAccumulator()
+ assertThat(acc.accept(370)).isEqualTo(3)
+ assertThat(acc.accept(110)).isEqualTo(1)
+ }
+
+ @Test
+ fun `reset drops a partial notch so it cannot leak into the next attach`() {
+ val acc = WheelNotchAccumulator()
+ assertThat(acc.accept(60)).isEqualTo(0)
+ acc.reset()
+ assertThat(acc.accept(60)).isEqualTo(0)
+ }
+
+ @Test
+ fun `no accumulated drift over a long conforming stream`() {
+ val acc = WheelNotchAccumulator()
+ var total = 0
+ repeat(1000) { total += acc.accept(120) }
+ assertThat(total).isEqualTo(1000)
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt b/app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt
new file mode 100644
index 00000000..ab2102c2
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt
@@ -0,0 +1,254 @@
+package com.inputleaf.android.ui
+
+import com.google.common.truth.Truth.assertThat
+import com.inputleaf.android.ui.components.MarkdownBlock
+import com.inputleaf.android.ui.components.MarkdownParser
+import org.junit.Test
+
+class MarkdownParserTest {
+
+ @Test
+ fun parseBlocks_extractsHeadingsCorrectly() {
+ val markdown = """
+ # Title 1
+ ## Title 2
+ ### Title 3
+ """.trimIndent()
+
+ val blocks = MarkdownParser.parseBlocks(markdown)
+ assertThat(blocks).hasSize(3)
+
+ val h1 = blocks[0] as MarkdownBlock.Heading
+ assertThat(h1.level).isEqualTo(1)
+ assertThat(h1.text).isEqualTo("Title 1")
+
+ val h2 = blocks[1] as MarkdownBlock.Heading
+ assertThat(h2.level).isEqualTo(2)
+ assertThat(h2.text).isEqualTo("Title 2")
+
+ val h3 = blocks[2] as MarkdownBlock.Heading
+ assertThat(h3.level).isEqualTo(3)
+ assertThat(h3.text).isEqualTo("Title 3")
+ }
+
+ @Test
+ fun parseBlocks_extractsBulletListItems() {
+ val markdown = """
+ * Star bullet
+ - Dash bullet
+ + Plus bullet
+ • Unicode bullet
+ """.trimIndent()
+
+ val blocks = MarkdownParser.parseBlocks(markdown)
+ assertThat(blocks).hasSize(4)
+
+ for (b in blocks) {
+ assertThat(b).isInstanceOf(MarkdownBlock.ListItem::class.java)
+ val item = b as MarkdownBlock.ListItem
+ assertThat(item.isOrdered).isFalse()
+ }
+
+ assertThat((blocks[0] as MarkdownBlock.ListItem).text).isEqualTo("Star bullet")
+ assertThat((blocks[1] as MarkdownBlock.ListItem).text).isEqualTo("Dash bullet")
+ assertThat((blocks[2] as MarkdownBlock.ListItem).text).isEqualTo("Plus bullet")
+ assertThat((blocks[3] as MarkdownBlock.ListItem).text).isEqualTo("Unicode bullet")
+ }
+
+ @Test
+ fun parseBlocks_extractsOrderedListItems() {
+ val markdown = """
+ 1. First item
+ 2. Second item
+ 10. Tenth item
+ """.trimIndent()
+
+ val blocks = MarkdownParser.parseBlocks(markdown)
+ assertThat(blocks).hasSize(3)
+
+ val item1 = blocks[0] as MarkdownBlock.ListItem
+ assertThat(item1.isOrdered).isTrue()
+ assertThat(item1.marker).isEqualTo("1.")
+ assertThat(item1.text).isEqualTo("First item")
+
+ val item2 = blocks[1] as MarkdownBlock.ListItem
+ assertThat(item2.isOrdered).isTrue()
+ assertThat(item2.marker).isEqualTo("2.")
+ assertThat(item2.text).isEqualTo("Second item")
+
+ val item3 = blocks[2] as MarkdownBlock.ListItem
+ assertThat(item3.isOrdered).isTrue()
+ assertThat(item3.marker).isEqualTo("10.")
+ assertThat(item3.text).isEqualTo("Tenth item")
+ }
+
+ @Test
+ fun parseBlocks_extractsCodeBlock() {
+ val markdown = """
+ ```kotlin
+ val x = 42
+ println(x)
+ ```
+ """.trimIndent()
+
+ val blocks = MarkdownParser.parseBlocks(markdown)
+ assertThat(blocks).hasSize(1)
+
+ val codeBlock = blocks[0] as MarkdownBlock.CodeBlock
+ assertThat(codeBlock.language).isEqualTo("kotlin")
+ assertThat(codeBlock.code).isEqualTo("val x = 42\nprintln(x)")
+ }
+
+ @Test
+ fun parseBlocks_extractsBlockquote() {
+ val markdown = "> This is a blockquote note"
+
+ val blocks = MarkdownParser.parseBlocks(markdown)
+ assertThat(blocks).hasSize(1)
+
+ val bq = blocks[0] as MarkdownBlock.Blockquote
+ assertThat(bq.text).isEqualTo("This is a blockquote note")
+ }
+
+ @Test
+ fun parseBlocks_extractsDivider() {
+ val markdown = """
+ Paragraph before
+ ---
+ Paragraph after
+ """.trimIndent()
+
+ val blocks = MarkdownParser.parseBlocks(markdown)
+ assertThat(blocks).hasSize(3)
+ assertThat(blocks[0]).isInstanceOf(MarkdownBlock.Paragraph::class.java)
+ assertThat(blocks[1]).isInstanceOf(MarkdownBlock.Divider::class.java)
+ assertThat(blocks[2]).isInstanceOf(MarkdownBlock.Paragraph::class.java)
+ }
+
+ @Test
+ fun parseBlocks_handlesRealGitHubRelease() {
+ val releaseNotes = """
+ ## What's Changed
+ * Show mouse cursor in accessibility service by @anasvhora284 in https://github.com/anasvhora284/input-leaf/pull/41
+ * Use real HID keyboard in Shizuku mode by @anasvhora284 in https://github.com/anasvhora284/input-leaf/pull/42
+
+ **Full Changelog**: https://github.com/anasvhora284/input-leaf/compare/1.4.1...v1.4.2
+ """.trimIndent()
+
+ val blocks = MarkdownParser.parseBlocks(releaseNotes)
+ assertThat(blocks).hasSize(4)
+
+ assertThat(blocks[0]).isInstanceOf(MarkdownBlock.Heading::class.java)
+ assertThat((blocks[0] as MarkdownBlock.Heading).text).isEqualTo("What's Changed")
+
+ assertThat(blocks[1]).isInstanceOf(MarkdownBlock.ListItem::class.java)
+ assertThat((blocks[1] as MarkdownBlock.ListItem).text).contains("Show mouse cursor")
+
+ assertThat(blocks[2]).isInstanceOf(MarkdownBlock.ListItem::class.java)
+ assertThat((blocks[2] as MarkdownBlock.ListItem).text).contains("Use real HID keyboard")
+
+ assertThat(blocks[3]).isInstanceOf(MarkdownBlock.Paragraph::class.java)
+ assertThat((blocks[3] as MarkdownBlock.Paragraph).text).contains("Full Changelog")
+ }
+
+ @Test
+ fun parseInline_extractsBoldText() {
+ val annotated = MarkdownParser.parseInline("Hello **World** and __Universe__")
+ assertThat(annotated.text).isEqualTo("Hello World and Universe")
+ assertThat(annotated.spanStyles).isNotEmpty()
+ }
+
+ @Test
+ fun parseInline_extractsItalicText() {
+ val annotated = MarkdownParser.parseInline("Hello *World* and _Universe_")
+ assertThat(annotated.text).isEqualTo("Hello World and Universe")
+ assertThat(annotated.spanStyles).isNotEmpty()
+ }
+
+ @Test
+ fun parseInline_extractsInlineCode() {
+ val annotated = MarkdownParser.parseInline("Use `input tap` to click")
+ assertThat(annotated.text).isEqualTo("Use input tap to click")
+ assertThat(annotated.spanStyles).isNotEmpty()
+ }
+
+ @Test
+ fun parseInline_extractsMarkdownLink() {
+ val annotated = MarkdownParser.parseInline("Check [Input Leaf](https://inputleaf.com) today")
+ assertThat(annotated.text).isEqualTo("Check Input Leaf today")
+ val links = annotated.getLinkAnnotations(0, annotated.length)
+ assertThat(links).isNotEmpty()
+ }
+
+ @Test
+ fun parseInline_markdownLinkWithNonHttpSchemeIsNotLinkified() {
+ // Release bodies are remote, so a tampered one can name any scheme here.
+ // AndroidUriHandler sends whatever it gets to ACTION_VIEW.
+ val hostile = listOf(
+ "intent://scan/#Intent;scheme=zxing;end",
+ "javascript:alert(1)",
+ "file:///data/data/com.inputleaf.android/databases",
+ "content://com.android.contacts/contacts",
+ "market://details?id=com.evil",
+ )
+ for (url in hostile) {
+ val annotated = MarkdownParser.parseInline("Tap [Update now]($url) to continue")
+ // The property that matters: nothing dispatchable reaches the UriHandler.
+ assertThat(annotated.getLinkAnnotations(0, annotated.length)).isEmpty()
+ assertThat(annotated.text).contains("Update now")
+ assertThat(annotated.text).doesNotContain(url)
+ }
+ }
+
+ @Test
+ fun parseInline_unlinkifiedLabelRendersAsCleanText() {
+ // Separate from the security assertion because INLINE_TOKEN_REGEX stops the URL
+ // at the first ')', so a URL containing parens leaves a stray ')' behind. That
+ // is a pre-existing cosmetic quirk of the regex, not a scheme-check failure.
+ val annotated = MarkdownParser.parseInline("Tap [Update now](intent://evil) to continue")
+ assertThat(annotated.text).isEqualTo("Tap Update now to continue")
+ assertThat(annotated.getLinkAnnotations(0, annotated.length)).isEmpty()
+ }
+
+ @Test
+ fun parseInline_markdownLinkKeepsHttpAndHttpsLinkified() {
+ for (url in listOf("https://inputleaf.com", "http://inputleaf.com")) {
+ val annotated = MarkdownParser.parseInline("Read [notes]($url) here")
+ assertThat(annotated.text).isEqualTo("Read notes here")
+ assertThat(annotated.getLinkAnnotations(0, annotated.length)).isNotEmpty()
+ }
+ }
+
+ @Test
+ fun parseInline_schemeCheckFailsClosedOnOddCasingAndPadding() {
+ // Not required to linkify these; required never to dispatch them.
+ for (url in listOf("HTTPS://inputleaf.com", " https://inputleaf.com", "https:/inputleaf.com")) {
+ val annotated = MarkdownParser.parseInline("Read [notes]($url) here")
+ assertThat(annotated.getLinkAnnotations(0, annotated.length)).isEmpty()
+ }
+ }
+
+ @Test
+ fun parseInline_extractsRawUrl() {
+ val annotated = MarkdownParser.parseInline("Visit https://github.com/anasvhora284 now")
+ assertThat(annotated.text).isEqualTo("Visit https://github.com/anasvhora284 now")
+ val links = annotated.getLinkAnnotations(0, annotated.length)
+ assertThat(links).isNotEmpty()
+ }
+
+ @Test
+ fun parseInline_extractsGitHubMention() {
+ val annotated = MarkdownParser.parseInline("Contributed by @anasvhora284")
+ assertThat(annotated.text).isEqualTo("Contributed by @anasvhora284")
+ val links = annotated.getLinkAnnotations(0, annotated.length)
+ assertThat(links).isNotEmpty()
+ }
+
+ @Test
+ fun parseInline_plainTextWithoutTokens() {
+ val raw = "Plain text without any formatting."
+ val annotated = MarkdownParser.parseInline(raw)
+ assertThat(annotated.text).isEqualTo(raw)
+ assertThat(annotated.spanStyles).isEmpty()
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/update/UpdateServiceContextJvmTest.kt b/app/src/test/java/com/inputleaf/android/update/UpdateServiceContextJvmTest.kt
index 11b53f0d..d9c0fda7 100644
--- a/app/src/test/java/com/inputleaf/android/update/UpdateServiceContextJvmTest.kt
+++ b/app/src/test/java/com/inputleaf/android/update/UpdateServiceContextJvmTest.kt
@@ -25,7 +25,7 @@ class UpdateServiceContextJvmTest {
@Test
fun getCurrentVersion_readsInstalledVersion() {
- assertThat(UpdateService.getCurrentVersion(context)).isEqualTo("1.4.1")
+ assertThat(UpdateService.getCurrentVersion(context)).isEqualTo("1.4.2")
}
@Test
diff --git a/app/src/test/java/com/inputleaf/android/update/UpdateServiceTest.kt b/app/src/test/java/com/inputleaf/android/update/UpdateServiceTest.kt
index 9e63587b..34f6642f 100644
--- a/app/src/test/java/com/inputleaf/android/update/UpdateServiceTest.kt
+++ b/app/src/test/java/com/inputleaf/android/update/UpdateServiceTest.kt
@@ -88,7 +88,7 @@ class UpdateServiceTest {
@Test
fun versionNameFrom_usesPackageInfoOrFallback() {
assertThat(versionNameFrom(PackageInfo().apply { versionName = "2.0.0" })).isEqualTo("2.0.0")
- assertThat(versionNameFrom(null)).isEqualTo("1.4.1")
+ assertThat(versionNameFrom(null)).isEqualTo("1.4.2")
}
@Test
@@ -98,7 +98,7 @@ class UpdateServiceTest {
versionCode = 42
}
assertThat(versionCodeFrom(packageInfo)).isEqualTo(42L)
- assertThat(versionCodeFrom(null)).isEqualTo(7L)
+ assertThat(versionCodeFrom(null)).isEqualTo(8L)
}
@Test
@@ -422,20 +422,20 @@ class UpdateServiceTest {
@Test
fun changelogProvider_returnsValidHighlights() {
- val changelog = ChangelogProvider.getChangelog("1.4.1")
- assertThat(changelog.versionName).isEqualTo("1.4.1")
+ val changelog = UpdateService.getChangelog("1.4.2")
+ assertThat(changelog.versionName).isEqualTo("1.4.2")
assertThat(changelog.highlights).isNotEmpty()
}
@Test
fun changelogProvider_fallsBackToLatestKnownRelease() {
- val changelog = ChangelogProvider.getChangelog("9.9.9")
- assertThat(changelog.versionName).isEqualTo(ChangelogProvider.RELEASES.first().versionName)
+ val changelog = UpdateService.getChangelog("9.9.9")
+ assertThat(changelog.versionName).isEqualTo("1.4.2")
}
@Test
fun changelogProvider_stripsVersionPrefix() {
- val changelog = ChangelogProvider.getChangelog("v1.4.0")
+ val changelog = UpdateService.getChangelog("v1.4.0")
assertThat(changelog.versionName).isEqualTo("1.4.0")
}
diff --git a/app/src/test/java/com/inputleaf/android/util/AccessibilityInputInjectorPointerSpeedTest.kt b/app/src/test/java/com/inputleaf/android/util/AccessibilityInputInjectorPointerSpeedTest.kt
new file mode 100644
index 00000000..9127cc24
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/util/AccessibilityInputInjectorPointerSpeedTest.kt
@@ -0,0 +1,38 @@
+package com.inputleaf.android.util
+
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import com.inputleaf.android.inject.AccessibilityInputInjector
+import com.inputleaf.android.shizuku.ShizukuInputInjector
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class AccessibilityInputInjectorPointerSpeedTest {
+
+ @Test
+ fun `updatePointerSpeed forwards to nested Shizuku injector`() {
+ val context = ApplicationProvider.getApplicationContext()
+ val injector = AccessibilityInputInjector(context, 1080, 2400)
+ val nested = nestedShizuku(injector)
+
+ injector.updatePointerSpeed(4)
+ assertThat(readPointerSpeed(nested)).isEqualTo(4)
+
+ injector.updatePointerSpeed(-3)
+ assertThat(readPointerSpeed(nested)).isEqualTo(-3)
+ }
+
+ private fun nestedShizuku(injector: AccessibilityInputInjector): ShizukuInputInjector {
+ val field = AccessibilityInputInjector::class.java.getDeclaredField("hidKeyboard")
+ field.isAccessible = true
+ return field.get(injector) as ShizukuInputInjector
+ }
+
+ private fun readPointerSpeed(injector: ShizukuInputInjector): Int {
+ val field = ShizukuInputInjector::class.java.getDeclaredField("pointerSpeed")
+ field.isAccessible = true
+ return field.getInt(injector)
+ }
+}
diff --git a/codecov.yml b/codecov.yml
index b0a47673..9b4b5724 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -31,8 +31,6 @@ ignore:
- "app/src/main/java/com/inputleaf/android/inject/KeysymInjection.kt"
- "app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt"
- "app/src/main/java/com/inputleaf/android/storage/ClientCertificateStore.kt"
- - "uhid-server/src/main/java/com/inputleaf/uhid/Main.java"
- - "uhid-server/src/main/java/com/inputleaf/uhid/UhidLocalSockets.java"
- "app/src/test/**"
- "app/src/androidTest/**"
- "app/src/main/res/**"
diff --git a/docs/DEVELOPMENT_JOURNEY.md b/docs/DEVELOPMENT_JOURNEY.md
deleted file mode 100644
index 26fdd6ce..00000000
--- a/docs/DEVELOPMENT_JOURNEY.md
+++ /dev/null
@@ -1,339 +0,0 @@
-# InputLeaf Android - Development Journey
-
-**Date Range:** March 21, 2026
-**Project:** Complete UI redesign + bug fixes + hotspot network fix
-**Branch:** `feature/android14-ui-redesign`
-
----
-
-## Overview
-
-InputLeaf is an Android app that allows you to control your PC (running InputLeap server) from your Android device. This session focused on:
-
-1. **Complete UI redesign** matching Android 14 Material You design language
-2. **Fixing reported bugs** (navigation, scrolling, theme issues)
-3. **Adding requested features** (theme toggle, permission cards, screen name editing)
-4. **Fixing network discovery** for hotspot connections
-
----
-
-## What Was Built
-
-### 1. UI Redesign (Android 14 Material You)
-
-#### Theme System
-- **Files:** `ui/theme/Color.kt`, `ui/theme/Shape.kt`
-- **Features:**
- - Purple/lavender primary color palette (#A78BFA to #8B5CF6)
- - Light/dark mode support following system theme
- - Material You dynamic colors (Android 12+) with fallback
- - Success/warning color definitions
- - Gradient definitions for primary, accent, and success states
-
-#### Reusable Components
-- **AnimatedBottomNavigation.kt** (145 lines)
- - Floating pill-shaped bottom navigation
- - Animated sliding indicator (300ms cubic-bezier)
- - Theme-aware colors (works in light/dark mode)
- - 4 tabs: Home, Servers, Permissions, Settings
-
-- **GradientCard.kt** - Card containers with gradient support
-
-- **CircularAvatar.kt** - Icon avatars with gradient backgrounds
-
-- **MaterialToggleSwitch.kt** - Android 14 style toggle switch
-
-- **ShizukuStatusCard.kt** (extracted as shared component)
- - Shows Shizuku permission status
- - Action buttons for each status state
- - Theme-aware colors
-
-#### Screens (Redesigned)
-
-**MainScreen.kt (Home)**
-- Connection status card
-- Quick info cards (Screen, Cursor)
-- Shizuku status card (hidden when ready)
-- Server search bar with Scan button
-- Discovered servers list
-- Removed duplicate "Scan Again" button
-
-**ServerListScreen.kt (New)**
-- Dedicated server list view
-- Scan functionality
-- Add manual server dialog
-- Empty state handling
-
-**SetupScreen.kt (Renamed to Permissions)**
-- Shizuku permission card
-- Overlay permission card
-- Battery optimization permission card
-- Setup instructions
-
-**SettingsScreen.kt**
-- Connection section (screen name, auto-connect)
-- Display section (theme toggle)
-- Security section (show cursor)
-- Fingerprints section
-- All settings fully scrollable
-
-#### Features Added
-
-1. **Theme Toggle**
- - System default / Light / Dark modes
- - Persisted to DataStore
- - Immediate effect on UI
-
-2. **Screen Name Editing**
- - Click to edit dialog
- - Validation (non-empty)
- - Pre-filled with current name
-
-3. **Permission Cards**
- - Shizuku status
- - Overlay permission
- - Battery optimization status
- - Action buttons to request permissions
-
-4. **Cursor Overlay Service**
- - Redesigned with ripple animation
- - Purple color scheme
- - Android "Show Taps" style
-
-5. **App Icon**
- - New design: green leaf + white cursor
- - Teal background (#0B3D4A)
- - Adaptive icon support
- - PNG assets for all densities
-
----
-
-## Technical Decisions
-
-### Architecture
-- **Pattern:** MVVM with AndroidViewModel
-- **UI Framework:** Jetpack Compose with Material3
-- **State Management:** StateFlow + Compose collectAsStateWithLifecycle
-- **Persistence:** DataStore for preferences
-
-### Icon Strategy
-- Limited to `material-icons-core` (no extended package due to OOM)
-- Icons used: Home, Settings, Info, Build, Phone, Lock, CheckCircle, Warning, Search, etc.
-- All icons from rounded variant for consistency
-
-### Theme Implementation
-- Android 12+: `dynamicLightColorScheme()` / `dynamicDarkColorScheme()`
-- Older Android: Fallback purple theme with light/dark variants
-- All components use `MaterialTheme.colorScheme.*` for colors
-- No hardcoded colors in UI components
-
-### Navigation
-- Single Activity architecture
-- State-based screen switching
-- Bottom navigation with animated indicator
-- Separate composable functions for each screen
-
-### Network Discovery Fix
-- **Problem:** WifiManager.connectionInfo.ipAddress doesn't work for hotspot
-- **Solution:** NetworkInterface enumeration finds correct IP
-- Works for: Wi-Fi client, mobile hotspot, ethernet, any interface
-
----
-
-## Files Modified/Created
-
-### Theme & Components
-```
-app/src/main/java/com/inputleaf/android/ui/theme/
-├── Color.kt # Purple palette, gradients, semantic colors
-└── Shape.kt # Rounded shapes (24-32dp)
-
-app/src/main/java/com/inputleaf/android/ui/components/
-├── AnimatedBottomNavigation.kt # Floating bottom nav
-├── CircularAvatar.kt # Icon avatars
-├── GradientCard.kt # Gradient card container
-├── MaterialToggleSwitch.kt # Custom toggle switch
-└── ShizukuStatusCard.kt # Shizuku status display
-```
-
-### Screens
-```
-app/src/main/java/com/inputleaf/android/ui/
-├── MainActivity.kt # Main activity with theme setup
-├── MainScreen.kt # Home screen
-├── ServerListScreen.kt # Server list screen
-├── SetupScreen.kt # Permissions screen
-├── SettingsScreen.kt # Settings screen
-└── SplashActivity.kt # Splash screen with logo
-```
-
-### Services
-```
-app/src/main/java/com/inputleaf/android/service/
-└── CursorOverlayService.kt # Cursor overlay with ripple animation
-```
-
-### Resources
-```
-app/src/main/res/
-├── drawable/
-│ ├── ic_logo.xml # App logo (leaf + cursor)
-│ ├── ic_logo_splash.xml # Splash logo
-│ └── ic_launcher_foreground.xml # Launcher foreground
-└── mipmap-*/ # Launcher icons (all densities)
-```
-
-### Data/ViewModel
-```
-app/src/main/java/com/inputleaf/android/
-├── ui/MainViewModel.kt # Main ViewModel with all logic
-└── storage/AppPreferences.kt # DataStore preferences
-```
-
----
-
-## Build Status
-
-- **Build:** ✅ Successful
-- **Installation:** ✅ Installed on device (CPH2661 - Android 16)
-- **Branch:** `feature/android14-ui-redesign`
-- **Commits:** 20+ commits on feature branch
-
----
-
-## Testing Checklist
-
-After any changes, verify:
-
-### UI/UX
-- [ ] Bottom navigation switches screens correctly
-- [ ] Theme toggle works (System/Light/Dark)
-- [ ] Settings screen scrolls with many fingerprints
-- [ ] Screen name edit dialog opens and saves
-- [ ] Permission cards show correct status
-- [ ] Only one Scan button on home screen
-- [ ] Shizuku card hidden when ready
-- [ ] App follows device wallpaper colors (Material You)
-
-### Network
-- [ ] Scan discovers servers on Wi-Fi network
-- [ ] Scan discovers servers on hotspot network
-- [ ] Manual server addition works
-- [ ] Connection to server works
-
-### Permissions
-- [ ] Overlay permission request works
-- [ ] Battery optimization exemption works
-- [ ] Shizuku permission flow works
-
----
-
-## Future Enhancements (Not Implemented)
-
-User mentioned these ideas for future development:
-
-1. **Setup Workflow**
- - Detailed screenshots and videos for Shizuku setup
- - Step-by-step guided setup
-
-2. **Logging System**
- - Save app logs to accessible location
- - Easy sharing for debugging
-
-3. **Hotspot Improvements**
- - Better support for PC as server via hotspot
- - Ensure single device connection at a time
-
-4. **Android Compatibility**
- - Test Android 9 to latest
- - Handle edge cases
- - No crashes on any version
-
-5. **Connection Management**
- - Hotspot connection to PC
- - PC acting as server
- - Single device priority
-
----
-
-## Known Issues / Pre-existing Failures
-
-- 5 pre-existing test failures in protocol/network layers (unrelated to UI changes)
-- These were present before the redesign and should be investigated separately
-
----
-
-## How to Build & Install
-
-```bash
-# Build debug APK
-./gradlew assembleDebug
-
-# Install on connected device
-./gradlew installDebug
-
-# Or manually
-adb install -r app/build/outputs/apk/debug/app-debug.apk
-```
-
----
-
-## Development Notes
-
-### For Future Agents
-
-When continuing development on this project:
-
-1. **Read this document first** - It provides full context of what's been built
-2. **Check the branch** - All changes are on `feature/android14-ui-redesign`
-3. **Test on device** - Emulators may not show Material You colors properly
-4. **Check Android version** - Dynamic colors need Android 12+
-5. **Theme consistency** - Always use `MaterialTheme.colorScheme.*` colors
-6. **No icon library** - Only use icons from `material-icons-core`
-
-### Design Principles Applied
-
-1. **Material You First** - Use dynamic colors when available
-2. **Consistency** - Same patterns across all screens
-3. **Theme-Aware** - All colors adapt to light/dark mode
-4. **DRY** - Shared components, no code duplication
-5. **User Feedback** - Loading states, status indicators, action buttons
-
----
-
-## Key Decisions & Rationale
-
-### Why No material-icons-extended?
-- Causes OOM (Out of Memory) during dex merging
-- Limited to icons in core package
-- Uses workarounds: Build icon for servers, Info for servers, Lock for permissions
-
-### Why Separate Screens?
-- Home shows connection status + quick actions
-- Servers shows detailed server list
-- Permissions shows all permission statuses
-- Settings shows app configuration
-- Clear separation of concerns
-
-### Why NetworkInterface Instead of WifiManager?
-- WifiManager only gets Wi-Fi client IP
-- Doesn't work for hotspot mode (phone is AP)
-- NetworkInterface finds any active IPv4 interface
-- Works for Wi-Fi, hotspot, ethernet, etc.
-
----
-
-## Contact / Context
-
-This document was created from the development session on March 21, 2026.
-
-**Project:** InputLeaf Android
-**Purpose:** Control InputLeap server from Android device
-**Platform:** Android 9+ (Material You on Android 12+)
-**Architecture:** Jetpack Compose + MVVM
-
-For questions or continuation of work, refer to:
-- This document for context
-- `docs/superpowers/plans/` for implementation plans
-- `docs/superpowers/specs/` for design specifications
-- Git history on `feature/android14-ui-redesign` for commit history
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 7466d1b1..76200c4c 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -14,10 +14,10 @@ Input Leaf uses a small, fast JVM test suite and Kover coverage reporting as the
From the repository root, run:
```sh
-./gradlew :koverXmlReportDebugJvm :uhid-server:jacocoTestReport
+./gradlew :koverXmlReportDebugJvm
```
-The Kover task runs the app's local Android `debug` JVM tests and writes `build/reports/kover/coverage-debug-jvm.xml`. The JaCoCo task runs the UHID module's plain Java JVM tests and writes `uhid-server/build/reports/jacoco/test/jacocoTestReport.xml`. The same tasks run in the `fast-jvm` GitHub Actions job.
+The Kover task runs the app's local Android `debug` JVM tests and writes `build/reports/kover/coverage-debug-jvm.xml`. The same task runs in the `fast-jvm` GitHub Actions job.
## Run the instrumented smoke tests
@@ -48,18 +48,6 @@ app/src/test/java/com/inputleaf/android//
Mirror the production package, name classes after the subject with a `Test` suffix, and follow the existing JUnit 4, Truth, and behavior-oriented naming conventions. Put deterministic file fixtures in `app/src/test/resources/`.
-### UHID server
-
-Place UHID JVM tests under:
-
-```text
-uhid-server/src/test/java/com/inputleaf/uhid/
-```
-
-The UHID module is Java-only. Keep its tests in Java so test coverage does not add the Kotlin plugin or runtime to the generated JAR and DEX pipeline. Use JUnit 4 and Truth.
-
-Socket lifecycle paths in `UhidServer.run()` are not covered by JVM unit tests because `android.net.LocalServerSocket` and `LocalSocket` come from a compile-only Android stub that throws at runtime. Keep explicit `finally` cleanup around both sockets rather than adding brittle stub-dependent tests.
-
### Android instrumented smoke tests
Place emulator smoke tests under:
@@ -70,18 +58,6 @@ app/src/androidTest/java/com/inputleaf/android//
Mirror the production package, name classes after the subject, and keep the suite small: these tests run on an emulator in CI on every pull request. They are smoke tests that launch real activities and bind real services to catch integration breakage the JVM suite cannot see — navigation rendering, service binding, lifecycle startup — not full behavioral coverage. Shared fixtures go in `app/src/androidTest/java/com/inputleaf/android/testutil/`.
-### Generated UHID DEX asset
-
-The app consumes the UHID sidecar from generated build output rather than a committed binary. App asset-merge tasks automatically depend on `:uhid-server:buildDex`, so packaging the app always uses the current Java source.
-
-To generate the asset directly, run:
-
-```sh
-./gradlew :uhid-server:buildDex
-```
-
-This task requires Android platform 37.0 and build tools 36.0.0. It compiles against the platform API, targets the app's minimum API 26, and writes `uhid-server/build/generated/assets/uhid/classes.dex`. JVM tests do not generate the DEX because they do not package app assets.
-
## Test design principles
- Test observable results, emitted events, persisted values, errors, and protocol bytes rather than private methods or collaborator call order.
@@ -99,14 +75,14 @@ The required `fast-jvm` JVM suite must not depend on:
- an Android emulator or connected device;
- a Deskflow installation or external server;
- LAN or Internet access during tests;
-- Shizuku, accessibility, IME, or `/dev/uhid` access;
+- Shizuku, accessibility, or IME access;
- APK signing or release secrets.
The parallel `android-coverage` job runs a small instrumented smoke suite that intentionally exercises the opposite: real activities, real service binding, and real APK installation on an API 36 emulator. It must stay smoke-sized — it runs on every pull request, and emulator startup dominates its wall-clock time. Lint and formatting are not part of the required test commands because the repository does not currently configure dedicated formatting or static-analysis tooling.
## Coverage guardrails
-Kover collects coverage from the local Android `debug` JVM tests. JaCoCo collects coverage from the Java-only UHID module because Kover's Gradle plugin does not create coverage variants for a pure Java project. The `android-coverage` job collects a JaCoCo report from the connected smoke tests against the instrumented debug APK. Codecov uploads all three as XML (`jvm` and `android` flags), waits for both jobs (`after_n_builds: 2` in `codecov.yml`), merges them for reporting, and comments on pull requests with project and changed-line coverage.
+Kover collects coverage from the local Android `debug` JVM tests. The `android-coverage` job collects a JaCoCo report from the connected smoke tests against the instrumented debug APK. Codecov uploads both as XML (`jvm` and `android` flags), waits for both jobs (`after_n_builds: 2` in `codecov.yml`), merges them for reporting, and comments on pull requests with project and changed-line coverage.
Codecov requires 100% patch coverage: every changed executable line must be exercised by one of the suites. This is a regression guardrail, not proof that a feature is behaviorally complete; tests must still assert the relevant observable behavior and edge cases.
@@ -116,7 +92,6 @@ Codecov requires 100% patch coverage: every changed executable line must be exer
The initial baseline was verified with JDK 17 and Android SDK 34 when the fast CI workflow was introduced; the current baseline is verified with JDK 17 and Android SDK 37.0:
- `:app:testDebugUnitTest` passes and runs the app's Kotlin behavior tests.
-- `:uhid-server:test` passes and runs the UHID module's Java behavior tests.
The `android-coverage` CI job verifies on the API 36 emulator that `:app:createDebugCoverageReport` passes and runs the service and onboarding smoke tests added with that job.
diff --git a/docs/UI_REDESIGN.md b/docs/UI_REDESIGN.md
deleted file mode 100644
index 0e87ae72..00000000
--- a/docs/UI_REDESIGN.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# Android 14 UI Redesign
-
-**Date:** 2026-03-21
-**Version:** v2.0
-
-## Overview
-
-Complete redesign of InputLeaf Android UI to match Android 14 AOSP Material You design language.
-
-## Key Changes
-
-### Visual Design
-- Material Symbols Rounded icon pack
-- Material You dynamic color system (Android 12+)
-- Heavy rounded corners (24-32dp)
-- Gradient backgrounds and accents
-- Generous spacing and breathing room
-
-### Components
-- **Animated Bottom Navigation:** Floating pill with sliding background indicator
-- **Gradient Cards:** Reusable cards with optional gradient backgrounds
-- **Circular Avatars:** Icon avatars with gradient backgrounds
-- **Material Toggle Switch:** Android 14 style toggle with gradient active state
-- **Redesigned Cursor:** Android "Show Taps" style with ripple animation
-
-### Screens
-- **Home Screen:** Large connection card, quick info cards, Shizuku status, search bar
-- **Servers Screen:** Search bar, gradient server cards
-- **Settings Screen:** Grouped settings with toggle switches
-
-## Design Specifications
-
-See: `docs/superpowers/specs/2026-03-21-android14-ui-redesign.md`
-
-## Implementation
-
-All UI components use Jetpack Compose with Material 3. Dynamic color support on Android 12+ with graceful fallback for older versions.
-
-## Testing
-
-Tested on:
-- Android 14 (Material You dynamic color)
-- Android 12-13 (dynamic color)
-- Android 11 and below (static theme)
-
-## Screenshots
-
-[Add screenshots here after implementation]
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 19ca2578..012a0b01 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,10 @@ shizuku = "13.1.5"
deviceNames = "2.1.1"
androidStub = "4.1.1.4"
junit = "4.13.2"
+# compose-ui-test-junit4 pulls Espresso 3.5.0 transitively, which reflects on the
+# InputManager.getInstance() Android 14 removed, so every Compose UI test dies in
+# Espresso.onIdle on API 34+. 3.6.1 still calls it; 3.7.0 uses getInputManager().
+espresso = "3.7.0"
truth = "1.4.5"
mockito = "5.23.0"
androidxTestCore = "1.7.0"
@@ -42,6 +46,7 @@ shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizu
device-names = { module = "com.jaredrummler:android-device-names", version.ref = "deviceNames" }
android-stub = { module = "com.google.android:android", version.ref = "androidStub" }
junit = { module = "junit:junit", version.ref = "junit" }
+espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index ab91c0c8..b43518a6 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -7,4 +7,4 @@ dependencyResolutionManagement {
repositories { google(); mavenCentral() }
}
rootProject.name = "input-leaf-android"
-include(":app", ":uhid-server")
+include(":app")
diff --git a/uhid-server/build.gradle.kts b/uhid-server/build.gradle.kts
deleted file mode 100644
index 356dcfbc..00000000
--- a/uhid-server/build.gradle.kts
+++ /dev/null
@@ -1,77 +0,0 @@
-import java.io.File
-import java.util.Properties
-
-plugins {
- id("java")
- id("jacoco")
-}
-
-tasks.jacocoTestReport {
- dependsOn(tasks.test)
- reports {
- xml.required.set(true)
- html.required.set(true)
- }
-}
-
-java {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
-}
-dependencies {
- // Android stub for android.net.LocalServerSocket / LocalSocket (compile-time only)
- compileOnly(libs.android.stub)
-
- testImplementation(libs.junit)
- testImplementation(libs.truth)
-}
-// Step 1: fat JAR
-tasks.register("fatJar") {
- archiveBaseName.set("inputleaf-uhid")
- duplicatesStrategy = DuplicatesStrategy.EXCLUDE
- manifest { attributes["Main-Class"] = "com.inputleaf.uhid.Main" }
- from(sourceSets.main.get().output)
-}
-// Step 2: convert to DEX in build output; the app consumes this as a generated asset.
-tasks.register("buildDex") {
- dependsOn("fatJar")
- val jarPath = layout.buildDirectory.file("libs/inputleaf-uhid.jar").get().asFile
- val dexOut = layout.buildDirectory.dir("generated/assets/uhid").get().asFile
- val dexFile = dexOut.resolve("classes.dex")
-
- fun resolveSdkDir(): String {
- val env = System.getenv("ANDROID_SDK_ROOT") ?: System.getenv("ANDROID_HOME")
- if (!env.isNullOrBlank()) return env
- val localProps = rootProject.file("local.properties")
- if (localProps.exists()) {
- val properties = Properties()
- localProps.inputStream().use { properties.load(it) }
- val dir = properties.getProperty("sdk.dir")
- if (!dir.isNullOrBlank()) return dir
- }
- return ""
- }
-
- val sdkRoot = resolveSdkDir()
- val d8Path = if (sdkRoot.isNotBlank()) "$sdkRoot/build-tools/36.0.0/d8" else ""
- val androidJar = if (sdkRoot.isNotBlank()) "$sdkRoot/platforms/android-37.0/android.jar" else ""
-
- inputs.file(jarPath)
- outputs.file(dexFile)
-
- doFirst {
- require(sdkRoot.isNotBlank()) { "ANDROID_SDK_ROOT, ANDROID_HOME, or sdk.dir in local.properties is required to build the UHID DEX" }
- require(File(d8Path).exists()) { "Android build tools 36.0.0 are required to build the UHID DEX (checked $d8Path)" }
- require(File(androidJar).exists()) { "Android platform 37.0 is required to build the UHID DEX (checked $androidJar)" }
- project.delete(dexOut)
- dexOut.mkdirs()
- }
- commandLine(
- d8Path,
- "--lib", androidJar,
- "--min-api", "26",
- "--output", dexOut.absolutePath,
- jarPath.absolutePath
- )
-}
-
diff --git a/uhid-server/src/main/java/com/inputleaf/uhid/EventProtocol.java b/uhid-server/src/main/java/com/inputleaf/uhid/EventProtocol.java
deleted file mode 100644
index 25d4c292..00000000
--- a/uhid-server/src/main/java/com/inputleaf/uhid/EventProtocol.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.inputleaf.uhid;
-
-public class EventProtocol {
- public static final byte TYPE_KEY_EVENT = 0x01;
- public static final byte TYPE_MOUSE_MOVE = 0x02;
- public static final byte TYPE_MOUSE_BTN = 0x03;
- public static final byte TYPE_MOUSE_WHEEL = 0x04;
- public static final byte TYPE_SHUTDOWN = (byte) 0xFF;
-
- public static final byte ACTION_DOWN = 0x00;
- public static final byte ACTION_UP = 0x01;
-
- public static class KeyEvent {
- public final int keysym; // X11 keysym, 4 bytes
- public final byte action;
- public final byte modifiers;
- public KeyEvent(int keysym, byte action, byte modifiers) {
- this.keysym = keysym; this.action = action; this.modifiers = modifiers;
- }
- }
-
- public static class MouseMove { public final int dx, dy; public MouseMove(int dx, int dy) { this.dx=dx; this.dy=dy; } }
- public static class MouseButton { public final byte button, action; public MouseButton(byte btn, byte act) { button=btn; action=act; } }
- public static class MouseWheel { public final short deltaX, deltaY; public MouseWheel(short dx, short dy) { deltaX=dx; deltaY=dy; } }
-}
diff --git a/uhid-server/src/main/java/com/inputleaf/uhid/KeyboardDevice.java b/uhid-server/src/main/java/com/inputleaf/uhid/KeyboardDevice.java
deleted file mode 100644
index 02d69d30..00000000
--- a/uhid-server/src/main/java/com/inputleaf/uhid/KeyboardDevice.java
+++ /dev/null
@@ -1,125 +0,0 @@
-package com.inputleaf.uhid;
-
-import java.io.Closeable;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
-
-public class KeyboardDevice implements Closeable {
- private static final int UHID_CREATE2 = 11;
- private static final int UHID_INPUT2 = 12;
- private static final int UHID_DATA_SIZE = 4096;
- private static final int REPORT_SIZE = 8;
- private static final int KEY_SLOT_START = 2;
- private static final int MAX_CONCURRENT_KEYS = 6;
- private static final int FIRST_MODIFIER_USAGE = 0xE0;
- private static final int LAST_MODIFIER_USAGE = 0xE7;
-
- private static final byte[] KEYBOARD_DESCRIPTOR = {
- 0x05, 0x01, 0x09, 0x06, (byte) 0xA1, 0x01,
- 0x05, 0x07, 0x19, (byte) FIRST_MODIFIER_USAGE, 0x29, (byte) LAST_MODIFIER_USAGE,
- 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, (byte) 0x95, 0x08, (byte) 0x81, 0x02,
- (byte) 0x95, 0x01, 0x75, 0x08, (byte) 0x81, 0x01,
- 0x05, 0x07, 0x19, 0x00, 0x29, (byte) 0xDD, 0x15, 0x00, 0x25, (byte) 0xDD,
- 0x75, 0x08, (byte) 0x95, MAX_CONCURRENT_KEYS, (byte) 0x81, 0x00,
- (byte) 0xC0
- };
-
- private final OutputStream uhid;
- // Standard boot-keyboard report: [modifiers, reserved, key0..key5].
- private final byte[] report = new byte[REPORT_SIZE];
-
- public KeyboardDevice() throws IOException {
- this(initializeOutput(new FileOutputStream("/dev/uhid")));
- }
-
- KeyboardDevice(OutputStream uhid) {
- this.uhid = uhid;
- }
-
- static OutputStream initializeOutput(OutputStream uhid) throws IOException {
- try {
- writeCreate2(uhid, "InputLeaf Keyboard", KEYBOARD_DESCRIPTOR);
- return uhid;
- } catch (IOException createFailure) {
- try {
- uhid.close();
- } catch (IOException closeFailure) {
- createFailure.addSuppressed(closeFailure);
- }
- throw createFailure;
- }
- }
-
- public void keyDown(int hidUsage, byte modifiers) throws IOException {
- report[0] = modifiers;
- if (isModifierUsage(hidUsage)) {
- sendReport();
- return;
- }
-
- byte key = (byte) (hidUsage & 0xFF);
- for (int index = KEY_SLOT_START; index < REPORT_SIZE; index++) {
- if (report[index] == key) {
- sendReport();
- return;
- }
- }
- for (int index = KEY_SLOT_START; index < REPORT_SIZE; index++) {
- if (report[index] == 0) {
- report[index] = key;
- break;
- }
- }
- sendReport();
- }
-
- public void keyUp(int hidUsage, byte modifiers) throws IOException {
- report[0] = modifiers;
- if (isModifierUsage(hidUsage)) {
- sendReport();
- return;
- }
-
- byte key = (byte) (hidUsage & 0xFF);
- for (int index = KEY_SLOT_START; index < REPORT_SIZE; index++) {
- if (report[index] == key) {
- report[index] = 0;
- break;
- }
- }
- sendReport();
- }
-
- private boolean isModifierUsage(int hidUsage) {
- return hidUsage >= FIRST_MODIFIER_USAGE && hidUsage <= LAST_MODIFIER_USAGE;
- }
-
- private void sendReport() throws IOException {
- byte[] packet = new byte[4 + 4 + UHID_DATA_SIZE];
- writeInt(packet, 0, UHID_INPUT2);
- writeInt(packet, 4, report.length);
- System.arraycopy(report, 0, packet, 8, report.length);
- uhid.write(packet);
- }
-
- private static void writeCreate2(OutputStream uhid, String name, byte[] descriptor) throws IOException {
- byte[] packet = new byte[4 + 4 + 128 + 4 + UHID_DATA_SIZE + 16];
- writeInt(packet, 0, UHID_CREATE2);
- byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
- System.arraycopy(nameBytes, 0, packet, 4, Math.min(nameBytes.length, 127));
- writeInt(packet, 132, descriptor.length);
- System.arraycopy(descriptor, 0, packet, 136, descriptor.length);
- uhid.write(packet);
- }
-
- private static void writeInt(byte[] buffer, int offset, int value) {
- buffer[offset] = (byte) (value & 0xFF);
- buffer[offset + 1] = (byte) ((value >> 8) & 0xFF);
- buffer[offset + 2] = (byte) ((value >> 16) & 0xFF);
- buffer[offset + 3] = (byte) ((value >> 24) & 0xFF);
- }
-
- @Override public void close() throws IOException { uhid.close(); }
-}
diff --git a/uhid-server/src/main/java/com/inputleaf/uhid/KeysymToHid.java b/uhid-server/src/main/java/com/inputleaf/uhid/KeysymToHid.java
deleted file mode 100644
index 975a2b88..00000000
--- a/uhid-server/src/main/java/com/inputleaf/uhid/KeysymToHid.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.inputleaf.uhid;
-
-import java.util.HashMap;
-
-public class KeysymToHid {
- private static final HashMap TABLE = new HashMap<>();
- static {
- // Letters a-z
- int[] letters = {0x61,0x62,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6A,
- 0x6B,0x6C,0x6D,0x6E,0x6F,0x70,0x71,0x72,0x73,0x74,
- 0x75,0x76,0x77,0x78,0x79,0x7A};
- for (int i = 0; i < 26; i++) {
- TABLE.put(letters[i], 0x04 + i); // lowercase
- TABLE.put(letters[i] - 32, 0x04 + i); // uppercase
- }
- // Digits
- for (int i = 1; i <= 9; i++) TABLE.put(0x30 + i, 0x1D + i);
- TABLE.put(0x30, 0x27);
- // Control
- TABLE.put(0xFF0D, 0x28); TABLE.put(0xFF1B, 0x29); TABLE.put(0xFF08, 0x2A);
- TABLE.put(0xFF09, 0x2B); TABLE.put(0x20, 0x2C); TABLE.put(0xFF50, 0x4A);
- TABLE.put(0xFF57, 0x4D); TABLE.put(0xFF55, 0x4B); TABLE.put(0xFF56, 0x4E);
- TABLE.put(0xFF63, 0x49); TABLE.put(0xFFFF, 0x4C);
- // Arrows
- TABLE.put(0xFF51, 0x50); TABLE.put(0xFF52, 0x52);
- TABLE.put(0xFF53, 0x4F); TABLE.put(0xFF54, 0x51);
- // F1-F12
- for (int i = 0; i < 12; i++) TABLE.put(0xFFBE + i, 0x3A + i);
- // Modifiers
- TABLE.put(0xFFE1, 0xE1); TABLE.put(0xFFE2, 0xE5);
- TABLE.put(0xFFE3, 0xE0); TABLE.put(0xFFE4, 0xE4);
- TABLE.put(0xFFE9, 0xE2); TABLE.put(0xFFEA, 0xE6);
- // Media
- TABLE.put(0x1008FF14, 0xCD); TABLE.put(0x1008FF11, 0xEA);
- TABLE.put(0x1008FF13, 0xE9); TABLE.put(0x1008FF12, 0xE2);
- }
- public static Integer lookup(int keysym) { return TABLE.get(keysym); }
-}
diff --git a/uhid-server/src/main/java/com/inputleaf/uhid/Main.java b/uhid-server/src/main/java/com/inputleaf/uhid/Main.java
deleted file mode 100644
index b3a0df9c..00000000
--- a/uhid-server/src/main/java/com/inputleaf/uhid/Main.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.inputleaf.uhid;
-
-public class Main {
- public static void main(String[] args) {
- try (UhidServer server = new UhidServer()) {
- server.run();
- } catch (Exception e) {
- System.err.println("UHID server error: " + e.getMessage());
- e.printStackTrace();
- System.exit(1);
- }
- }
-}
diff --git a/uhid-server/src/main/java/com/inputleaf/uhid/MouseDevice.java b/uhid-server/src/main/java/com/inputleaf/uhid/MouseDevice.java
deleted file mode 100644
index 3be96265..00000000
--- a/uhid-server/src/main/java/com/inputleaf/uhid/MouseDevice.java
+++ /dev/null
@@ -1,115 +0,0 @@
-package com.inputleaf.uhid;
-
-import java.io.Closeable;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
-
-public class MouseDevice implements Closeable {
- static final int MIN_BUTTON = 1;
- static final int MAX_BUTTON = 3;
-
- private static final int UHID_CREATE2 = 11;
- private static final int UHID_INPUT2 = 12;
-
- private static final byte[] MOUSE_DESCRIPTOR = {
- 0x05, 0x01, 0x09, 0x02, (byte) 0xA1, 0x01, 0x09, 0x01,
- (byte) 0xA1, 0x00,
- 0x05, 0x09, 0x19, MIN_BUTTON, 0x29, MAX_BUTTON, 0x15, 0x00, 0x25, 0x01,
- 0x75, 0x01, (byte) 0x95, MAX_BUTTON, (byte) 0x81, 0x02,
- 0x75, 0x05, (byte) 0x95, 0x01, (byte) 0x81, 0x03,
- 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x15, (byte) 0x81, 0x25, 0x7F,
- 0x75, 0x08, (byte) 0x95, 0x02, (byte) 0x81, 0x06,
- 0x09, 0x38, 0x15, (byte) 0x81, 0x25, 0x7F, 0x75, 0x08, (byte) 0x95, 0x01,
- (byte) 0x81, 0x06,
- // Consumer-page AC Pan provides horizontal wheel scrolling.
- 0x05, 0x0C, 0x0A, 0x38, 0x02, 0x15, (byte) 0x81, 0x25, 0x7F,
- 0x75, 0x08, (byte) 0x95, 0x01, (byte) 0x81, 0x06,
- (byte) 0xC0, (byte) 0xC0
- };
-
- private final OutputStream uhid;
- private byte buttonState = 0;
-
- public MouseDevice() throws IOException {
- this(initializeOutput(new FileOutputStream("/dev/uhid")));
- }
-
- MouseDevice(OutputStream uhid) {
- this.uhid = uhid;
- }
-
- static OutputStream initializeOutput(OutputStream uhid) throws IOException {
- try {
- writeCreate2(uhid, "InputLeaf Mouse", MOUSE_DESCRIPTOR);
- return uhid;
- } catch (IOException createFailure) {
- try {
- uhid.close();
- } catch (IOException closeFailure) {
- createFailure.addSuppressed(closeFailure);
- }
- throw createFailure;
- }
- }
-
- public void move(int dx, int dy) throws IOException {
- sendReport(buttonState, clamp(dx), clamp(dy), (byte) 0, (byte) 0);
- }
-
- // button: 1-indexed (1=left, 2=right, 3=middle) per Input-Leap protocol
- public void buttonDown(int button) throws IOException {
- validateButton(button);
- buttonState |= (byte) (1 << (button - 1));
- sendReport(buttonState, (byte) 0, (byte) 0, (byte) 0, (byte) 0);
- }
-
- public void buttonUp(int button) throws IOException {
- validateButton(button);
- buttonState &= (byte) ~(1 << (button - 1));
- sendReport(buttonState, (byte) 0, (byte) 0, (byte) 0, (byte) 0);
- }
-
- private void validateButton(int button) {
- if (button < MIN_BUTTON || button > MAX_BUTTON) {
- throw new IllegalArgumentException("Unsupported mouse button: " + button);
- }
- }
-
- public void wheel(int deltaX, int deltaY) throws IOException {
- sendReport(buttonState, (byte) 0, (byte) 0, clamp(deltaY), clamp(deltaX));
- }
-
- private byte clamp(int value) {
- return (byte) Math.max(-127, Math.min(127, value));
- }
-
- private void sendReport(byte buttons, byte x, byte y, byte wheel, byte horizontalPan) throws IOException {
- byte[] report = {buttons, x, y, wheel, horizontalPan};
- byte[] pkt = new byte[4 + 4 + 4096];
- writeInt(pkt, 0, UHID_INPUT2);
- writeInt(pkt, 4, report.length);
- System.arraycopy(report, 0, pkt, 8, report.length);
- uhid.write(pkt);
- }
-
- private static void writeCreate2(OutputStream uhid, String name, byte[] descriptor) throws IOException {
- byte[] pkt = new byte[4 + 4 + 128 + 4 + 4096 + 16];
- writeInt(pkt, 0, UHID_CREATE2);
- byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
- System.arraycopy(nameBytes, 0, pkt, 4, Math.min(nameBytes.length, 127));
- writeInt(pkt, 132, descriptor.length);
- System.arraycopy(descriptor, 0, pkt, 136, descriptor.length);
- uhid.write(pkt);
- }
-
- private static void writeInt(byte[] buf, int offset, int value) {
- buf[offset] = (byte) (value & 0xFF);
- buf[offset + 1] = (byte) ((value >> 8) & 0xFF);
- buf[offset + 2] = (byte) ((value >> 16) & 0xFF);
- buf[offset + 3] = (byte) ((value >> 24) & 0xFF);
- }
-
- @Override public void close() throws IOException { uhid.close(); }
-}
diff --git a/uhid-server/src/main/java/com/inputleaf/uhid/UhidEventDispatcher.java b/uhid-server/src/main/java/com/inputleaf/uhid/UhidEventDispatcher.java
deleted file mode 100644
index 9142e6a4..00000000
--- a/uhid-server/src/main/java/com/inputleaf/uhid/UhidEventDispatcher.java
+++ /dev/null
@@ -1,115 +0,0 @@
-package com.inputleaf.uhid;
-
-import java.io.DataInputStream;
-import java.io.EOFException;
-import java.io.IOException;
-import java.util.Objects;
-
-/** Decodes socket event payloads and forwards supported events to an input sink. */
-public class UhidEventDispatcher {
- public interface EventSink {
- void keyDown(int hidUsage, byte modifiers) throws IOException;
- void keyUp(int hidUsage, byte modifiers) throws IOException;
- void mouseMove(int dx, int dy) throws IOException;
- void mouseButtonDown(byte button) throws IOException;
- void mouseButtonUp(byte button) throws IOException;
- void mouseWheel(short deltaX, short deltaY) throws IOException;
- }
-
- private final EventSink sink;
-
- public UhidEventDispatcher(EventSink sink) {
- this.sink = Objects.requireNonNull(sink, "sink");
- }
-
- public void dispatch(byte type, DataInputStream input) throws IOException {
- switch (type) {
- case EventProtocol.TYPE_KEY_EVENT: {
- // keysym(4), action(1), modifiers(1)
- int keysym;
- byte action;
- byte modifiers;
- try {
- keysym = input.readInt();
- action = input.readByte();
- modifiers = input.readByte();
- } catch (EOFException truncated) {
- throw truncated("keyboard", truncated);
- }
- dispatchKey(keysym, action, modifiers);
- break;
- }
- case EventProtocol.TYPE_MOUSE_MOVE: {
- // deltaX(4), deltaY(4)
- int deltaX;
- int deltaY;
- try {
- deltaX = input.readInt();
- deltaY = input.readInt();
- } catch (EOFException truncated) {
- throw truncated("mouse-move", truncated);
- }
- sink.mouseMove(deltaX, deltaY);
- break;
- }
- case EventProtocol.TYPE_MOUSE_BTN: {
- // button(1), action(1)
- byte button;
- byte action;
- try {
- button = input.readByte();
- action = input.readByte();
- } catch (EOFException truncated) {
- throw truncated("mouse-button", truncated);
- }
- dispatchButton(button, action);
- break;
- }
- case EventProtocol.TYPE_MOUSE_WHEEL: {
- // deltaX(2), deltaY(2)
- short deltaX;
- short deltaY;
- try {
- deltaX = input.readShort();
- deltaY = input.readShort();
- } catch (EOFException truncated) {
- throw truncated("mouse-wheel", truncated);
- }
- sink.mouseWheel(deltaX, deltaY);
- break;
- }
- default:
- throw new IOException("Unsupported UHID event type: " + (type & 0xFF));
- }
- }
-
- private IOException truncated(String event, EOFException cause) {
- return new IOException("Truncated UHID " + event + " event", cause);
- }
-
- private void dispatchKey(int keysym, byte action, byte modifiers) throws IOException {
- if (action != EventProtocol.ACTION_DOWN && action != EventProtocol.ACTION_UP) {
- throw new IOException("Unsupported key action: " + (action & 0xFF));
- }
- Integer hid = KeysymToHid.lookup(keysym);
- if (hid == null) return;
- if (action == EventProtocol.ACTION_DOWN) {
- sink.keyDown(hid, modifiers);
- } else {
- sink.keyUp(hid, modifiers);
- }
- }
-
- private void dispatchButton(byte button, byte action) throws IOException {
- if (button < MouseDevice.MIN_BUTTON || button > MouseDevice.MAX_BUTTON) {
- throw new IOException("Unsupported mouse button: " + (button & 0xFF));
- }
- if (action == EventProtocol.ACTION_DOWN) {
- sink.mouseButtonDown(button);
- } else if (action == EventProtocol.ACTION_UP) {
- sink.mouseButtonUp(button);
- } else {
- throw new IOException("Unsupported mouse action: " + (action & 0xFF));
- }
- }
-}
diff --git a/uhid-server/src/main/java/com/inputleaf/uhid/UhidLocalSockets.java b/uhid-server/src/main/java/com/inputleaf/uhid/UhidLocalSockets.java
deleted file mode 100644
index 260b8a69..00000000
--- a/uhid-server/src/main/java/com/inputleaf/uhid/UhidLocalSockets.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.inputleaf.uhid;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-
-/** Android abstract-namespace socket used by the privileged UHID process. */
-final class UhidLocalSockets {
- private static final String SOCKET_NAME = "inputleaf_uhid";
-
- private UhidLocalSockets() {}
-
- static UhidServer.ClientAcceptor bind() throws IOException {
- return new Acceptor(new android.net.LocalServerSocket(SOCKET_NAME));
- }
-
- private static final class Acceptor implements UhidServer.ClientAcceptor {
- private final android.net.LocalServerSocket server;
-
- Acceptor(android.net.LocalServerSocket server) {
- this.server = server;
- }
-
- @Override public UhidServer.ClientSession accept() throws IOException {
- return new Session(server.accept());
- }
-
- @Override public void close() throws IOException {
- server.close();
- }
- }
-
- private static final class Session implements UhidServer.ClientSession {
- private final android.net.LocalSocket client;
-
- Session(android.net.LocalSocket client) {
- this.client = client;
- }
-
- @Override public void verifyPeer() {
- int pid = -1;
- try {
- android.net.Credentials credentials = client.getPeerCredentials();
- pid = credentials.getPid();
- UhidServer.verifyPeerFromProc(pid);
- } catch (IOException failure) {
- throw UhidServer.cannotVerifyPeer(pid, failure);
- }
- }
-
- @Override public OutputStream getOutputStream() throws IOException {
- return client.getOutputStream();
- }
-
- @Override public InputStream getInputStream() throws IOException {
- return client.getInputStream();
- }
-
- @Override public void close() throws IOException {
- client.close();
- }
- }
-}
diff --git a/uhid-server/src/main/java/com/inputleaf/uhid/UhidServer.java b/uhid-server/src/main/java/com/inputleaf/uhid/UhidServer.java
deleted file mode 100644
index f1f7933d..00000000
--- a/uhid-server/src/main/java/com/inputleaf/uhid/UhidServer.java
+++ /dev/null
@@ -1,218 +0,0 @@
-package com.inputleaf.uhid;
-
-import java.io.Closeable;
-import java.io.DataInputStream;
-import java.io.EOFException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.util.Objects;
-
-public class UhidServer implements Closeable {
- interface DeviceFactory {
- KeyboardDevice createKeyboard() throws IOException;
- MouseDevice createMouse() throws IOException;
- }
-
- interface ClientSession extends Closeable {
- void verifyPeer() throws IOException;
- OutputStream getOutputStream() throws IOException;
- InputStream getInputStream() throws IOException;
- }
-
- interface ClientAcceptor extends Closeable {
- ClientSession accept() throws IOException;
- }
-
- static final String EXPECTED_PACKAGE = "com.inputleaf.android";
- static final byte[] READY_MESSAGE = "READY\n".getBytes(StandardCharsets.US_ASCII);
-
- private static final DeviceFactory DEFAULT_DEVICE_FACTORY = new DeviceFactory() {
- @Override public KeyboardDevice createKeyboard() throws IOException {
- return new KeyboardDevice();
- }
-
- @Override public MouseDevice createMouse() throws IOException {
- return new MouseDevice();
- }
- };
-
- private final KeyboardDevice keyboard;
- private final MouseDevice mouse;
- private final UhidEventDispatcher dispatcher;
-
- public UhidServer() throws IOException {
- this(DEFAULT_DEVICE_FACTORY);
- }
-
- UhidServer(DeviceFactory deviceFactory) throws IOException {
- Objects.requireNonNull(deviceFactory, "deviceFactory");
- KeyboardDevice createdKeyboard = Objects.requireNonNull(
- deviceFactory.createKeyboard(), "deviceFactory keyboard"
- );
- MouseDevice createdMouse;
- try {
- createdMouse = Objects.requireNonNull(
- deviceFactory.createMouse(), "deviceFactory mouse"
- );
- } catch (IOException | RuntimeException | Error creationFailure) {
- closeAfterFailure(createdKeyboard, creationFailure);
- throw creationFailure;
- }
-
- keyboard = createdKeyboard;
- mouse = createdMouse;
- dispatcher = createDispatcher();
- }
-
- UhidServer(KeyboardDevice keyboard, MouseDevice mouse) {
- this.keyboard = Objects.requireNonNull(keyboard, "keyboard");
- this.mouse = Objects.requireNonNull(mouse, "mouse");
- dispatcher = createDispatcher();
- }
-
- private UhidEventDispatcher createDispatcher() {
- return new UhidEventDispatcher(new UhidEventDispatcher.EventSink() {
- @Override public void keyDown(int hidUsage, byte modifiers) throws IOException {
- keyboard.keyDown(hidUsage, modifiers);
- }
-
- @Override public void keyUp(int hidUsage, byte modifiers) throws IOException {
- keyboard.keyUp(hidUsage, modifiers);
- }
-
- @Override public void mouseMove(int dx, int dy) throws IOException {
- mouse.move(dx, dy);
- }
-
- @Override public void mouseButtonDown(byte button) throws IOException {
- mouse.buttonDown(button);
- }
-
- @Override public void mouseButtonUp(byte button) throws IOException {
- mouse.buttonUp(button);
- }
-
- @Override public void mouseWheel(short deltaX, short deltaY) throws IOException {
- mouse.wheel(deltaX, deltaY);
- }
- });
- }
-
- public void run() throws IOException {
- serve(UhidLocalSockets.bind());
- }
-
- void serve(ClientAcceptor server) throws IOException {
- Throwable serverFailure = null;
- try {
- System.out.println("READY");
- System.out.flush();
-
- ClientSession client = server.accept();
- Throwable clientFailure = null;
- try {
- client.verifyPeer();
- client.getOutputStream().write(READY_MESSAGE);
- client.getOutputStream().flush();
-
- runSession(new DataInputStream(client.getInputStream()), dispatcher);
- } catch (IOException | RuntimeException | Error failure) {
- clientFailure = failure;
- throw failure;
- } finally {
- close(client, clientFailure);
- }
- } catch (IOException | RuntimeException | Error failure) {
- serverFailure = failure;
- throw failure;
- } finally {
- close(server, serverFailure);
- }
- }
-
- static void runSession(DataInputStream input, UhidEventDispatcher dispatcher) throws IOException {
- while (true) {
- byte type;
- try {
- type = input.readByte();
- } catch (EOFException disconnected) {
- return;
- }
- if (type == EventProtocol.TYPE_SHUTDOWN) return;
- dispatcher.dispatch(type, input);
- }
- }
-
- static void close(Closeable socket, Throwable failure) throws IOException {
- try {
- socket.close();
- } catch (IOException closeFailure) {
- if (failure == null) throw closeFailure;
- failure.addSuppressed(closeFailure);
- }
- }
-
- static void closeAfterFailure(Closeable resource, Throwable failure) {
- try {
- resource.close();
- } catch (IOException closeFailure) {
- failure.addSuppressed(closeFailure);
- }
- }
-
- static void verifyPeerFromProc(int pid) throws IOException {
- byte[] cmdline = Files.readAllBytes(Paths.get("/proc/" + pid + "/cmdline"));
- String processName = firstArgument(cmdline);
- if (!isAllowedProcessName(processName)) {
- throw new SecurityException("Rejected connection from unknown process: " + processName);
- }
- }
-
- static SecurityException cannotVerifyPeer(int pid, IOException failure) {
- String peer = pid < 0 ? "unknown" : Integer.toString(pid);
- return new SecurityException("Cannot verify peer PID " + peer, failure);
- }
-
- static String firstArgument(byte[] cmdline) {
- int end = 0;
- while (end < cmdline.length && cmdline[end] != 0) end++;
- return new String(cmdline, 0, end, StandardCharsets.UTF_8);
- }
-
- static boolean isAllowedProcessName(String processName) {
- if (processName.equals(EXPECTED_PACKAGE)) return true;
- String prefix = EXPECTED_PACKAGE + ":";
- if (!processName.startsWith(prefix)) return false;
-
- String suffix = processName.substring(prefix.length());
- if (suffix.isEmpty()) return false;
- for (int index = 0; index < suffix.length(); index++) {
- char character = suffix.charAt(index);
- if (!Character.isLetterOrDigit(character) && character != '_' && character != '.') return false;
- }
- return true;
- }
-
- @Override public void close() throws IOException {
- IOException failure = null;
- try {
- keyboard.close();
- } catch (IOException keyboardFailure) {
- failure = keyboardFailure;
- }
- try {
- mouse.close();
- } catch (IOException mouseFailure) {
- if (failure == null) {
- failure = mouseFailure;
- } else {
- failure.addSuppressed(mouseFailure);
- }
- }
- if (failure != null) throw failure;
- }
-}
diff --git a/uhid-server/src/test/java/com/inputleaf/uhid/EventProtocolTest.java b/uhid-server/src/test/java/com/inputleaf/uhid/EventProtocolTest.java
deleted file mode 100644
index 2acc23a0..00000000
--- a/uhid-server/src/test/java/com/inputleaf/uhid/EventProtocolTest.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.inputleaf.uhid;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class EventProtocolTest {
- @Test public void innerEventTypesExposeConstructorArguments() {
- EventProtocol.KeyEvent key = new EventProtocol.KeyEvent(0x61, EventProtocol.ACTION_DOWN, (byte) 2);
- assertThat(key.keysym).isEqualTo(0x61);
- assertThat(key.action).isEqualTo(EventProtocol.ACTION_DOWN);
- assertThat(key.modifiers).isEqualTo(2);
-
- EventProtocol.MouseMove move = new EventProtocol.MouseMove(3, -4);
- assertThat(move.dx).isEqualTo(3);
- assertThat(move.dy).isEqualTo(-4);
-
- EventProtocol.MouseButton button = new EventProtocol.MouseButton((byte) 1, EventProtocol.ACTION_UP);
- assertThat(button.button).isEqualTo(1);
- assertThat(button.action).isEqualTo(EventProtocol.ACTION_UP);
-
- EventProtocol.MouseWheel wheel = new EventProtocol.MouseWheel((short) 5, (short) 6);
- assertThat(wheel.deltaX).isEqualTo(5);
- assertThat(wheel.deltaY).isEqualTo(6);
- }
-}
diff --git a/uhid-server/src/test/java/com/inputleaf/uhid/KeyboardDeviceTest.java b/uhid-server/src/test/java/com/inputleaf/uhid/KeyboardDeviceTest.java
deleted file mode 100644
index 00adbba8..00000000
--- a/uhid-server/src/test/java/com/inputleaf/uhid/KeyboardDeviceTest.java
+++ /dev/null
@@ -1,124 +0,0 @@
-package com.inputleaf.uhid;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertThrows;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
-import org.junit.Test;
-
-public class KeyboardDeviceTest {
- private static final int PACKET_SIZE = 4 + 4 + 4096;
- private static final int CREATE_PACKET_SIZE = 4 + 4 + 128 + 4 + 4096 + 16;
-
- private byte[] reportAt(ByteArrayOutputStream output, int index) {
- byte[] bytes = output.toByteArray();
- int packetOffset = index * PACKET_SIZE;
- assertThat(littleEndianInt(bytes, packetOffset)).isEqualTo(12);
- assertThat(littleEndianInt(bytes, packetOffset + 4)).isEqualTo(8);
- byte[] report = new byte[8];
- System.arraycopy(bytes, packetOffset + 8, report, 0, report.length);
- return report;
- }
-
- private int littleEndianInt(byte[] bytes, int offset) {
- return (bytes[offset] & 0xFF)
- | ((bytes[offset + 1] & 0xFF) << 8)
- | ((bytes[offset + 2] & 0xFF) << 16)
- | ((bytes[offset + 3] & 0xFF) << 24);
- }
-
- @Test public void writesCreatePacketWithKeyboardNameAndDescriptor() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
-
- assertThat(KeyboardDevice.initializeOutput(output)).isSameInstanceAs(output);
-
- byte[] packet = output.toByteArray();
- assertThat(packet).hasLength(CREATE_PACKET_SIZE);
- assertThat(littleEndianInt(packet, 0)).isEqualTo(11);
- assertThat(new String(packet, 4, "InputLeaf Keyboard".length(), StandardCharsets.UTF_8))
- .isEqualTo("InputLeaf Keyboard");
- assertThat(littleEndianInt(packet, 132)).isGreaterThan(0);
- }
-
- @Test public void closesOutputWhenCreatePacketCannotBeWritten() {
- FailingOutputStream output = new FailingOutputStream();
-
- IOException failure = assertThrows(IOException.class, () -> KeyboardDevice.initializeOutput(output));
-
- assertThat(output.closed).isTrue();
- assertThat(failure.getSuppressed()).asList().hasSize(1);
- assertThat(failure.getSuppressed()[0]).hasMessageThat().isEqualTo("close failed");
- }
-
- @Test public void emitsKeyDownUpAndCurrentModifierState() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- KeyboardDevice keyboard = new KeyboardDevice(output);
-
- keyboard.keyDown(0x04, (byte) 0x02);
- keyboard.keyUp(0x04, (byte) 0);
-
- assertThat(reportAt(output, 0)).isEqualTo(new byte[] {0x02, 0, 0x04, 0, 0, 0, 0, 0});
- assertThat(reportAt(output, 1)).isEqualTo(new byte[] {0, 0, 0, 0, 0, 0, 0, 0});
- }
-
- @Test public void representsModifiersOnlyInTheModifierByte() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- KeyboardDevice keyboard = new KeyboardDevice(output);
-
- keyboard.keyDown(0xE1, (byte) 0x02);
- keyboard.keyDown(0x04, (byte) 0x02);
- keyboard.keyUp(0xE1, (byte) 0);
-
- assertThat(reportAt(output, 0)).isEqualTo(new byte[] {0x02, 0, 0, 0, 0, 0, 0, 0});
- assertThat(reportAt(output, 1)).isEqualTo(new byte[] {0x02, 0, 0x04, 0, 0, 0, 0, 0});
- assertThat(reportAt(output, 2)).isEqualTo(new byte[] {0, 0, 0x04, 0, 0, 0, 0, 0});
- }
-
- @Test public void keepsAKeyInOneSlotAndDoesNotDuplicateIt() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- KeyboardDevice keyboard = new KeyboardDevice(output);
-
- keyboard.keyDown(0x04, (byte) 0);
- keyboard.keyDown(0x04, (byte) 0);
-
- assertThat(reportAt(output, 1)).isEqualTo(new byte[] {0, 0, 0x04, 0, 0, 0, 0, 0});
- }
-
- @Test public void intentionallyIgnoresASeventhConcurrentKey() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- KeyboardDevice keyboard = new KeyboardDevice(output);
-
- for (int key = 0x04; key <= 0x0A; key++) keyboard.keyDown(key, (byte) 0);
-
- assertThat(reportAt(output, 6)).isEqualTo(new byte[] {0, 0, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09});
- }
-
- @Test public void retainsHeldBackspaceUntilItIsReleased() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- KeyboardDevice keyboard = new KeyboardDevice(output);
-
- keyboard.keyDown(0x2A, (byte) 0);
- keyboard.keyDown(0x04, (byte) 0);
- keyboard.keyUp(0x2A, (byte) 0);
-
- assertThat(reportAt(output, 0)).isEqualTo(new byte[] {0, 0, 0x2A, 0, 0, 0, 0, 0});
- assertThat(reportAt(output, 1)).isEqualTo(new byte[] {0, 0, 0x2A, 0x04, 0, 0, 0, 0});
- assertThat(reportAt(output, 2)).isEqualTo(new byte[] {0, 0, 0, 0x04, 0, 0, 0, 0});
- }
-
- private static class FailingOutputStream extends OutputStream {
- boolean closed;
-
- @Override public void write(int value) throws IOException {
- throw new IOException("write failed");
- }
-
- @Override public void close() throws IOException {
- closed = true;
- throw new IOException("close failed");
- }
- }
-}
diff --git a/uhid-server/src/test/java/com/inputleaf/uhid/KeysymToHidTest.java b/uhid-server/src/test/java/com/inputleaf/uhid/KeysymToHidTest.java
deleted file mode 100644
index 4981defd..00000000
--- a/uhid-server/src/test/java/com/inputleaf/uhid/KeysymToHidTest.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.inputleaf.uhid;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-public class KeysymToHidTest {
- @Test public void mapsEveryLetterInBothCases() {
- for (int index = 0; index < 26; index++) {
- int usage = 0x04 + index;
- assertThat(KeysymToHid.lookup('a' + index)).isEqualTo(usage);
- assertThat(KeysymToHid.lookup('A' + index)).isEqualTo(usage);
- }
- }
-
- @Test public void mapsEveryDigit() {
- for (int digit = 1; digit <= 9; digit++) {
- assertThat(KeysymToHid.lookup('0' + digit)).isEqualTo(0x1D + digit);
- }
- assertThat(KeysymToHid.lookup('0')).isEqualTo(0x27);
- }
-
- @Test public void mapsEverySupportedControlAndNavigationKey() {
- assertMappings(new int[][] {
- {0xFF0D, 0x28}, {0xFF1B, 0x29}, {0xFF08, 0x2A}, {0xFF09, 0x2B},
- {0x0020, 0x2C}, {0xFF50, 0x4A}, {0xFF57, 0x4D}, {0xFF55, 0x4B},
- {0xFF56, 0x4E}, {0xFF63, 0x49}, {0xFFFF, 0x4C},
- {0xFF51, 0x50}, {0xFF52, 0x52}, {0xFF53, 0x4F}, {0xFF54, 0x51}
- });
- }
-
- @Test public void mapsEverySupportedFunctionKey() {
- for (int index = 0; index < 12; index++) {
- assertThat(KeysymToHid.lookup(0xFFBE + index)).isEqualTo(0x3A + index);
- }
- }
-
- @Test public void mapsEverySupportedModifierKey() {
- assertMappings(new int[][] {
- {0xFFE1, 0xE1}, {0xFFE2, 0xE5}, {0xFFE3, 0xE0},
- {0xFFE4, 0xE4}, {0xFFE9, 0xE2}, {0xFFEA, 0xE6}
- });
- }
-
- @Test public void mapsEverySupportedMediaKey() {
- assertMappings(new int[][] {
- {0x1008FF14, 0xCD}, {0x1008FF11, 0xEA},
- {0x1008FF13, 0xE9}, {0x1008FF12, 0xE2}
- });
- }
-
- @Test public void returnsNullForUnsupportedKeysyms() {
- assertThat(KeysymToHid.lookup(0x123456)).isNull();
- assertThat(KeysymToHid.lookup(0xFFEB)).isNull();
- assertThat(KeysymToHid.lookup('/')).isNull();
- }
-
- private void assertMappings(int[][] mappings) {
- for (int[] mapping : mappings) {
- assertThat(KeysymToHid.lookup(mapping[0])).isEqualTo(mapping[1]);
- }
- }
-}
diff --git a/uhid-server/src/test/java/com/inputleaf/uhid/MouseDeviceTest.java b/uhid-server/src/test/java/com/inputleaf/uhid/MouseDeviceTest.java
deleted file mode 100644
index df42306d..00000000
--- a/uhid-server/src/test/java/com/inputleaf/uhid/MouseDeviceTest.java
+++ /dev/null
@@ -1,129 +0,0 @@
-package com.inputleaf.uhid;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertThrows;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
-import org.junit.Test;
-
-public class MouseDeviceTest {
- private static final int PACKET_SIZE = 4 + 4 + 4096;
- private static final int CREATE_PACKET_SIZE = 4 + 4 + 128 + 4 + 4096 + 16;
-
- private byte[] reportAt(ByteArrayOutputStream output, int index) {
- byte[] bytes = output.toByteArray();
- int packetOffset = index * PACKET_SIZE;
- assertThat(littleEndianInt(bytes, packetOffset)).isEqualTo(12);
- assertThat(littleEndianInt(bytes, packetOffset + 4)).isEqualTo(5);
- byte[] report = new byte[5];
- System.arraycopy(bytes, packetOffset + 8, report, 0, report.length);
- return report;
- }
-
- private int littleEndianInt(byte[] bytes, int offset) {
- return (bytes[offset] & 0xFF)
- | ((bytes[offset + 1] & 0xFF) << 8)
- | ((bytes[offset + 2] & 0xFF) << 16)
- | ((bytes[offset + 3] & 0xFF) << 24);
- }
-
- @Test public void writesCreatePacketWithMouseNameAndDescriptor() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
-
- assertThat(MouseDevice.initializeOutput(output)).isSameInstanceAs(output);
-
- byte[] packet = output.toByteArray();
- assertThat(packet).hasLength(CREATE_PACKET_SIZE);
- assertThat(littleEndianInt(packet, 0)).isEqualTo(11);
- assertThat(new String(packet, 4, "InputLeaf Mouse".length(), StandardCharsets.UTF_8))
- .isEqualTo("InputLeaf Mouse");
- int descriptorLength = littleEndianInt(packet, 132);
- assertThat(descriptorLength).isGreaterThan(0);
- byte[] descriptor = new byte[descriptorLength];
- System.arraycopy(packet, 136, descriptor, 0, descriptorLength);
- assertThat(containsSequence(descriptor, new byte[] {0x05, 0x0C, 0x0A, 0x38, 0x02})).isTrue();
- }
-
- @Test public void closesOutputWhenCreatePacketCannotBeWritten() {
- FailingOutputStream output = new FailingOutputStream();
-
- IOException failure = assertThrows(IOException.class, () -> MouseDevice.initializeOutput(output));
-
- assertThat(output.closed).isTrue();
- assertThat(failure.getSuppressed()).asList().hasSize(1);
- assertThat(failure.getSuppressed()[0]).hasMessageThat().isEqualTo("close failed");
- }
-
- @Test public void retainsAllSupportedButtonStatesAcrossMovementAndRelease() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- MouseDevice mouse = new MouseDevice(output);
-
- mouse.buttonDown(1);
- mouse.buttonDown(2);
- mouse.buttonDown(3);
- mouse.move(5, -3);
- mouse.buttonUp(2);
- mouse.buttonUp(1);
- mouse.buttonUp(3);
-
- assertThat(reportAt(output, 0)).isEqualTo(new byte[] {1, 0, 0, 0, 0});
- assertThat(reportAt(output, 1)).isEqualTo(new byte[] {3, 0, 0, 0, 0});
- assertThat(reportAt(output, 2)).isEqualTo(new byte[] {7, 0, 0, 0, 0});
- assertThat(reportAt(output, 3)).isEqualTo(new byte[] {7, 5, -3, 0, 0});
- assertThat(reportAt(output, 4)).isEqualTo(new byte[] {5, 0, 0, 0, 0});
- assertThat(reportAt(output, 5)).isEqualTo(new byte[] {4, 0, 0, 0, 0});
- assertThat(reportAt(output, 6)).isEqualTo(new byte[] {0, 0, 0, 0, 0});
- }
-
- @Test public void rejectsUnsupportedMouseButtons() throws Exception {
- MouseDevice mouse = new MouseDevice(new ByteArrayOutputStream());
-
- assertThrows(IllegalArgumentException.class, () -> mouse.buttonDown(0));
- assertThrows(IllegalArgumentException.class, () -> mouse.buttonUp(0));
- assertThrows(IllegalArgumentException.class, () -> mouse.buttonDown(4));
- assertThrows(IllegalArgumentException.class, () -> mouse.buttonUp(4));
- }
-
- @Test public void clampsMovementAndBothWheelAxesToHidReportRange() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- MouseDevice mouse = new MouseDevice(output);
-
- mouse.move(1000, -1000);
- mouse.wheel(1000, -1000);
- mouse.wheel(-1000, 1000);
-
- assertThat(reportAt(output, 0)).isEqualTo(new byte[] {0, 127, -127, 0, 0});
- assertThat(reportAt(output, 1)).isEqualTo(new byte[] {0, 0, 0, -127, 127});
- assertThat(reportAt(output, 2)).isEqualTo(new byte[] {0, 0, 0, 127, -127});
- }
-
- private boolean containsSequence(byte[] bytes, byte[] sequence) {
- for (int offset = 0; offset <= bytes.length - sequence.length; offset++) {
- boolean matches = true;
- for (int index = 0; index < sequence.length; index++) {
- if (bytes[offset + index] != sequence[index]) {
- matches = false;
- break;
- }
- }
- if (matches) return true;
- }
- return false;
- }
-
- private static class FailingOutputStream extends OutputStream {
- boolean closed;
-
- @Override public void write(int value) throws IOException {
- throw new IOException("write failed");
- }
-
- @Override public void close() throws IOException {
- closed = true;
- throw new IOException("close failed");
- }
- }
-}
diff --git a/uhid-server/src/test/java/com/inputleaf/uhid/UhidEventDispatcherTest.java b/uhid-server/src/test/java/com/inputleaf/uhid/UhidEventDispatcherTest.java
deleted file mode 100644
index e57a165f..00000000
--- a/uhid-server/src/test/java/com/inputleaf/uhid/UhidEventDispatcherTest.java
+++ /dev/null
@@ -1,187 +0,0 @@
-package com.inputleaf.uhid;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertThrows;
-
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.DataInputStream;
-import java.io.DataOutputStream;
-import java.io.EOFException;
-import java.io.IOException;
-import org.junit.Test;
-
-public class UhidEventDispatcherTest {
- private static class FakeSink implements UhidEventDispatcher.EventSink {
- String event;
- int first;
- int second;
- byte modifiers;
-
- @Override public void keyDown(int hidUsage, byte modifiers) { event = "keyDown"; first = hidUsage; this.modifiers = modifiers; }
- @Override public void keyUp(int hidUsage, byte modifiers) { event = "keyUp"; first = hidUsage; this.modifiers = modifiers; }
- @Override public void mouseMove(int dx, int dy) { event = "move"; first = dx; second = dy; }
- @Override public void mouseButtonDown(byte button) { event = "buttonDown"; first = button; }
- @Override public void mouseButtonUp(byte button) { event = "buttonUp"; first = button; }
- @Override public void mouseWheel(short deltaX, short deltaY) { event = "wheel"; first = deltaX; second = deltaY; }
- }
-
- private static class ThrowingSink implements UhidEventDispatcher.EventSink {
- final IOException failure;
-
- ThrowingSink(IOException failure) {
- this.failure = failure;
- }
-
- @Override public void keyDown(int hidUsage, byte modifiers) throws IOException { throw failure; }
- @Override public void keyUp(int hidUsage, byte modifiers) throws IOException { throw failure; }
- @Override public void mouseMove(int dx, int dy) throws IOException { throw failure; }
- @Override public void mouseButtonDown(byte button) throws IOException { throw failure; }
- @Override public void mouseButtonUp(byte button) throws IOException { throw failure; }
- @Override public void mouseWheel(short deltaX, short deltaY) throws IOException { throw failure; }
- }
-
- private DataInputStream input(ThrowingConsumer writer) throws Exception {
- ByteArrayOutputStream bytes = new ByteArrayOutputStream();
- DataOutputStream output = new DataOutputStream(bytes);
- writer.accept(output);
- return new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()));
- }
-
- private interface ThrowingConsumer { void accept(T value) throws Exception; }
-
- @Test public void rejectsNullSinkImmediately() {
- NullPointerException error = assertThrows(NullPointerException.class,
- () -> new UhidEventDispatcher(null));
-
- assertThat(error).hasMessageThat().isEqualTo("sink");
- }
-
- @Test public void decodesKeyboardEventsAndIgnoresUnknownKeysyms() throws Exception {
- FakeSink sink = new FakeSink();
- UhidEventDispatcher dispatcher = new UhidEventDispatcher(sink);
-
- dispatcher.dispatch(EventProtocol.TYPE_KEY_EVENT, input(out -> {
- out.writeInt('A'); out.writeByte(EventProtocol.ACTION_DOWN); out.writeByte(2);
- }));
- assertThat(sink.event).isEqualTo("keyDown");
- assertThat(sink.first).isEqualTo(0x04);
- assertThat(sink.modifiers).isEqualTo((byte) 2);
-
- dispatcher.dispatch(EventProtocol.TYPE_KEY_EVENT, input(out -> {
- out.writeInt('A'); out.writeByte(EventProtocol.ACTION_UP); out.writeByte(4);
- }));
- assertThat(sink.event).isEqualTo("keyUp");
- assertThat(sink.modifiers).isEqualTo((byte) 4);
-
- sink.event = null;
- dispatcher.dispatch(EventProtocol.TYPE_KEY_EVENT, input(out -> {
- out.writeInt(0x123456); out.writeByte(EventProtocol.ACTION_DOWN); out.writeByte(0);
- }));
- assertThat(sink.event).isNull();
- }
-
- @Test public void decodesMouseMovementBoundaryValues() throws Exception {
- FakeSink sink = new FakeSink();
- UhidEventDispatcher dispatcher = new UhidEventDispatcher(sink);
-
- dispatcher.dispatch(EventProtocol.TYPE_MOUSE_MOVE, input(out -> {
- out.writeInt(Integer.MIN_VALUE); out.writeInt(Integer.MAX_VALUE);
- }));
-
- assertThat(sink.event).isEqualTo("move");
- assertThat(sink.first).isEqualTo(Integer.MIN_VALUE);
- assertThat(sink.second).isEqualTo(Integer.MAX_VALUE);
- }
-
- @Test public void decodesDownAndUpForEverySupportedMouseButton() throws Exception {
- FakeSink sink = new FakeSink();
- UhidEventDispatcher dispatcher = new UhidEventDispatcher(sink);
-
- for (int button = MouseDevice.MIN_BUTTON; button <= MouseDevice.MAX_BUTTON; button++) {
- int currentButton = button;
- dispatcher.dispatch(EventProtocol.TYPE_MOUSE_BTN, input(out -> {
- out.writeByte(currentButton); out.writeByte(EventProtocol.ACTION_DOWN);
- }));
- assertThat(sink.event).isEqualTo("buttonDown");
- assertThat(sink.first).isEqualTo(currentButton);
-
- dispatcher.dispatch(EventProtocol.TYPE_MOUSE_BTN, input(out -> {
- out.writeByte(currentButton); out.writeByte(EventProtocol.ACTION_UP);
- }));
- assertThat(sink.event).isEqualTo("buttonUp");
- assertThat(sink.first).isEqualTo(currentButton);
- }
- }
-
- @Test public void decodesHorizontalAndVerticalWheelBoundaryValues() throws Exception {
- FakeSink sink = new FakeSink();
- UhidEventDispatcher dispatcher = new UhidEventDispatcher(sink);
-
- dispatcher.dispatch(EventProtocol.TYPE_MOUSE_WHEEL, input(out -> {
- out.writeShort(Short.MAX_VALUE); out.writeShort(Short.MIN_VALUE);
- }));
-
- assertThat(sink.event).isEqualTo("wheel");
- assertThat(sink.first).isEqualTo(Short.MAX_VALUE);
- assertThat(sink.second).isEqualTo(Short.MIN_VALUE);
- }
-
- @Test public void preservesSinkFailures() throws Exception {
- IOException expected = new IOException("device write failed");
- UhidEventDispatcher dispatcher = new UhidEventDispatcher(new ThrowingSink(expected));
-
- IOException actual = assertThrows(IOException.class, () -> dispatcher.dispatch(
- EventProtocol.TYPE_MOUSE_MOVE,
- input(out -> { out.writeInt(1); out.writeInt(2); })
- ));
-
- assertThat(actual).isSameInstanceAs(expected);
- }
-
- @Test public void identifiesEveryTruncatedEventPayload() {
- assertTruncated(EventProtocol.TYPE_KEY_EVENT, new byte[5], "keyboard");
- assertTruncated(EventProtocol.TYPE_MOUSE_MOVE, new byte[7], "mouse-move");
- assertTruncated(EventProtocol.TYPE_MOUSE_BTN, new byte[1], "mouse-button");
- assertTruncated(EventProtocol.TYPE_MOUSE_WHEEL, new byte[3], "mouse-wheel");
- }
-
- @Test public void rejectsUnsupportedEventsActionsAndButtonsWithUsefulMessages() {
- UhidEventDispatcher dispatcher = new UhidEventDispatcher(new FakeSink());
-
- IOException typeFailure = assertThrows(IOException.class, () -> dispatcher.dispatch((byte) 99,
- new DataInputStream(new ByteArrayInputStream(new byte[0]))));
- assertThat(typeFailure).hasMessageThat().isEqualTo("Unsupported UHID event type: 99");
-
- IOException keyFailure = assertThrows(IOException.class, () -> dispatcher.dispatch(
- EventProtocol.TYPE_KEY_EVENT,
- input(out -> { out.writeInt(0x123456); out.writeByte(99); out.writeByte(0); })
- ));
- assertThat(keyFailure).hasMessageThat().isEqualTo("Unsupported key action: 99");
-
- IOException actionFailure = assertThrows(IOException.class, () -> dispatcher.dispatch(
- EventProtocol.TYPE_MOUSE_BTN,
- input(out -> { out.writeByte(1); out.writeByte(99); })
- ));
- assertThat(actionFailure).hasMessageThat().isEqualTo("Unsupported mouse action: 99");
-
- for (int button : new int[] {0, 4, 255}) {
- IOException buttonFailure = assertThrows(IOException.class, () -> dispatcher.dispatch(
- EventProtocol.TYPE_MOUSE_BTN,
- input(out -> { out.writeByte(button); out.writeByte(EventProtocol.ACTION_DOWN); })
- ));
- assertThat(buttonFailure).hasMessageThat()
- .isEqualTo("Unsupported mouse button: " + button);
- }
- }
-
- private void assertTruncated(byte type, byte[] payload, String event) {
- UhidEventDispatcher dispatcher = new UhidEventDispatcher(new FakeSink());
-
- IOException error = assertThrows(IOException.class, () -> dispatcher.dispatch(type,
- new DataInputStream(new ByteArrayInputStream(payload))));
-
- assertThat(error).hasMessageThat().isEqualTo("Truncated UHID " + event + " event");
- assertThat(error).hasCauseThat().isInstanceOf(EOFException.class);
- }
-}
diff --git a/uhid-server/src/test/java/com/inputleaf/uhid/UhidServerTest.java b/uhid-server/src/test/java/com/inputleaf/uhid/UhidServerTest.java
deleted file mode 100644
index ed92c672..00000000
--- a/uhid-server/src/test/java/com/inputleaf/uhid/UhidServerTest.java
+++ /dev/null
@@ -1,443 +0,0 @@
-package com.inputleaf.uhid;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertThrows;
-
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.DataInputStream;
-import java.io.DataOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.List;
-import org.junit.Test;
-
-public class UhidServerTest {
- @Test public void acceptsOnlyTheAppAndValidNamedProcesses() {
- assertThat(UhidServer.isAllowedProcessName("com.inputleaf.android")).isTrue();
- assertThat(UhidServer.isAllowedProcessName("com.inputleaf.android:uhid")).isTrue();
- assertThat(UhidServer.isAllowedProcessName("com.inputleaf.android:worker_2.remote")).isTrue();
-
- assertThat(UhidServer.isAllowedProcessName("")).isFalse();
- assertThat(UhidServer.isAllowedProcessName("com.inputleaf.android:")).isFalse();
- assertThat(UhidServer.isAllowedProcessName("com.inputleaf.android:worker process")).isFalse();
- assertThat(UhidServer.isAllowedProcessName("com.inputleaf.android:worker:extra")).isFalse();
- assertThat(UhidServer.isAllowedProcessName("com.inputleaf.android.evil")).isFalse();
- assertThat(UhidServer.isAllowedProcessName("evil.com.inputleaf.android")).isFalse();
- }
-
- @Test public void readsOnlyTheFirstProcCmdlineArgument() {
- byte[] cmdline = "com.inputleaf.android:worker\u0000--argument\u0000"
- .getBytes(StandardCharsets.UTF_8);
-
- assertThat(UhidServer.firstArgument(cmdline)).isEqualTo("com.inputleaf.android:worker");
- assertThat(UhidServer.firstArgument("without-separator".getBytes(StandardCharsets.UTF_8)))
- .isEqualTo("without-separator");
- assertThat(UhidServer.firstArgument(new byte[0])).isEmpty();
- }
-
- @Test public void treatsClientEofAsACleanSessionEnd() throws Exception {
- DataInputStream input = new DataInputStream(new ByteArrayInputStream(new byte[0]));
-
- UhidServer.runSession(input, new UhidEventDispatcher(new RecordingSink()));
-
- assertThat(input.available()).isEqualTo(0);
- }
-
- @Test public void dispatchesEventsInOrderAndStopsAtShutdown() throws Exception {
- ByteArrayOutputStream bytes = new ByteArrayOutputStream();
- DataOutputStream output = new DataOutputStream(bytes);
- output.writeByte(EventProtocol.TYPE_KEY_EVENT);
- output.writeInt('A');
- output.writeByte(EventProtocol.ACTION_DOWN);
- output.writeByte(2);
- output.writeByte(EventProtocol.TYPE_MOUSE_MOVE);
- output.writeInt(5);
- output.writeInt(-3);
- output.writeByte(EventProtocol.TYPE_SHUTDOWN);
- output.writeByte(42);
- DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()));
- RecordingSink sink = new RecordingSink();
-
- UhidServer.runSession(input, new UhidEventDispatcher(sink));
-
- assertThat(sink.events).containsExactly("keyDown:4:2", "move:5:-3").inOrder();
- assertThat(input.readUnsignedByte()).isEqualTo(42);
- }
-
- @Test public void preservesMalformedEventErrors() throws Exception {
- byte[] truncatedWheel = {
- EventProtocol.TYPE_MOUSE_WHEEL, 0, 1, 0
- };
- DataInputStream input = new DataInputStream(new ByteArrayInputStream(truncatedWheel));
-
- IOException failure = assertThrows(IOException.class,
- () -> UhidServer.runSession(input, new UhidEventDispatcher(new RecordingSink())));
-
- assertThat(failure).hasMessageThat().isEqualTo("Truncated UHID mouse-wheel event");
- }
-
- @Test public void closesBothDevicesWhenNeitherCloseFails() throws Exception {
- TrackingOutputStream keyboardOutput = new TrackingOutputStream("keyboard", false);
- TrackingOutputStream mouseOutput = new TrackingOutputStream("mouse", false);
- UhidServer server = serverWith(keyboardOutput, mouseOutput);
-
- server.close();
-
- assertThat(keyboardOutput.closed).isTrue();
- assertThat(mouseOutput.closed).isTrue();
- }
-
- @Test public void attemptsToCloseMouseWhenKeyboardCloseFails() {
- TrackingOutputStream keyboardOutput = new TrackingOutputStream("keyboard", true);
- TrackingOutputStream mouseOutput = new TrackingOutputStream("mouse", false);
- UhidServer server = serverWith(keyboardOutput, mouseOutput);
-
- IOException failure = assertThrows(IOException.class, server::close);
-
- assertThat(keyboardOutput.closed).isTrue();
- assertThat(mouseOutput.closed).isTrue();
- assertThat(failure).hasMessageThat().isEqualTo("keyboard close failed");
- assertThat(failure.getSuppressed()).isEmpty();
- }
-
- @Test public void reportsMouseCloseFailureAfterClosingKeyboard() {
- TrackingOutputStream keyboardOutput = new TrackingOutputStream("keyboard", false);
- TrackingOutputStream mouseOutput = new TrackingOutputStream("mouse", true);
- UhidServer server = serverWith(keyboardOutput, mouseOutput);
-
- IOException failure = assertThrows(IOException.class, server::close);
-
- assertThat(keyboardOutput.closed).isTrue();
- assertThat(mouseOutput.closed).isTrue();
- assertThat(failure).hasMessageThat().isEqualTo("mouse close failed");
- }
-
- @Test public void preservesBothDeviceCloseFailures() {
- TrackingOutputStream keyboardOutput = new TrackingOutputStream("keyboard", true);
- TrackingOutputStream mouseOutput = new TrackingOutputStream("mouse", true);
- UhidServer server = serverWith(keyboardOutput, mouseOutput);
-
- IOException failure = assertThrows(IOException.class, server::close);
-
- assertThat(keyboardOutput.closed).isTrue();
- assertThat(mouseOutput.closed).isTrue();
- assertThat(failure).hasMessageThat().isEqualTo("keyboard close failed");
- assertThat(failure.getSuppressed()).asList().hasSize(1);
- assertThat(failure.getSuppressed()[0]).hasMessageThat().isEqualTo("mouse close failed");
- }
-
- @Test public void constructsFromASuccessfulDeviceFactoryAndDispatchesThroughIt() throws Exception {
- TrackingOutputStream keyboardOutput = new TrackingOutputStream("keyboard", false);
- TrackingOutputStream mouseOutput = new TrackingOutputStream("mouse", false);
- UhidServer.DeviceFactory factory = new UhidServer.DeviceFactory() {
- @Override public KeyboardDevice createKeyboard() {
- return new KeyboardDevice(keyboardOutput);
- }
-
- @Override public MouseDevice createMouse() {
- return new MouseDevice(mouseOutput);
- }
- };
-
- UhidServer server = new UhidServer(factory);
- java.lang.reflect.Field dispatcherField = UhidServer.class.getDeclaredField("dispatcher");
- dispatcherField.setAccessible(true);
- UhidEventDispatcher dispatcher = (UhidEventDispatcher) dispatcherField.get(server);
-
- ByteArrayOutputStream bytes = new ByteArrayOutputStream();
- DataOutputStream output = new DataOutputStream(bytes);
- output.writeByte(EventProtocol.TYPE_KEY_EVENT);
- output.writeInt('A');
- output.writeByte(EventProtocol.ACTION_DOWN);
- output.writeByte(0);
- output.writeByte(EventProtocol.TYPE_KEY_EVENT);
- output.writeInt('A');
- output.writeByte(EventProtocol.ACTION_UP);
- output.writeByte(0);
- output.writeByte(EventProtocol.TYPE_MOUSE_MOVE);
- output.writeInt(1);
- output.writeInt(2);
- output.writeByte(EventProtocol.TYPE_MOUSE_BTN);
- output.writeByte(1);
- output.writeByte(EventProtocol.ACTION_DOWN);
- output.writeByte(EventProtocol.TYPE_MOUSE_BTN);
- output.writeByte(1);
- output.writeByte(EventProtocol.ACTION_UP);
- output.writeByte(EventProtocol.TYPE_MOUSE_WHEEL);
- output.writeShort(3);
- output.writeShort(4);
- DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()));
- while (input.available() > 0) {
- byte type = input.readByte();
- dispatcher.dispatch(type, input);
- }
-
- server.close();
- assertThat(keyboardOutput.closed).isTrue();
- assertThat(mouseOutput.closed).isTrue();
- }
-
- @Test public void defaultFactoryFailsWithoutUhidOrClosesCleanly() {
- try {
- UhidServer server = new UhidServer();
- try {
- server.close();
- } catch (IOException ignored) {
- }
- } catch (IOException | RuntimeException | UnsatisfiedLinkError | NoClassDefFoundError ignored) {
- }
- try {
- new KeyboardDevice().close();
- } catch (IOException | RuntimeException ignored) {
- }
- try {
- new MouseDevice().close();
- } catch (IOException | RuntimeException ignored) {
- }
- }
-
- @Test public void closesKeyboardWhenMouseCreationFails() {
- TrackingOutputStream keyboardOutput = new TrackingOutputStream("keyboard", true);
- IOException creationFailure = new IOException("mouse creation failed");
- UhidServer.DeviceFactory factory = new UhidServer.DeviceFactory() {
- @Override public KeyboardDevice createKeyboard() {
- return new KeyboardDevice(keyboardOutput);
- }
-
- @Override public MouseDevice createMouse() throws IOException {
- throw creationFailure;
- }
- };
-
- IOException failure = assertThrows(IOException.class, () -> new UhidServer(factory));
-
- assertThat(failure).isSameInstanceAs(creationFailure);
- assertThat(keyboardOutput.closed).isTrue();
- assertThat(failure.getSuppressed()).asList().hasSize(1);
- assertThat(failure.getSuppressed()[0]).hasMessageThat().isEqualTo("keyboard close failed");
- }
-
- @Test public void serveWritesReadyRunsTheSessionAndClosesBothEnds() throws Exception {
- ByteArrayOutputStream clientOut = new ByteArrayOutputStream();
- ByteArrayOutputStream sessionBytes = new ByteArrayOutputStream();
- new DataOutputStream(sessionBytes).writeByte(EventProtocol.TYPE_SHUTDOWN);
- FakeSession client = new FakeSession(
- new ByteArrayInputStream(sessionBytes.toByteArray()),
- clientOut
- );
- FakeAcceptor acceptor = new FakeAcceptor(client);
- UhidServer server = serverWith(
- new TrackingOutputStream("keyboard", false),
- new TrackingOutputStream("mouse", false)
- );
-
- server.serve(acceptor);
-
- assertThat(client.verified).isTrue();
- assertThat(client.closed).isTrue();
- assertThat(acceptor.closed).isTrue();
- assertThat(clientOut.toByteArray()).isEqualTo(UhidServer.READY_MESSAGE);
- }
-
- @Test public void serveClosesTheAcceptorWhenAcceptFails() {
- IOException acceptFailure = new IOException("accept failed");
- FakeAcceptor acceptor = new FakeAcceptor(acceptFailure);
- UhidServer server = serverWith(
- new TrackingOutputStream("keyboard", false),
- new TrackingOutputStream("mouse", false)
- );
-
- IOException failure = assertThrows(IOException.class, () -> server.serve(acceptor));
-
- assertThat(failure).isSameInstanceAs(acceptFailure);
- assertThat(acceptor.closed).isTrue();
- }
-
- @Test public void serveSuppressesClientCloseErrorsAfterASessionFailure() {
- IOException sessionFailure = new IOException("session failed");
- FakeSession client = new FakeSession(sessionFailure, true);
- FakeAcceptor acceptor = new FakeAcceptor(client);
- UhidServer server = serverWith(
- new TrackingOutputStream("keyboard", false),
- new TrackingOutputStream("mouse", false)
- );
-
- IOException failure = assertThrows(IOException.class, () -> server.serve(acceptor));
-
- assertThat(failure).isSameInstanceAs(sessionFailure);
- assertThat(failure.getSuppressed()).asList().hasSize(1);
- assertThat(failure.getSuppressed()[0]).hasMessageThat().isEqualTo("client close failed");
- assertThat(client.closed).isTrue();
- assertThat(acceptor.closed).isTrue();
- }
-
- @Test public void closeRethrowsWhenTheResourceFailsWithoutAPriorError() {
- TrackingCloseable resource = new TrackingCloseable(true);
-
- IOException failure = assertThrows(IOException.class, () -> UhidServer.close(resource, null));
-
- assertThat(failure).hasMessageThat().isEqualTo("close failed");
- assertThat(resource.closed).isTrue();
- }
-
- @Test public void verifyPeerFromProcRejectsThisJvm() {
- int pid = (int) ProcessHandle.current().pid();
- SecurityException failure = assertThrows(
- SecurityException.class,
- () -> UhidServer.verifyPeerFromProc(pid)
- );
- assertThat(failure).hasMessageThat().contains("Rejected connection from unknown process");
- }
-
- @Test public void cannotVerifyPeerUsesUnknownWhenPidIsMissing() {
- IOException cause = new IOException("no credentials");
- SecurityException failure = UhidServer.cannotVerifyPeer(-1, cause);
- assertThat(failure).hasMessageThat().isEqualTo("Cannot verify peer PID unknown");
- assertThat(failure).hasCauseThat().isSameInstanceAs(cause);
-
- SecurityException numbered = UhidServer.cannotVerifyPeer(42, cause);
- assertThat(numbered).hasMessageThat().isEqualTo("Cannot verify peer PID 42");
- }
-
- @Test public void runFailsWithoutAndroidLocalSockets() throws Exception {
- UhidServer server = serverWith(
- new TrackingOutputStream("keyboard", false),
- new TrackingOutputStream("mouse", false)
- );
-
- assertThrows(Throwable.class, server::run);
- server.close();
- }
-
- @Test public void rejectsANullDeviceFactory() {
- assertThrows(NullPointerException.class, () -> new UhidServer((UhidServer.DeviceFactory) null));
- }
-
- private static final class FakeAcceptor implements UhidServer.ClientAcceptor {
- private final FakeSession client;
- private final IOException acceptFailure;
- boolean closed;
-
- FakeAcceptor(FakeSession client) {
- this.client = client;
- this.acceptFailure = null;
- }
-
- FakeAcceptor(IOException acceptFailure) {
- this.client = null;
- this.acceptFailure = acceptFailure;
- }
-
- @Override public UhidServer.ClientSession accept() throws IOException {
- if (acceptFailure != null) throw acceptFailure;
- return client;
- }
-
- @Override public void close() {
- closed = true;
- }
- }
-
- private static final class FakeSession implements UhidServer.ClientSession {
- private final ByteArrayInputStream input;
- private final ByteArrayOutputStream output;
- private final IOException verifyFailure;
- private final boolean failOnClose;
- boolean verified;
- boolean closed;
-
- FakeSession(ByteArrayInputStream input, ByteArrayOutputStream output) {
- this.input = input;
- this.output = output;
- this.verifyFailure = null;
- this.failOnClose = false;
- }
-
- FakeSession(IOException verifyFailure, boolean failOnClose) {
- this.input = new ByteArrayInputStream(new byte[0]);
- this.output = new ByteArrayOutputStream();
- this.verifyFailure = verifyFailure;
- this.failOnClose = failOnClose;
- }
-
- @Override public void verifyPeer() throws IOException {
- if (verifyFailure != null) throw verifyFailure;
- verified = true;
- }
-
- @Override public java.io.OutputStream getOutputStream() {
- return output;
- }
-
- @Override public java.io.InputStream getInputStream() {
- return input;
- }
-
- @Override public void close() throws IOException {
- closed = true;
- if (failOnClose) throw new IOException("client close failed");
- }
- }
-
- private static final class TrackingCloseable implements java.io.Closeable {
- private final boolean failOnClose;
- boolean closed;
-
- TrackingCloseable(boolean failOnClose) {
- this.failOnClose = failOnClose;
- }
-
- @Override public void close() throws IOException {
- closed = true;
- if (failOnClose) throw new IOException("close failed");
- }
- }
-
- private UhidServer serverWith(OutputStream keyboardOutput, OutputStream mouseOutput) {
- return new UhidServer(new KeyboardDevice(keyboardOutput), new MouseDevice(mouseOutput));
- }
-
- private static class RecordingSink implements UhidEventDispatcher.EventSink {
- final List events = new ArrayList<>();
-
- @Override public void keyDown(int hidUsage, byte modifiers) {
- events.add("keyDown:" + hidUsage + ":" + modifiers);
- }
- @Override public void keyUp(int hidUsage, byte modifiers) {
- events.add("keyUp:" + hidUsage + ":" + modifiers);
- }
- @Override public void mouseMove(int dx, int dy) {
- events.add("move:" + dx + ":" + dy);
- }
- @Override public void mouseButtonDown(byte button) {
- events.add("buttonDown:" + button);
- }
- @Override public void mouseButtonUp(byte button) {
- events.add("buttonUp:" + button);
- }
- @Override public void mouseWheel(short deltaX, short deltaY) {
- events.add("wheel:" + deltaX + ":" + deltaY);
- }
- }
-
- private static class TrackingOutputStream extends OutputStream {
- private final String device;
- private final boolean failOnClose;
- boolean closed;
-
- TrackingOutputStream(String device, boolean failOnClose) {
- this.device = device;
- this.failOnClose = failOnClose;
- }
-
- @Override public void write(int value) {}
-
- @Override public void close() throws IOException {
- closed = true;
- if (failOnClose) throw new IOException(device + " close failed");
- }
- }
-}
diff --git a/uhid_mouse_backup.patch b/uhid_mouse_backup.patch
deleted file mode 100644
index 361ff7a1..00000000
--- a/uhid_mouse_backup.patch
+++ /dev/null
@@ -1,753 +0,0 @@
-diff --git a/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl b/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl
-index 6d5d8f9..1cbf86c 100644
---- a/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl
-+++ b/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl
-@@ -43,6 +43,21 @@ interface IInputInjector {
- */
- boolean injectText(String text);
-
-+ /**
-+ * Open a virtual HID mouse on /dev/uhid for system pointer (notification shade).
-+ */
-+ boolean openUhidMouse();
-+
-+ void closeUhidMouse();
-+
-+ boolean isUhidMouseOpen();
-+
-+ boolean injectUhidMouseRelative(int dx, int dy);
-+
-+ boolean injectUhidMouseButton(int button, boolean isDown);
-+
-+ boolean injectUhidMouseWheel(int deltaX, int deltaY);
-+
- /**
- * Destroy the service and release resources.
- */
-diff --git a/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt b/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt
-index 90614a4..f365b4b 100644
---- a/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt
-+++ b/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt
-@@ -14,6 +14,8 @@ import android.view.MotionEvent
- */
- class InputInjectorService : IInputInjector.Stub() {
-
-+ private var uhidMouse: com.inputleaf.uhid.MouseDevice? = null
-+
- private val inputManager: HiddenInputManager.Target? =
- try {
- HiddenInputManager.resolve()
-@@ -23,6 +25,8 @@ class InputInjectorService : IInputInjector.Stub() {
- }
-
- companion object {
-+ private const val TAG = "InputInjectorService"
-+
- // Injection mode: async (don't wait for injection to complete)
- private const val INJECT_INPUT_EVENT_MODE_ASYNC = 0
- // Wait until the system reports whether text injection was accepted.
-@@ -185,7 +189,64 @@ class InputInjectorService : IInputInjector.Stub() {
- }
- }
-
-+ override fun openUhidMouse(): Boolean {
-+ closeUhidMouse()
-+ return try {
-+ uhidMouse = com.inputleaf.uhid.MouseDevice()
-+ android.util.Log.i(TAG, "Opened UHID mouse on /dev/uhid")
-+ true
-+ } catch (e: Exception) {
-+ android.util.Log.e(TAG, "Failed to open UHID mouse", e)
-+ uhidMouse = null
-+ false
-+ }
-+ }
-+
-+ override fun closeUhidMouse() {
-+ try {
-+ uhidMouse?.close()
-+ } catch (e: Exception) {
-+ android.util.Log.w(TAG, "Error closing UHID mouse", e)
-+ }
-+ uhidMouse = null
-+ }
-+
-+ override fun isUhidMouseOpen(): Boolean = uhidMouse != null
-+
-+ override fun injectUhidMouseRelative(dx: Int, dy: Int): Boolean {
-+ val mouse = uhidMouse ?: return false
-+ return try {
-+ mouse.move(dx, dy)
-+ true
-+ } catch (e: Exception) {
-+ android.util.Log.e(TAG, "UHID mouse move failed", e)
-+ false
-+ }
-+ }
-+
-+ override fun injectUhidMouseButton(button: Int, isDown: Boolean): Boolean {
-+ val mouse = uhidMouse ?: return false
-+ return try {
-+ if (isDown) mouse.buttonDown(button) else mouse.buttonUp(button)
-+ true
-+ } catch (e: Exception) {
-+ android.util.Log.e(TAG, "UHID mouse button failed", e)
-+ false
-+ }
-+ }
-+
-+ override fun injectUhidMouseWheel(deltaX: Int, deltaY: Int): Boolean {
-+ val mouse = uhidMouse ?: return false
-+ return try {
-+ mouse.wheel(deltaX, deltaY)
-+ true
-+ } catch (e: Exception) {
-+ android.util.Log.e(TAG, "UHID mouse wheel failed", e)
-+ false
-+ }
-+ }
-+
- override fun destroy() {
-- // Nothing to clean up
-+ closeUhidMouse()
- }
- }
-diff --git a/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt
-index 850840d..d40dc00 100644
---- a/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt
-+++ b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt
-@@ -15,12 +15,16 @@ import com.inputleaf.android.inject.KeysymInjection
- import com.inputleaf.android.inject.KeysymResolver
- import com.inputleaf.android.inject.ProtocolScanCodeDecoder
- import com.inputleaf.android.model.InputLeapEvent
-+import kotlin.math.abs
- import kotlinx.coroutines.CompletableDeferred
-+import kotlinx.coroutines.Dispatchers
- import kotlinx.coroutines.TimeoutCancellationException
-+import kotlinx.coroutines.withContext
- import kotlinx.coroutines.withTimeout
- import rikka.shizuku.Shizuku
-
- private const val TAG = "ShizukuInputInjector"
-+private const val WARP_THRESHOLD_PX = 80
-
- /**
- * Wrapper for Shizuku-based input injection.
-@@ -31,16 +35,21 @@ class ShizukuInputInjector(
- private val screenWidth: Int,
- private val screenHeight: Int
- ) : InputInjector {
-- override val name: String = "Shizuku (ADB-level injection)"
--
-+ override val name: String = "Shizuku (system pointer + ADB keys)"
-+
-+ /** True when virtual HID mouse is active — Android draws pointer over notification shade. */
-+ var usesSystemPointer: Boolean = false
-+ private set
-+
- private var service: IInputInjector? = null
- private var isBound = false
- private var connectDeferred: CompletableDeferred? = null
-
-- // Track absolute mouse position (InputLeap sends absolute coords,
-- // but we may need to synthesize relative movements)
-+ // Logical InputLeap coordinates. A relative HID mouse cannot teleport, so we pin
-+ // to (0,0) then walk to the target whenever the pointer is unsynced (connect/leave/enter).
- private var mouseX = 0f
- private var mouseY = 0f
-+ private var uhidSynced = false
-
- // Track button state for proper motion event sequencing
- private var buttonState = 0
-@@ -54,7 +63,10 @@ class ShizukuInputInjector(
- "com.inputleaf.android",
- InputInjectorService::class.java.name
- )
-- ).daemon(false).processNameSuffix("input_injector")
-+ )
-+ .daemon(false)
-+ .processNameSuffix("input_injector")
-+ .version(5)
-
- private val serviceConnection = object : ServiceConnection {
- override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
-@@ -68,6 +80,8 @@ class ShizukuInputInjector(
- Log.d(TAG, "Shizuku service disconnected")
- service = null
- isBound = false
-+ usesSystemPointer = false
-+ uhidSynced = false
- connectDeferred?.complete(false)
- }
- }
-@@ -88,21 +102,21 @@ class ShizukuInputInjector(
- * Bind to the Shizuku service. Must be called before sending events.
- * @return true if binding was initiated successfully
- */
-- override suspend fun connect(): Boolean {
-+ override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
- if (!isAvailable()) {
- Log.e(TAG, "Shizuku not available or permission not granted")
-- return false
-+ return@withContext false
- }
- if (isBound && service != null) {
-- return true
-+ return@withContext true
- }
--
-+
- val deferred = CompletableDeferred()
- connectDeferred = deferred
--
-- return try {
-+
-+ val bound = try {
- Shizuku.bindUserService(serviceArgs, serviceConnection)
-- withTimeout(5000) {
-+ withTimeout(5_000) {
- deferred.await()
- }
- } catch (e: TimeoutCancellationException) {
-@@ -114,6 +128,30 @@ class ShizukuInputInjector(
- } finally {
- connectDeferred = null
- }
-+
-+ if (!bound) {
-+ disconnect()
-+ return@withContext false
-+ }
-+
-+ val svc = service
-+ if (svc == null) {
-+ disconnect()
-+ return@withContext false
-+ }
-+
-+ usesSystemPointer = try {
-+ svc.openUhidMouse()
-+ } catch (e: Exception) {
-+ Log.w(TAG, "UHID mouse unavailable, falling back to injected motion events", e)
-+ false
-+ }
-+
-+ if (usesSystemPointer) {
-+ Log.i(TAG, "UHID mouse ready — system pointer active")
-+ }
-+
-+ true
- }
-
- /**
-@@ -129,6 +167,10 @@ class ShizukuInputInjector(
- }
- service = null
- isBound = false
-+ usesSystemPointer = false
-+ uhidSynced = false
-+ mouseX = 0f
-+ mouseY = 0f
- }
- }
-
-@@ -140,48 +182,54 @@ class ShizukuInputInjector(
-
- try {
- when (event) {
-+ is InputLeapEvent.Leave -> {
-+ uhidSynced = false
-+ }
-+
- is InputLeapEvent.MouseMoveAbs -> {
-- mouseX = event.x.toFloat().coerceIn(0f, screenWidth.toFloat())
-- mouseY = event.y.toFloat().coerceIn(0f, screenHeight.toFloat())
--
-- // Determine action based on button state
-- val action = if (buttonState != 0) {
-- MotionEvent.ACTION_MOVE
-- } else {
-- MotionEvent.ACTION_HOVER_MOVE
-- }
-- svc.injectMotionEvent(action, mouseX, mouseY, buttonState)
-+ moveMouseTo(svc, event.x, event.y)
- }
--
-+
- is InputLeapEvent.MouseMoveRel -> {
-- mouseX = (mouseX + event.dx).coerceIn(0f, screenWidth.toFloat())
-- mouseY = (mouseY + event.dy).coerceIn(0f, screenHeight.toFloat())
--
-- val action = if (buttonState != 0) {
-- MotionEvent.ACTION_MOVE
-+ if (usesSystemPointer) {
-+ moveMouseBy(svc, event.dx, event.dy)
- } else {
-- MotionEvent.ACTION_HOVER_MOVE
-+ mouseX = (mouseX + event.dx).coerceIn(0f, screenWidth.toFloat())
-+ mouseY = (mouseY + event.dy).coerceIn(0f, screenHeight.toFloat())
-+ injectMotionAt(svc, MotionEvent.ACTION_HOVER_MOVE)
- }
-- svc.injectMotionEvent(action, mouseX, mouseY, buttonState)
- }
--
-+
- is InputLeapEvent.MouseDown -> {
- val button = inputLeapButtonToAndroid(event.buttonId)
- buttonState = buttonState or button
-- svc.injectMotionEvent(MotionEvent.ACTION_DOWN, mouseX, mouseY, buttonState)
-+ if (usesSystemPointer) {
-+ svc.injectUhidMouseButton(inputLeapButtonToHid(event.buttonId), true)
-+ } else {
-+ svc.injectMotionEvent(MotionEvent.ACTION_DOWN, mouseX, mouseY, buttonState)
-+ }
- }
--
-+
- is InputLeapEvent.MouseUp -> {
- val button = inputLeapButtonToAndroid(event.buttonId)
- buttonState = buttonState and button.inv()
-- svc.injectMotionEvent(MotionEvent.ACTION_UP, mouseX, mouseY, buttonState)
-+ if (usesSystemPointer) {
-+ svc.injectUhidMouseButton(inputLeapButtonToHid(event.buttonId), false)
-+ } else {
-+ svc.injectMotionEvent(MotionEvent.ACTION_UP, mouseX, mouseY, buttonState)
-+ }
- }
--
-+
- is InputLeapEvent.MouseWheel -> {
-- // InputLeap sends 120 units per notch, Android expects -1 to 1
-- val vScroll = event.yDelta / 120f
-- val hScroll = event.xDelta / 120f
-- svc.injectScrollEvent(mouseX, mouseY, hScroll, vScroll)
-+ if (usesSystemPointer) {
-+ val vWheel = (event.yDelta / 120).coerceIn(-127, 127)
-+ val hWheel = (event.xDelta / 120).coerceIn(-127, 127)
-+ svc.injectUhidMouseWheel(hWheel, vWheel)
-+ } else {
-+ val vScroll = event.yDelta / 120f
-+ val hScroll = event.xDelta / 120f
-+ svc.injectScrollEvent(mouseX, mouseY, hScroll, vScroll)
-+ }
- }
-
- is InputLeapEvent.KeyDown -> {
-@@ -329,15 +377,83 @@ class ShizukuInputInjector(
- svc.injectKeyEvent(action, keyCode, scancode, metaState)
- }
-
-+ private fun moveMouseTo(svc: IInputInjector, x: Int, y: Int) {
-+ val targetX = x.coerceIn(0, screenWidth)
-+ val targetY = y.coerceIn(0, screenHeight)
-+ if (!usesSystemPointer) {
-+ mouseX = targetX.toFloat()
-+ mouseY = targetY.toFloat()
-+ injectMotionAt(svc, if (buttonState != 0) MotionEvent.ACTION_MOVE else MotionEvent.ACTION_HOVER_MOVE)
-+ return
-+ }
-+ val dx = targetX - mouseX.toInt()
-+ val dy = targetY - mouseY.toInt()
-+ if (!uhidSynced || abs(dx) + abs(dy) > WARP_THRESHOLD_PX) {
-+ warpUhidTo(svc, targetX, targetY)
-+ } else {
-+ sendRelativeDelta(svc, dx, dy)
-+ mouseX = targetX.toFloat()
-+ mouseY = targetY.toFloat()
-+ }
-+ }
-+
-+ private fun moveMouseBy(svc: IInputInjector, dx: Int, dy: Int) {
-+ if (!usesSystemPointer) {
-+ mouseX = (mouseX + dx).coerceIn(0f, screenWidth.toFloat())
-+ mouseY = (mouseY + dy).coerceIn(0f, screenHeight.toFloat())
-+ injectMotionAt(svc, if (buttonState != 0) MotionEvent.ACTION_MOVE else MotionEvent.ACTION_HOVER_MOVE)
-+ return
-+ }
-+ sendRelativeDelta(svc, dx, dy)
-+ mouseX = (mouseX + dx).coerceIn(0f, screenWidth.toFloat())
-+ mouseY = (mouseY + dy).coerceIn(0f, screenHeight.toFloat())
-+ }
-+
-+ /**
-+ * Relative HID cannot set x/y. Pin the OS pointer to the top-left, then walk to the
-+ * InputLeap coordinate so Enter lands on the same border as the overlay path did.
-+ */
-+ private fun warpUhidTo(svc: IInputInjector, x: Int, y: Int) {
-+ val slam = (screenWidth.coerceAtLeast(screenHeight) * 2).coerceAtLeast(512)
-+ sendRelativeDelta(svc, -slam, -slam)
-+ sendRelativeDelta(svc, x, y)
-+ mouseX = x.toFloat()
-+ mouseY = y.toFloat()
-+ uhidSynced = true
-+ }
-+
-+ private fun sendRelativeDelta(svc: IInputInjector, dx: Int, dy: Int) {
-+ var remainingX = dx
-+ var remainingY = dy
-+ while (remainingX != 0 || remainingY != 0) {
-+ val stepX = remainingX.coerceIn(-127, 127)
-+ val stepY = remainingY.coerceIn(-127, 127)
-+ svc.injectUhidMouseRelative(stepX, stepY)
-+ remainingX -= stepX
-+ remainingY -= stepY
-+ }
-+ }
-+
-+ private fun injectMotionAt(svc: IInputInjector, action: Int) {
-+ svc.injectMotionEvent(action, mouseX, mouseY, buttonState)
-+ }
-+
- private fun inputLeapButtonToAndroid(buttonId: Int): Int {
-- // InputLeap button IDs: 1=left, 2=middle, 3=right
- return when (buttonId) {
- 1 -> MotionEvent.BUTTON_PRIMARY
-- 2 -> MotionEvent.BUTTON_TERTIARY // middle
-- 3 -> MotionEvent.BUTTON_SECONDARY // right
-+ 2 -> MotionEvent.BUTTON_TERTIARY
-+ 3 -> MotionEvent.BUTTON_SECONDARY
- else -> 0
- }
- }
--
-+
-+ private fun inputLeapButtonToHid(buttonId: Int): Int {
-+ return when (buttonId) {
-+ 1 -> 1
-+ 2 -> 3
-+ 3 -> 2
-+ else -> 1
-+ }
-+ }
- }
-
-diff --git a/uhid-server/src/main/java/com/inputleaf/uhid/MouseDevice.java b/uhid-server/src/main/java/com/inputleaf/uhid/MouseDevice.java
-index 3be9626..2e15d28 100644
---- a/uhid-server/src/main/java/com/inputleaf/uhid/MouseDevice.java
-+++ b/uhid-server/src/main/java/com/inputleaf/uhid/MouseDevice.java
-@@ -1,74 +1,65 @@
- package com.inputleaf.uhid;
-
- import java.io.Closeable;
--import java.io.FileOutputStream;
- import java.io.IOException;
- import java.io.OutputStream;
--import java.nio.charset.StandardCharsets;
-
-+/** Relative HID mouse for Android system pointer (works over notification shade). */
- public class MouseDevice implements Closeable {
- static final int MIN_BUTTON = 1;
- static final int MAX_BUTTON = 3;
-
-- private static final int UHID_CREATE2 = 11;
-- private static final int UHID_INPUT2 = 12;
--
- private static final byte[] MOUSE_DESCRIPTOR = {
- 0x05, 0x01, 0x09, 0x02, (byte) 0xA1, 0x01, 0x09, 0x01,
- (byte) 0xA1, 0x00,
- 0x05, 0x09, 0x19, MIN_BUTTON, 0x29, MAX_BUTTON, 0x15, 0x00, 0x25, 0x01,
- 0x75, 0x01, (byte) 0x95, MAX_BUTTON, (byte) 0x81, 0x02,
- 0x75, 0x05, (byte) 0x95, 0x01, (byte) 0x81, 0x03,
-- 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x15, (byte) 0x81, 0x25, 0x7F,
-- 0x75, 0x08, (byte) 0x95, 0x02, (byte) 0x81, 0x06,
-- 0x09, 0x38, 0x15, (byte) 0x81, 0x25, 0x7F, 0x75, 0x08, (byte) 0x95, 0x01,
-- (byte) 0x81, 0x06,
-- // Consumer-page AC Pan provides horizontal wheel scrolling.
-- 0x05, 0x0C, 0x0A, 0x38, 0x02, 0x15, (byte) 0x81, 0x25, 0x7F,
-- 0x75, 0x08, (byte) 0x95, 0x01, (byte) 0x81, 0x06,
-- (byte) 0xC0, (byte) 0xC0
-+ 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x38,
-+ 0x15, (byte) 0x81, 0x25, 0x7F,
-+ 0x75, 0x08, (byte) 0x95, 0x03, (byte) 0x81, 0x06,
-+ (byte) 0xC0, (byte) 0xC0,
- };
-
-- private final OutputStream uhid;
-+ private final UhidChannel channel;
- private byte buttonState = 0;
-
- public MouseDevice() throws IOException {
-- this(initializeOutput(new FileOutputStream("/dev/uhid")));
-+ this(UhidChannel.openDevice("/dev/uhid"));
- }
-
-- MouseDevice(OutputStream uhid) {
-- this.uhid = uhid;
-+ MouseDevice(OutputStream output) throws IOException {
-+ this(UhidChannel.forTesting(output));
- }
-
-- static OutputStream initializeOutput(OutputStream uhid) throws IOException {
-- try {
-- writeCreate2(uhid, "InputLeaf Mouse", MOUSE_DESCRIPTOR);
-- return uhid;
-- } catch (IOException createFailure) {
-- try {
-- uhid.close();
-- } catch (IOException closeFailure) {
-- createFailure.addSuppressed(closeFailure);
-- }
-- throw createFailure;
-- }
-+ MouseDevice(UhidChannel channel) throws IOException {
-+ this.channel = channel;
-+ channel.createDevice("InputLeaf Mouse", MOUSE_DESCRIPTOR);
-+ }
-+
-+ static OutputStream initializeOutput(OutputStream output) throws IOException {
-+ new MouseDevice(UhidChannel.forTesting(output));
-+ return output;
- }
-
- public void move(int dx, int dy) throws IOException {
-- sendReport(buttonState, clamp(dx), clamp(dy), (byte) 0, (byte) 0);
-+ sendReport(buttonState, clamp(dx), clamp(dy), (byte) 0);
- }
-
-- // button: 1-indexed (1=left, 2=right, 3=middle) per Input-Leap protocol
- public void buttonDown(int button) throws IOException {
- validateButton(button);
- buttonState |= (byte) (1 << (button - 1));
-- sendReport(buttonState, (byte) 0, (byte) 0, (byte) 0, (byte) 0);
-+ sendReport(buttonState, (byte) 0, (byte) 0, (byte) 0);
- }
-
- public void buttonUp(int button) throws IOException {
- validateButton(button);
- buttonState &= (byte) ~(1 << (button - 1));
-- sendReport(buttonState, (byte) 0, (byte) 0, (byte) 0, (byte) 0);
-+ sendReport(buttonState, (byte) 0, (byte) 0, (byte) 0);
-+ }
-+
-+ public void wheel(int deltaX, int deltaY) throws IOException {
-+ sendReport(buttonState, (byte) 0, (byte) 0, clamp(deltaY));
- }
-
- private void validateButton(int button) {
-@@ -77,39 +68,16 @@ public class MouseDevice implements Closeable {
- }
- }
-
-- public void wheel(int deltaX, int deltaY) throws IOException {
-- sendReport(buttonState, (byte) 0, (byte) 0, clamp(deltaY), clamp(deltaX));
-- }
--
-- private byte clamp(int value) {
-+ private static byte clamp(int value) {
- return (byte) Math.max(-127, Math.min(127, value));
- }
-
-- private void sendReport(byte buttons, byte x, byte y, byte wheel, byte horizontalPan) throws IOException {
-- byte[] report = {buttons, x, y, wheel, horizontalPan};
-- byte[] pkt = new byte[4 + 4 + 4096];
-- writeInt(pkt, 0, UHID_INPUT2);
-- writeInt(pkt, 4, report.length);
-- System.arraycopy(report, 0, pkt, 8, report.length);
-- uhid.write(pkt);
-+ private void sendReport(byte buttons, byte dx, byte dy, byte wheel) throws IOException {
-+ channel.sendReport(new byte[] {buttons, dx, dy, wheel});
- }
-
-- private static void writeCreate2(OutputStream uhid, String name, byte[] descriptor) throws IOException {
-- byte[] pkt = new byte[4 + 4 + 128 + 4 + 4096 + 16];
-- writeInt(pkt, 0, UHID_CREATE2);
-- byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
-- System.arraycopy(nameBytes, 0, pkt, 4, Math.min(nameBytes.length, 127));
-- writeInt(pkt, 132, descriptor.length);
-- System.arraycopy(descriptor, 0, pkt, 136, descriptor.length);
-- uhid.write(pkt);
-+ @Override
-+ public void close() throws IOException {
-+ channel.close();
- }
--
-- private static void writeInt(byte[] buf, int offset, int value) {
-- buf[offset] = (byte) (value & 0xFF);
-- buf[offset + 1] = (byte) ((value >> 8) & 0xFF);
-- buf[offset + 2] = (byte) ((value >> 16) & 0xFF);
-- buf[offset + 3] = (byte) ((value >> 24) & 0xFF);
-- }
--
-- @Override public void close() throws IOException { uhid.close(); }
- }
-diff --git a/uhid-server/src/test/java/com/inputleaf/uhid/MouseDeviceTest.java b/uhid-server/src/test/java/com/inputleaf/uhid/MouseDeviceTest.java
-index 35ace6c..e803132 100644
---- a/uhid-server/src/test/java/com/inputleaf/uhid/MouseDeviceTest.java
-+++ b/uhid-server/src/test/java/com/inputleaf/uhid/MouseDeviceTest.java
-@@ -5,122 +5,48 @@ import static org.junit.Assert.assertThrows;
-
- import java.io.ByteArrayOutputStream;
- import java.io.IOException;
--import java.io.OutputStream;
- import java.nio.charset.StandardCharsets;
- import org.junit.Test;
-
- public class MouseDeviceTest {
-- private static final int PACKET_SIZE = 4 + 4 + 4096;
-- private static final int CREATE_PACKET_SIZE = 4 + 4 + 128 + 4 + 4096 + 16;
-+ private static final int REPORT_SIZE = 4;
-
- private byte[] reportAt(ByteArrayOutputStream output, int index) {
- byte[] bytes = output.toByteArray();
-- int packetOffset = index * PACKET_SIZE;
-- assertThat(littleEndianInt(bytes, packetOffset)).isEqualTo(12);
-- assertThat(littleEndianInt(bytes, packetOffset + 4)).isEqualTo(5);
-- byte[] report = new byte[5];
-- System.arraycopy(bytes, packetOffset + 8, report, 0, report.length);
-+ int packetOffset = UhidProtocol.CREATE2_PACKET_SIZE + index * UhidProtocol.INPUT2_PACKET_SIZE;
-+ assertThat(UhidProtocol.littleEndianInt(bytes, packetOffset)).isEqualTo(UhidProtocol.UHID_INPUT2);
-+ int length = bytes[packetOffset + 4] | ((bytes[packetOffset + 5] & 0xFF) << 8);
-+ assertThat(length).isEqualTo(REPORT_SIZE);
-+ byte[] report = new byte[REPORT_SIZE];
-+ System.arraycopy(bytes, packetOffset + 6, report, 0, report.length);
- return report;
- }
-
-- private int littleEndianInt(byte[] bytes, int offset) {
-- return (bytes[offset] & 0xFF)
-- | ((bytes[offset + 1] & 0xFF) << 8)
-- | ((bytes[offset + 2] & 0xFF) << 16)
-- | ((bytes[offset + 3] & 0xFF) << 24);
-- }
--
- @Test public void writesCreatePacketWithMouseNameAndDescriptor() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
--
- assertThat(MouseDevice.initializeOutput(output)).isSameInstanceAs(output);
--
- byte[] packet = output.toByteArray();
-- assertThat(packet).hasLength(CREATE_PACKET_SIZE);
-- assertThat(littleEndianInt(packet, 0)).isEqualTo(11);
-+ assertThat(packet).hasLength(UhidProtocol.CREATE2_PACKET_SIZE);
-+ assertThat(UhidProtocol.littleEndianInt(packet, 0)).isEqualTo(UhidProtocol.UHID_CREATE2);
- assertThat(new String(packet, 4, "InputLeaf Mouse".length(), StandardCharsets.UTF_8))
- .isEqualTo("InputLeaf Mouse");
-- int descriptorLength = littleEndianInt(packet, 132);
-- assertThat(descriptorLength).isGreaterThan(0);
-- byte[] descriptor = new byte[descriptorLength];
-- System.arraycopy(packet, 136, descriptor, 0, descriptorLength);
-- assertThat(containsSequence(descriptor, new byte[] {0x05, 0x0C, 0x0A, 0x38, 0x02})).isTrue();
-- }
--
-- @Test public void closesOutputWhenCreatePacketCannotBeWritten() {
-- FailingOutputStream output = new FailingOutputStream();
--
-- assertThrows(IOException.class, () -> MouseDevice.initializeOutput(output));
--
-- assertThat(output.closed).isTrue();
- }
-
-- @Test public void retainsAllSupportedButtonStatesAcrossMovementAndRelease() throws Exception {
-+ @Test public void moveAndButtonsEmitRelativeReports() throws Exception {
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- MouseDevice mouse = new MouseDevice(output);
--
- mouse.buttonDown(1);
-- mouse.buttonDown(2);
-- mouse.buttonDown(3);
- mouse.move(5, -3);
-- mouse.buttonUp(2);
- mouse.buttonUp(1);
-- mouse.buttonUp(3);
--
-- assertThat(reportAt(output, 0)).isEqualTo(new byte[] {1, 0, 0, 0, 0});
-- assertThat(reportAt(output, 1)).isEqualTo(new byte[] {3, 0, 0, 0, 0});
-- assertThat(reportAt(output, 2)).isEqualTo(new byte[] {7, 0, 0, 0, 0});
-- assertThat(reportAt(output, 3)).isEqualTo(new byte[] {7, 5, -3, 0, 0});
-- assertThat(reportAt(output, 4)).isEqualTo(new byte[] {5, 0, 0, 0, 0});
-- assertThat(reportAt(output, 5)).isEqualTo(new byte[] {4, 0, 0, 0, 0});
-- assertThat(reportAt(output, 6)).isEqualTo(new byte[] {0, 0, 0, 0, 0});
-+ assertThat(reportAt(output, 0)[0]).isEqualTo((byte) 1);
-+ assertThat(reportAt(output, 1)[1]).isEqualTo((byte) 5);
-+ assertThat(reportAt(output, 1)[2]).isEqualTo((byte) -3);
-+ assertThat(reportAt(output, 2)[0]).isEqualTo((byte) 0);
- }
-
- @Test public void rejectsUnsupportedMouseButtons() throws Exception {
- MouseDevice mouse = new MouseDevice(new ByteArrayOutputStream());
--
- assertThrows(IllegalArgumentException.class, () -> mouse.buttonDown(0));
-- assertThrows(IllegalArgumentException.class, () -> mouse.buttonUp(0));
- assertThrows(IllegalArgumentException.class, () -> mouse.buttonDown(4));
-- assertThrows(IllegalArgumentException.class, () -> mouse.buttonUp(4));
-- }
--
-- @Test public void clampsMovementAndBothWheelAxesToHidReportRange() throws Exception {
-- ByteArrayOutputStream output = new ByteArrayOutputStream();
-- MouseDevice mouse = new MouseDevice(output);
--
-- mouse.move(1000, -1000);
-- mouse.wheel(1000, -1000);
-- mouse.wheel(-1000, 1000);
--
-- assertThat(reportAt(output, 0)).isEqualTo(new byte[] {0, 127, -127, 0, 0});
-- assertThat(reportAt(output, 1)).isEqualTo(new byte[] {0, 0, 0, -127, 127});
-- assertThat(reportAt(output, 2)).isEqualTo(new byte[] {0, 0, 0, 127, -127});
-- }
--
-- private boolean containsSequence(byte[] bytes, byte[] sequence) {
-- for (int offset = 0; offset <= bytes.length - sequence.length; offset++) {
-- boolean matches = true;
-- for (int index = 0; index < sequence.length; index++) {
-- if (bytes[offset + index] != sequence[index]) {
-- matches = false;
-- break;
-- }
-- }
-- if (matches) return true;
-- }
-- return false;
-- }
--
-- private static class FailingOutputStream extends OutputStream {
-- boolean closed;
--
-- @Override public void write(int value) throws IOException {
-- throw new IOException("write failed");
-- }
--
-- @Override public void close() {
-- closed = true;
-- }
- }
- }
-diff --git a/uhid-server/src/test/java/com/inputleaf/uhid/UhidServerTest.java b/uhid-server/src/test/java/com/inputleaf/uhid/UhidServerTest.java
-index 0f8136a..6d5bf1b 100644
---- a/uhid-server/src/test/java/com/inputleaf/uhid/UhidServerTest.java
-+++ b/uhid-server/src/test/java/com/inputleaf/uhid/UhidServerTest.java
-@@ -90,7 +90,7 @@ public class UhidServerTest {
- assertThat(mouseOutput.closed).isTrue();
- }
-
-- @Test public void attemptsToCloseMouseWhenKeyboardCloseFails() {
-+ @Test public void attemptsToCloseMouseWhenKeyboardCloseFails() throws Exception {
- TrackingOutputStream keyboardOutput = new TrackingOutputStream("keyboard", true);
- TrackingOutputStream mouseOutput = new TrackingOutputStream("mouse", false);
- UhidServer server = serverWith(keyboardOutput, mouseOutput);
-@@ -103,7 +103,7 @@ public class UhidServerTest {
- assertThat(failure.getSuppressed()).isEmpty();
- }
-
-- @Test public void reportsMouseCloseFailureAfterClosingKeyboard() {
-+ @Test public void reportsMouseCloseFailureAfterClosingKeyboard() throws Exception {
- TrackingOutputStream keyboardOutput = new TrackingOutputStream("keyboard", false);
- TrackingOutputStream mouseOutput = new TrackingOutputStream("mouse", true);
- UhidServer server = serverWith(keyboardOutput, mouseOutput);
-@@ -115,7 +115,7 @@ public class UhidServerTest {
- assertThat(failure).hasMessageThat().isEqualTo("mouse close failed");
- }
-
-- @Test public void preservesBothDeviceCloseFailures() {
-+ @Test public void preservesBothDeviceCloseFailures() throws Exception {
- TrackingOutputStream keyboardOutput = new TrackingOutputStream("keyboard", true);
- TrackingOutputStream mouseOutput = new TrackingOutputStream("mouse", true);
- UhidServer server = serverWith(keyboardOutput, mouseOutput);
-@@ -150,7 +150,7 @@ public class UhidServerTest {
- assertThat(failure.getSuppressed()[0]).hasMessageThat().isEqualTo("keyboard close failed");
- }
-
-- private UhidServer serverWith(OutputStream keyboardOutput, OutputStream mouseOutput) {
-+ private UhidServer serverWith(OutputStream keyboardOutput, OutputStream mouseOutput) throws IOException {
- return new UhidServer(new KeyboardDevice(keyboardOutput), new MouseDevice(mouseOutput));
- }
-