From 5445fbd9d003a59d7df169b48e8af27dde60a271 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:59:21 +0530 Subject: [PATCH 01/17] chore: drop unused UHID sidecar and one-caller wrappers. The DEX was packaged with no app launcher; keep HID work on experiment/uhid-system-cursor instead of shipping a second unused stack. Co-authored-by: Cursor --- .github/workflows/ci.yml | 6 +- app/build.gradle.kts | 15 - app/src/main/AndroidManifest.xml | 6 +- .../network/ConnectionTransportPolicy.kt | 26 + .../android/network/InputLeapConnection.kt | 3 +- .../android/network/TransportPolicy.kt | 35 - .../android/network/TransportProber.kt | 39 +- .../inputleaf/android/protocol/KeysymTable.kt | 76 -- .../android/service/ConnectionService.kt | 16 +- .../android/service/RetryDelayCalculator.kt | 10 - .../service/ShizukuActiveSessionPolicy.kt | 12 - .../com/inputleaf/android/ui/AppNavigation.kt | 13 - .../inputleaf/android/ui/LeafNavigation.kt | 33 +- .../com/inputleaf/android/ui/MainActivity.kt | 2 +- .../com/inputleaf/android/ui/MainViewModel.kt | 3 +- .../inputleaf/android/ui/SettingsScreen.kt | 9 +- .../inputleaf/android/ui/SplashActivity.kt | 108 --- .../ui/components/AnimatedBottomNavigation.kt | 43 - .../ui/components/MaterialToggleSwitch.kt | 75 -- .../android/update/ChangelogProvider.kt | 37 - .../inputleaf/android/update/UpdateService.kt | 33 + .../inputleaf/android/util/DeviceIdentity.kt | 47 +- app/src/main/res/values/themes.xml | 7 - .../android/network/TransportPolicyTest.kt | 24 +- .../android/network/TransportProberTest.kt | 33 - .../android/protocol/KeysymTableTest.kt | 22 - .../service/RetryDelayCalculatorTest.kt | 32 - .../android/update/UpdateServiceTest.kt | 8 +- codecov.yml | 2 - docs/TESTING.md | 33 +- settings.gradle.kts | 2 +- uhid-server/build.gradle.kts | 77 -- .../com/inputleaf/uhid/EventProtocol.java | 25 - .../com/inputleaf/uhid/KeyboardDevice.java | 125 --- .../java/com/inputleaf/uhid/KeysymToHid.java | 38 - .../main/java/com/inputleaf/uhid/Main.java | 13 - .../java/com/inputleaf/uhid/MouseDevice.java | 115 --- .../inputleaf/uhid/UhidEventDispatcher.java | 115 --- .../com/inputleaf/uhid/UhidLocalSockets.java | 63 -- .../java/com/inputleaf/uhid/UhidServer.java | 218 ----- .../com/inputleaf/uhid/EventProtocolTest.java | 26 - .../inputleaf/uhid/KeyboardDeviceTest.java | 124 --- .../com/inputleaf/uhid/KeysymToHidTest.java | 63 -- .../com/inputleaf/uhid/MouseDeviceTest.java | 129 --- .../uhid/UhidEventDispatcherTest.java | 187 ----- .../com/inputleaf/uhid/UhidServerTest.java | 443 ----------- uhid_mouse_backup.patch | 753 ------------------ 47 files changed, 151 insertions(+), 3173 deletions(-) delete mode 100644 app/src/main/java/com/inputleaf/android/network/TransportPolicy.kt delete mode 100644 app/src/main/java/com/inputleaf/android/protocol/KeysymTable.kt delete mode 100644 app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt delete mode 100644 app/src/main/java/com/inputleaf/android/service/ShizukuActiveSessionPolicy.kt delete mode 100644 app/src/main/java/com/inputleaf/android/ui/AppNavigation.kt delete mode 100644 app/src/main/java/com/inputleaf/android/ui/SplashActivity.kt delete mode 100644 app/src/main/java/com/inputleaf/android/ui/components/AnimatedBottomNavigation.kt delete mode 100644 app/src/main/java/com/inputleaf/android/ui/components/MaterialToggleSwitch.kt delete mode 100644 app/src/main/java/com/inputleaf/android/update/ChangelogProvider.kt delete mode 100644 app/src/test/java/com/inputleaf/android/protocol/KeysymTableTest.kt delete mode 100644 app/src/test/java/com/inputleaf/android/service/RetryDelayCalculatorTest.kt delete mode 100644 uhid-server/build.gradle.kts delete mode 100644 uhid-server/src/main/java/com/inputleaf/uhid/EventProtocol.java delete mode 100644 uhid-server/src/main/java/com/inputleaf/uhid/KeyboardDevice.java delete mode 100644 uhid-server/src/main/java/com/inputleaf/uhid/KeysymToHid.java delete mode 100644 uhid-server/src/main/java/com/inputleaf/uhid/Main.java delete mode 100644 uhid-server/src/main/java/com/inputleaf/uhid/MouseDevice.java delete mode 100644 uhid-server/src/main/java/com/inputleaf/uhid/UhidEventDispatcher.java delete mode 100644 uhid-server/src/main/java/com/inputleaf/uhid/UhidLocalSockets.java delete mode 100644 uhid-server/src/main/java/com/inputleaf/uhid/UhidServer.java delete mode 100644 uhid-server/src/test/java/com/inputleaf/uhid/EventProtocolTest.java delete mode 100644 uhid-server/src/test/java/com/inputleaf/uhid/KeyboardDeviceTest.java delete mode 100644 uhid-server/src/test/java/com/inputleaf/uhid/KeysymToHidTest.java delete mode 100644 uhid-server/src/test/java/com/inputleaf/uhid/MouseDeviceTest.java delete mode 100644 uhid-server/src/test/java/com/inputleaf/uhid/UhidEventDispatcherTest.java delete mode 100644 uhid-server/src/test/java/com/inputleaf/uhid/UhidServerTest.java delete mode 100644 uhid_mouse_backup.patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67cacfb5..4e9279ba 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 0000eb1a..23f90f36 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,12 +64,6 @@ android { aidl = true // Enable AIDL for Shizuku IPC } - sourceSets { - getByName("main").assets.srcDir( - project(":uhid-server").layout.buildDirectory.dir("generated/assets/uhid") - ) - } - packaging { jniLibs { useLegacyPackaging = true @@ -97,14 +91,6 @@ android { } } -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("androidx.compose:compose-bom:2024.09.00") implementation(composeBom) @@ -117,7 +103,6 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.datastore:datastore-preferences:1.0.0") - implementation("com.jaredrummler:android-device-names:2.1.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") // Shizuku for privileged input injection without root diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dfce3e41..bac6662d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,17 +16,13 @@ android:label="Input Leaf" android:theme="@style/Theme.InputLeaf"> - = 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..6c5da9dd 100644 --- a/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt +++ b/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt @@ -101,8 +101,7 @@ class InputLeapConnection( ) { listOf(ServerTransport.TLS) } else { - TransportPolicy.order( - policy = transportPolicy, + transportPolicy.order( preferredTransport = preferredTransport, detectedMode = detectedMode, ) 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/network/TransportProber.kt b/app/src/main/java/com/inputleaf/android/network/TransportProber.kt index 13e8cf79..1ff0aecc 100644 --- a/app/src/main/java/com/inputleaf/android/network/TransportProber.kt +++ b/app/src/main/java/com/inputleaf/android/network/TransportProber.kt @@ -37,31 +37,23 @@ object TransportProber { if (tlsResult != TlsProbeResult.Failed) { plain.cancel() } - securityModeForProbe( - tlsResult, - tlsResult == TlsProbeResult.Failed && plain.await(), - ) + when (tlsResult) { + TlsProbeResult.Success -> ServerSecurityMode.TLS + TlsProbeResult.RequiresClientCert -> ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED + TlsProbeResult.PlainServer -> ServerSecurityMode.PLAIN + TlsProbeResult.Failed -> + if (plain.await()) ServerSecurityMode.PLAIN else ServerSecurityMode.TLS + } } } - internal enum class TlsProbeResult { + private enum class TlsProbeResult { Success, RequiresClientCert, PlainServer, Failed, } - internal fun securityModeForProbe( - tlsResult: TlsProbeResult, - plainHello: Boolean, - ): ServerSecurityMode = when (tlsResult) { - TlsProbeResult.Success -> ServerSecurityMode.TLS - TlsProbeResult.RequiresClientCert -> ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED - TlsProbeResult.PlainServer -> ServerSecurityMode.PLAIN - TlsProbeResult.Failed -> - if (plainHello) ServerSecurityMode.PLAIN else ServerSecurityMode.TLS - } - private fun probeTls(host: String, port: Int): TlsProbeResult = try { val sslContext = TlsFingerprintManager.buildCapturingSSLContext { } val sslSocket = sslContext.socketFactory.createSocket() as SSLSocket @@ -72,15 +64,12 @@ object TransportProber { TlsProbeResult.Success } } catch (error: Exception) { - classifyTlsProbeError(error) - } - - internal fun classifyTlsProbeError(error: Exception): TlsProbeResult = when { - InputLeapConnection.isPlainServerTlsError(error) -> - TlsProbeResult.PlainServer - InputLeapConnection.isClientCertificateRequired(error) || isTlsHandshake(error) -> - TlsProbeResult.RequiresClientCert - else -> TlsProbeResult.Failed + when { + InputLeapConnection.isPlainServerTlsError(error) -> TlsProbeResult.PlainServer + InputLeapConnection.isClientCertificateRequired(error) || isTlsHandshake(error) -> + TlsProbeResult.RequiresClientCert + else -> TlsProbeResult.Failed + } } private fun isTlsHandshake(error: Exception): Boolean = 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..483a1c6a 100644 --- a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt +++ b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.launch private const val TAG = "ConnectionService" private const val KEEPALIVE_POLL_MS = 5_000L +private val RETRY_DELAYS_MS = longArrayOf(1_000L, 2_000L, 5_000L, 10_000L, 30_000L) class ConnectionService : Service() { @@ -422,7 +423,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 = RETRY_DELAYS_MS[retryAttempt.coerceIn(0, RETRY_DELAYS_MS.lastIndex)] + retryAttempt++ retryJob = scope.launch { delay(delayMs) if (userInitiatedDisconnect || generation != connectGeneration) return@launch @@ -604,3 +606,15 @@ class ConnectionService : Service() { super.onDestroy() } } + +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/service/RetryDelayCalculator.kt b/app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt deleted file mode 100644 index f58a7422..00000000 --- a/app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.inputleaf.android.service - -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)] - } -} 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/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..8d8ad882 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") @@ -234,11 +244,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)) { 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..9a99a854 100644 --- a/app/src/main/java/com/inputleaf/android/ui/MainActivity.kt +++ b/app/src/main/java/com/inputleaf/android/ui/MainActivity.kt @@ -106,7 +106,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/MainViewModel.kt b/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt index c9a92ad5..97e76e41 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 @@ -416,7 +415,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) 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..c316f479 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 @@ -151,9 +150,9 @@ fun SettingsScreen( icon = Icons.Rounded.Build, title = "Auto-connect on launch", trailingContent = { - MaterialToggleSwitch( + Switch( checked = autoConnect, - onCheckedChange = onAutoConnectChange + onCheckedChange = onAutoConnectChange, ) } ) @@ -247,10 +246,10 @@ fun SettingsScreen( title = "Show cursor overlay", subtitle = if (canDrawOverlays) "Display cursor when active" else "Grant permission first", trailingContent = { - MaterialToggleSwitch( + Switch( checked = showCursor, onCheckedChange = onShowCursorChange, - enabled = canDrawOverlays + enabled = canDrawOverlays, ) } ) 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/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/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..e6934e1b 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,33 @@ 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.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) 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/network/TransportPolicyTest.kt b/app/src/test/java/com/inputleaf/android/network/TransportPolicyTest.kt index 20d3a7b7..8d973195 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,8 +14,7 @@ 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) @@ -24,14 +22,12 @@ class TransportPolicyTest { @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 +35,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 +43,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/network/TransportProberTest.kt b/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt index 5c95ea76..6b233e90 100644 --- a/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt +++ b/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt @@ -80,39 +80,6 @@ class TransportProberTest { } } - @Test fun `classifies client-certificate and generic TLS handshake errors`() { - assertThat( - TransportProber.classifyTlsProbeError(SSLHandshakeException("certificate required")), - ).isEqualTo(TransportProber.TlsProbeResult.RequiresClientCert) - assertThat( - TransportProber.classifyTlsProbeError(SSLException("handshake_failure")), - ).isEqualTo(TransportProber.TlsProbeResult.RequiresClientCert) - assertThat( - TransportProber.classifyTlsProbeError(java.net.ConnectException("Connection refused")), - ).isEqualTo(TransportProber.TlsProbeResult.Failed) - } - - @Test fun `maps every TLS probe result onto a security mode`() { - assertThat( - TransportProber.securityModeForProbe(TransportProber.TlsProbeResult.Success, false), - ).isEqualTo(ServerSecurityMode.TLS) - assertThat( - TransportProber.securityModeForProbe( - TransportProber.TlsProbeResult.RequiresClientCert, - false, - ), - ).isEqualTo(ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED) - assertThat( - TransportProber.securityModeForProbe(TransportProber.TlsProbeResult.PlainServer, false), - ).isEqualTo(ServerSecurityMode.PLAIN) - assertThat( - TransportProber.securityModeForProbe(TransportProber.TlsProbeResult.Failed, true), - ).isEqualTo(ServerSecurityMode.PLAIN) - assertThat( - TransportProber.securityModeForProbe(TransportProber.TlsProbeResult.Failed, false), - ).isEqualTo(ServerSecurityMode.TLS) - } - @Test fun `unreachable loopback listener conservatively defaults to TLS`() = runBlocking { val port = ServerSocket(0, 50, InetAddress.getByName(LOOPBACK_HOST)).use { it.localPort } 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/service/RetryDelayCalculatorTest.kt b/app/src/test/java/com/inputleaf/android/service/RetryDelayCalculatorTest.kt deleted file mode 100644 index 5bad6ec2..00000000 --- a/app/src/test/java/com/inputleaf/android/service/RetryDelayCalculatorTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.inputleaf.android.service - -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class RetryDelayCalculatorTest { - - @Test - fun `first attempt has 1 second delay`() { - val delay = RetryDelayCalculator.getDelay(0) - assertThat(delay).isEqualTo(1_000L) - } - - @Test - fun `subsequent attempts scale delays correctly`() { - assertThat(RetryDelayCalculator.getDelay(1)).isEqualTo(2_000L) - assertThat(RetryDelayCalculator.getDelay(2)).isEqualTo(5_000L) - assertThat(RetryDelayCalculator.getDelay(3)).isEqualTo(10_000L) - assertThat(RetryDelayCalculator.getDelay(4)).isEqualTo(30_000L) - } - - @Test - fun `attempts exceeding delays list max out at 30 seconds`() { - assertThat(RetryDelayCalculator.getDelay(5)).isEqualTo(30_000L) - assertThat(RetryDelayCalculator.getDelay(10)).isEqualTo(30_000L) - } - - @Test - fun `negative attempts default to 1 second`() { - assertThat(RetryDelayCalculator.getDelay(-1)).isEqualTo(1_000L) - } -} 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..42673674 100644 --- a/app/src/test/java/com/inputleaf/android/update/UpdateServiceTest.kt +++ b/app/src/test/java/com/inputleaf/android/update/UpdateServiceTest.kt @@ -422,20 +422,20 @@ class UpdateServiceTest { @Test fun changelogProvider_returnsValidHighlights() { - val changelog = ChangelogProvider.getChangelog("1.4.1") + val changelog = UpdateService.getChangelog("1.4.1") assertThat(changelog.versionName).isEqualTo("1.4.1") 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.1") } @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/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/TESTING.md b/docs/TESTING.md index 39eb4536..301d5e0c 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 34 and build tools 34.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 34 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: - `: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 34 emulator that `:app:createDebugCoverageReport` passes and runs the service and onboarding smoke tests added with that job. 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 1d288f58..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("com.google.android:android:4.1.1.4") - - testImplementation("junit:junit:4.13.2") - testImplementation("com.google.truth:truth:1.4.5") -} -// 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/34.0.0/d8" else "" - val androidJar = if (sdkRoot.isNotBlank()) "$sdkRoot/platforms/android-34/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 34.0.0 are required to build the UHID DEX (checked $d8Path)" } - require(File(androidJar).exists()) { "Android platform 34 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)); - } - From ee754f2006e3b9b4491dafa057fb92b9d7455df5 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:49:26 +0530 Subject: [PATCH 02/17] chore: drop unused wizard, resolver, and stale docs. Handshake logs go through Log.* with a JVM-safe wrapper; connection state stays a small class next to the service. Co-authored-by: Cursor --- .../android/inject/InputMethodResolver.kt | 24 -- .../android/network/InputLeapConnection.kt | 60 ++-- .../android/service/ConnectionService.kt | 51 +++ .../android/service/ConnectionStateMachine.kt | 54 --- .../inputleaf/android/ui/AdbSetupWizard.kt | 69 ---- .../com/inputleaf/android/ui/MainViewModel.kt | 16 +- .../android/inject/InputMethodResolverTest.kt | 87 ----- .../network/InputLeapConnectionTest.kt | 7 - docs/DEVELOPMENT_JOURNEY.md | 339 ------------------ docs/UI_REDESIGN.md | 48 --- 10 files changed, 78 insertions(+), 677 deletions(-) delete mode 100644 app/src/main/java/com/inputleaf/android/inject/InputMethodResolver.kt delete mode 100644 app/src/main/java/com/inputleaf/android/service/ConnectionStateMachine.kt delete mode 100644 app/src/main/java/com/inputleaf/android/ui/AdbSetupWizard.kt delete mode 100644 app/src/test/java/com/inputleaf/android/inject/InputMethodResolverTest.kt delete mode 100644 docs/DEVELOPMENT_JOURNEY.md delete mode 100644 docs/UI_REDESIGN.md 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/network/InputLeapConnection.kt b/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt index 6c5da9dd..3aeabf5d 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. @@ -141,7 +123,7 @@ class InputLeapConnection( } } 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 } } @@ -158,7 +140,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), ) @@ -213,7 +195,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, @@ -221,7 +203,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, @@ -229,7 +211,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, @@ -237,7 +219,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), ) @@ -302,7 +284,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 @@ -318,7 +300,7 @@ class InputLeapConnection( protocol = negotiatedProtocol, ) helloSent = true - logger.debug( + logD( "Handshake sent ${negotiatedProtocol.magic} client hello " + "as $screenName using 1.$negotiatedMinor", ) @@ -327,7 +309,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() @@ -341,7 +323,7 @@ class InputLeapConnection( } } is InputLeapEvent.Incompatible -> { - logger.error("Server rejected handshake: $event") + logE("Server rejected handshake: $event") close() return ConnectResult.Failed( ConnectResult.FailureReason.INCOMPATIBLE, @@ -349,7 +331,7 @@ class InputLeapConnection( ) } is InputLeapEvent.Busy -> { - logger.error("Server rejected handshake: busy") + logE("Server rejected handshake: busy") close() return ConnectResult.Failed(ConnectResult.FailureReason.BUSY) } @@ -358,12 +340,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) } @@ -372,11 +354,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, @@ -392,14 +374,14 @@ class InputLeapConnection( event !is InputLeapEvent.MouseMoveRel && event !is InputLeapEvent.KeepAlive ) { - logger.debug("Read event: $event") + logD("Read event: $event") } _events.emit(event) } } 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__")) } } 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 483a1c6a..7ac32298 100644 --- a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt +++ b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt @@ -28,6 +28,7 @@ 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 @@ -618,3 +619,53 @@ 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/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/MainViewModel.kt b/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt index 97e76e41..8a1d7c36 100644 --- a/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt @@ -478,16 +478,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 } } 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/network/InputLeapConnectionTest.kt b/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt index c4831bc8..85baf575 100644 --- a/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt +++ b/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt @@ -811,12 +811,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 +826,6 @@ class InputLeapConnectionTest { transportPolicy = transportPolicy, clientCertificate = clientCertificate, onCertificate = onCertificate, - logger = NoOpLogger, ) private suspend fun InputLeapConnection.useConnection( 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/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] From 7cfb0cac91ccce0dfae834e9281ff4eac20bd385 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:33:28 +0530 Subject: [PATCH 03/17] [UPD]: Release v1.4.2 with UHID attachment, cursor overlay fixes, and rich Markdown changelog - Native UHID keyboard and mouse attach on Enter and disconnect on Leave - Live mid-session DINF screen size updates and native pointer compensation/edge anchoring - Server cards now reflect real-time connecting states - In-house lightweight Markdown parser and renderer for update changelogs - Fixed accessibility overlay cursor visibility without SYSTEM_ALERT_WINDOW-only gate - Fixed first-enter freeze and Shizuku bind/reconnection lifecycle - Fixed raw Markdown syntax rendering in the Update Available dialog - Added MarkdownContent component and 14 unit test cases in MarkdownParserTest --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 1 + .../android/shizuku/IInputInjector.aidl | 39 ++ .../inject/AccessibilityInputInjector.kt | 88 +++- .../inject/AccessibilityInputService.kt | 90 ++-- .../inputleaf/android/inject/InputInjector.kt | 14 + .../android/inject/NativePointerState.kt | 21 + .../inputleaf/android/model/InputLeapEvent.kt | 1 + .../android/network/InputLeapConnection.kt | 10 +- .../android/service/ConnectionService.kt | 220 +++++++- .../android/service/CursorOverlayPolicy.kt | 25 + .../android/service/CursorOverlayService.kt | 110 ++-- .../service/HidAttachmentController.kt | 42 ++ .../inputleaf/android/service/PointerSpeed.kt | 18 + .../android/shizuku/InputInjectorService.kt | 105 +++- .../android/shizuku/ShizukuInputInjector.kt | 470 +++++++++++++---- .../android/shizuku/uhid/EvdevToHid.kt | 108 ++++ .../android/shizuku/uhid/HidKeyboard.kt | 86 +++ .../android/shizuku/uhid/HidMouse.kt | 84 +++ .../android/shizuku/uhid/HidMouseState.kt | 314 +++++++++++ .../android/shizuku/uhid/HidReportSink.kt | 7 + .../android/shizuku/uhid/HidSysfsPresence.kt | 29 + .../android/shizuku/uhid/MouseEdgeAnchor.kt | 272 ++++++++++ .../shizuku/uhid/MousePointerCompensation.kt | 178 +++++++ .../android/shizuku/uhid/UhidChannel.kt | 224 ++++++++ .../android/shizuku/uhid/UhidProtocol.kt | 150 ++++++ .../inputleaf/android/ui/LeafNavigation.kt | 3 + .../com/inputleaf/android/ui/MainActivity.kt | 5 - .../com/inputleaf/android/ui/MainScreen.kt | 10 +- .../com/inputleaf/android/ui/MainViewModel.kt | 23 +- .../inputleaf/android/ui/ServerListItem.kt | 39 +- .../inputleaf/android/ui/ServerListScreen.kt | 17 +- .../inputleaf/android/ui/SettingsScreen.kt | 20 +- .../android/ui/components/MarkdownContent.kt | 391 ++++++++++++++ .../android/ui/components/UpdateDialogs.kt | 9 +- .../inputleaf/android/update/UpdateService.kt | 12 +- .../android/protocol/ProtocolWriterTest.kt | 12 + .../service/CursorOverlayPolicyTest.kt | 101 ++++ .../service/HidAttachmentControllerTest.kt | 69 +++ .../android/service/PointerSpeedTest.kt | 14 + .../android/shizuku/uhid/HidKeyboardTest.kt | 188 +++++++ .../android/shizuku/uhid/HidMouseStateTest.kt | 279 ++++++++++ .../android/shizuku/uhid/HidMouseTest.kt | 85 +++ .../shizuku/uhid/HidSysfsPresenceTest.kt | 36 ++ .../shizuku/uhid/MouseEdgeAnchorTest.kt | 494 ++++++++++++++++++ .../uhid/MousePointerCompensationTest.kt | 133 +++++ .../shizuku/uhid/UhidChannelReadinessTest.kt | 83 +++ .../android/shizuku/uhid/UhidProtocolTest.kt | 130 +++++ .../android/ui/MarkdownParserTest.kt | 206 ++++++++ .../update/UpdateServiceContextJvmTest.kt | 2 +- .../android/update/UpdateServiceTest.kt | 10 +- ...essibilityInputInjectorPointerSpeedTest.kt | 38 ++ 52 files changed, 4834 insertions(+), 285 deletions(-) create mode 100644 app/src/main/java/com/inputleaf/android/inject/NativePointerState.kt create mode 100644 app/src/main/java/com/inputleaf/android/service/CursorOverlayPolicy.kt create mode 100644 app/src/main/java/com/inputleaf/android/service/HidAttachmentController.kt create mode 100644 app/src/main/java/com/inputleaf/android/service/PointerSpeed.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/EvdevToHid.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/HidKeyboard.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouse.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouseState.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/HidReportSink.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresence.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/MouseEdgeAnchor.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensation.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidChannel.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidProtocol.kt create mode 100644 app/src/main/java/com/inputleaf/android/ui/components/MarkdownContent.kt create mode 100644 app/src/test/java/com/inputleaf/android/service/CursorOverlayPolicyTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/service/HidAttachmentControllerTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/service/PointerSpeedTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/HidKeyboardTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseStateTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresenceTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/MouseEdgeAnchorTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensationTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidChannelReadinessTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidProtocolTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/util/AccessibilityInputInjectorPointerSpeedTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 23f90f36..be8f63ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,8 +21,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" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bac6662d..5ac17aed 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ android:theme="@style/Theme.InputLeaf"> 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 6d5d8f90..dff6e175 100644 --- a/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl +++ b/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl @@ -42,6 +42,45 @@ interface IInputInjector { * @return true if injection succeeded */ boolean injectText(String text); + + /** + * Register a real HID keyboard on /dev/uhid so Android treats keys as hardware input. + * Idempotent while already open. + */ + boolean openVirtualKeyboard(); + + /** + * Destroy the HID keyboard. Android then sees the physical keyboard disconnect. + */ + void closeVirtualKeyboard(); + + /** + * Send a key through the HID keyboard. + * @return false when unmapped or the keyboard is not open, so the caller can fall back + */ + boolean injectHidKey(int evdevCode, boolean isDown); + + /** + * Release every held HID key so a leave/disconnect cannot stick a key down. + */ + void releaseHidKeys(); + + /** + * Register a real HID mouse on /dev/uhid so Android treats motion as hardware input. + * Idempotent while already open. + */ + boolean openVirtualMouse(); + + /** + * Destroy the HID mouse. Android then sees the physical mouse disconnect. + */ + void closeVirtualMouse(); + + /** + * Send a relative motion report through the HID mouse. + * @return false when the mouse is not open, so the caller can fall back + */ + boolean injectHidMouse(int dx, int dy, int buttons, int wheel); /** * Destroy the service and release resources. diff --git a/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputInjector.kt b/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputInjector.kt index eb4f730f..dd568fe8 100644 --- a/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputInjector.kt +++ b/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputInjector.kt @@ -5,22 +5,34 @@ import android.provider.Settings import android.util.Log import android.view.KeyEvent import com.inputleaf.android.model.InputLeapEvent +import com.inputleaf.android.shizuku.ShizukuInputInjector import kotlinx.coroutines.delay private const val TAG = "AccessibilityInputInjector" class AccessibilityInputInjector( private val context: Context, - private val screenWidth: Int, - private val screenHeight: Int + private var screenWidth: Int, + private var screenHeight: Int ) : InputInjector { override val name: String = "Accessibility Service (no extra app)" + override fun updateScreenSize(width: Int, height: Int) { + screenWidth = width + screenHeight = height + hidKeyboard.updateScreenSize(width, height) + } + + override fun updatePointerSpeed(speed: Int) { + hidKeyboard.updatePointerSpeed(speed) + } + private var mouseX = 0f private var mouseY = 0f private var metaState = 0 private val scanCodeDecoder = ProtocolScanCodeDecoder() + private val hidKeyboard = ShizukuInputInjector(screenWidth, screenHeight) override fun isAvailable(): Boolean { return try { @@ -49,6 +61,9 @@ class AccessibilityInputInjector( val connected = AccessibilityInputService.isServiceRunning() if (connected) { Log.d(TAG, "Accessibility service connected successfully") + if (hidKeyboard.isAvailable()) { + hidKeyboard.connect() + } } else { Log.e(TAG, "Accessibility service connection timeout") } @@ -57,6 +72,33 @@ class AccessibilityInputInjector( override fun disconnect() { Log.d(TAG, "disconnect called") + hidKeyboard.disconnect() + } + + override fun setHidKeyboardAttached(attached: Boolean) { + hidKeyboard.setHidKeyboardAttached(attached) + } + + override fun setHidMouseAttached(attached: Boolean) { + hidKeyboard.setHidMouseAttached(attached) + } + + override fun onHidMouseEnter(x: Int, y: Int) { + hidKeyboard.onHidMouseEnter(x, y) + } + + override fun onHidMouseLeave() { + hidKeyboard.onHidMouseLeave() + } + + override fun usesNativePointer(): Boolean = hidKeyboard.usesNativePointer() + + override fun nativePointerState() = hidKeyboard.nativePointerState() + + override fun expectsNativePointer() = hidKeyboard.expectsNativePointer() + + override fun setOnNativePointerStateChanged(listener: ((NativePointerState) -> 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/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/InputLeapConnection.kt b/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt index 3aeabf5d..a4b6c3f2 100644 --- a/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt +++ b/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt @@ -376,7 +376,12 @@ class InputLeapConnection( ) { logD("Read event: $event") } - _events.emit(event) + val mapped = when { + event is InputLeapEvent.Unhandled && event.tag == "CIAK" -> + InputLeapEvent.InfoAck() + else -> event + } + _events.emit(mapped) } } catch (e: CancellationException) { throw e @@ -390,7 +395,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/service/ConnectionService.kt b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt index 7ac32298..f7baa478 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 @@ -35,6 +42,7 @@ import kotlinx.coroutines.launch private const val TAG = "ConnectionService" private const val KEEPALIVE_POLL_MS = 5_000L +private const val LEAVE_DEBOUNCE_MS = 300L private val RETRY_DELAYS_MS = longArrayOf(1_000L, 2_000L, 5_000L, 10_000L, 30_000L) class ConnectionService : Service() { @@ -49,6 +57,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 @@ -63,8 +72,22 @@ class ConnectionService : Service() { private var activeServerIp: String? = null private var activeScreenName: String? = null private var shizukuRecoveryJob: Job? = null + private var leaveDebounceJob: 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() @@ -103,6 +126,7 @@ class ConnectionService : Service() { prefs.showCursor.collect { enabled -> cursorOverlayEnabled = enabled Log.d(TAG, "Cursor overlay enabled changed: $enabled") + applyCursorOverlay() } } @@ -110,7 +134,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) } } @@ -118,12 +143,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 { @@ -148,6 +195,7 @@ class ConnectionService : Service() { } userInitiatedDisconnect = false + infoAckPending = false activeServerIp = serverIp activeScreenName = screenName val generation = ++connectGeneration @@ -300,20 +348,43 @@ class ConnectionService : Service() { if (generation != connectGeneration) return@collect when (event) { is InputLeapEvent.Enter -> { + 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() @@ -322,6 +393,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()) @@ -340,8 +412,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) } @@ -356,26 +432,90 @@ 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 + } + + 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() + setHidKeyboardAttached(false) + setHidMouseAttached(false) + leaveDebounceJob = 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) @@ -383,6 +523,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 @@ -391,14 +532,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) } @@ -412,8 +569,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 } @@ -436,6 +597,7 @@ class ConnectionService : Service() { private fun cancelPendingJobs(keepConnection: Boolean) { retryJob?.cancel() retryJob = null + cancelLeaveDebounce() eventLoopJob?.cancel() eventLoopJob = null connectJob?.cancel() @@ -455,14 +617,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) @@ -474,6 +641,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) @@ -589,6 +778,7 @@ class ConnectionService : Service() { } override fun onDestroy() { + unregisterPointerSpeedObserver() try { Shizuku.removeBinderReceivedListener(shizukuBinderReceivedListener) Shizuku.removeBinderDeadListener(shizukuBinderDeadListener) @@ -600,8 +790,10 @@ 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() 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/shizuku/InputInjectorService.kt b/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt index 90614a41..2334b69c 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,9 @@ 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.UhidChannel /** * Shizuku UserService that runs with shell (ADB) privileges. @@ -23,6 +26,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 +194,101 @@ 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 + + override fun openVirtualKeyboard(): Boolean = synchronized(keyboardLock) { + if (keyboard != null) return true + val channel = UhidChannel.openHandle() ?: 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) { + android.util.Log.i("InputInjectorService", "HID mouse already open (idempotent)") + return true + } + val channel = UhidChannel.openHandle() ?: 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 + mouse = HidMouse(channel) + android.util.Log.i( + "InputInjectorService", + "HID mouse connected in ${android.os.SystemClock.uptimeMillis() - startedAt}ms pid=${android.os.Process.myPid()}", + ) + true + } catch (e: Exception) { + android.util.Log.w("InputInjectorService", "HID mouse create failed", e) + 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 destroy() { - // Nothing to clean up + closeVirtualKeyboard() + closeVirtualMouse() } } 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..ed3c154d 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,49 +19,52 @@ 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 kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import rikka.shizuku.Shizuku private const val TAG = "ShizukuInputInjector" +private const val SERVICE_VERSION = 4 -/** - * 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 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) + @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") @@ -67,7 +72,7 @@ class ShizukuInputInjector( isBound = true connectDeferred?.complete(true) } - + override fun onServiceDisconnected(name: ComponentName?) { Log.d(TAG, "Shizuku service disconnected") notifyDisconnected() @@ -75,31 +80,49 @@ 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() + 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 +131,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 +162,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 +177,308 @@ class ShizukuInputInjector( } service = null isBound = false + hidMouse.resetOnDisconnect() + 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() + 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) + finishPendingSnap(svc) + 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) + Log.i(TAG, "HID mouse attached (newDevice=$newDevice)") + finishPendingSnap(svc) + } 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 + if (hidMouse.attached) { + finishPendingSnap(svc) + } + } + + override fun onHidMouseLeave() { + hidMouse.markLeave() + } + + private fun finishPendingSnap(svc: IInputInjector) { + 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()}", + ) + val now = SystemClock.uptimeMillis() + val plans = MouseEdgeAnchor.planSnap(hidMouse.plannerInput(now), pointerSpeed) + for (plan in plans) { + if (plan.isNoOp) continue + 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 -> + svc.injectHidMouse(0, 0, hidMouse.buttons(), event.yDelta / 120) + else -> false + } + } catch (e: DeadObjectException) { + Log.w(TAG, "Shizuku service binder is dead", e) + notifyDisconnected() + false + } catch (e: Exception) { + handleRemoteException(e, null) + false } } - - /** - * Send an InputLeap event to be injected. - */ + + 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 +487,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 +500,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 +519,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 +531,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 +570,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 +585,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/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..5a688296 --- /dev/null +++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensation.kt @@ -0,0 +1,178 @@ +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) + for (segment in CURVE_SEGMENTS) { + if (speedMmPerS <= segment.maxSpeedMmPerS) { + val base = factor * segment.baseGain + val reciprocal = factor * segment.reciprocal + return if (reciprocal == 0.0) { + base + } else { + base + reciprocal / speedMmPerS + } + } + } + val last = CURVE_SEGMENTS.last() + val base = factor * last.baseGain + val reciprocal = factor * last.reciprocal + return 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..cc32bdaf --- /dev/null +++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidChannel.kt @@ -0,0 +1,224 @@ +package com.inputleaf.android.shizuku.uhid + +import android.util.Log +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.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Timeouts for UHID device readiness. ColorOS does not emit [UhidProtocol.UHID_OPEN]; + * [UhidProtocol.UHID_START] means hid-core created the device and is the wait target. + */ +internal data class UhidReadinessConfig( + val startTimeoutMs: Long = 300L, + val openGraceMs: Long = 80L, + 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, +) : HidReportSink, Closeable { + + private val closed = AtomicBoolean(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 { + output.write(UhidProtocol.input2Packet(report)) + output.flush() + true + } catch (e: Exception) { + Log.w(TAG, "Dropping HID report after write failure", e) + false + } + } + + 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() + output.write(UhidProtocol.create2Packet(name, descriptor, vendor, product, uniq)) + output.flush() + if (wait != null) { + awaitReady(wait, name, uniq) + readiness.compareAndSet(wait, null) + val elapsedMs = TimeUnit.NANOSECONDS.toMillis(readinessConfig.nanoTime() - startedAt) + when { + wait.sawOpen.get() -> + Log.i(TAG, "UHID device '$name' ready (START+OPEN) in ${elapsedMs}ms") + wait.sawStart.get() -> + Log.i(TAG, "UHID device '$name' ready (START) in ${elapsedMs}ms") + else -> + Log.w(TAG, "No UHID_START for '$name' after ${elapsedMs}ms; continuing") + } + } + } + + /** + * Wait for START (kernel hid created). OPEN is best-effort only: ColorOS never + * sends it, so we must not block the full historical 1.5s OPEN timeout. + */ + private fun awaitReady(wait: ReadinessWait, name: String, uniq: String) { + val startDeadline = readinessConfig.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(readinessConfig.startTimeoutMs) + while (readinessConfig.nanoTime() < startDeadline && !wait.sawStart.get()) { + readinessConfig.sleeper(readinessConfig.pollIntervalMs) + } + if (!wait.sawStart.get()) return + + // OPEN and EventHub appearance are best-effort in the same bounded window. + // ColorOS does not emit OPEN; do not wait a second timeout for it. + val readyDeadline = readinessConfig.nanoTime() + + TimeUnit.MILLISECONDS.toNanos( + maxOf(readinessConfig.openGraceMs, readinessConfig.presenceTimeoutMs), + ) + while (readinessConfig.nanoTime() < readyDeadline) { + if (wait.sawOpen.get() || 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 + 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() && !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) { + 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 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 + } + return try { + val channel = UhidChannel( + FileOutputStream(file.fd), + FileInputStream(file.fd), + file, + readinessConfig, + ) + Log.i(TAG, "Opened $path") + channel + } catch (e: Exception) { + Log.w(TAG, "Failed to wrap $path", e) + 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) + } +} 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..d5b916e7 --- /dev/null +++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidProtocol.kt @@ -0,0 +1,150 @@ +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. + */ +internal object UhidProtocol { + + const val UHID_DESTROY = 1 + const val UHID_START = 4 + /** EventHub opened the evdev node — safe to send the first input report. */ + const val UHID_OPEN = 6 + const val UHID_CREATE2 = 11 + const val UHID_INPUT2 = 12 + + 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)) + } + + fun input2Packet(report: ByteArray): ByteArray { + require(report.size <= UHID_DATA_MAX) { "Report too large: ${report.size}" } + val packet = ByteBuffer.allocate(INPUT2_PACKET_SIZE).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(0, UHID_INPUT2) + packet.putShort(4, report.size.toShort()) + System.arraycopy(report, 0, packet.array(), 6, report.size) + return packet.array() + } + + /** + * 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() + } + } + + private fun payloadSize(type: Int): Int = when (type) { + UHID_START, 5, 6, 7 -> 0 + 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/ui/LeafNavigation.kt b/app/src/main/java/com/inputleaf/android/ui/LeafNavigation.kt index 8d8ad882..bbb09191 100644 --- a/app/src/main/java/com/inputleaf/android/ui/LeafNavigation.kt +++ b/app/src/main/java/com/inputleaf/android/ui/LeafNavigation.kt @@ -56,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") @@ -281,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, @@ -291,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 9a99a854..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 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 8a1d7c36..21fdbea8 100644 --- a/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt @@ -93,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 @@ -336,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 -> @@ -492,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 c316f479..052f4697 100644 --- a/app/src/main/java/com/inputleaf/android/ui/SettingsScreen.kt +++ b/app/src/main/java/com/inputleaf/android/ui/SettingsScreen.kt @@ -71,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) } @@ -202,7 +203,7 @@ fun SettingsScreen( padding = 0.dp ) { Column { - if (!canDrawOverlays) { + if (!cursorAvailable) { // Permission warning Row( modifier = Modifier @@ -221,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 ) @@ -244,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 = { 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/components/MarkdownContent.kt b/app/src/main/java/com/inputleaf/android/ui/components/MarkdownContent.kt new file mode 100644 index 00000000..1dd67b00 --- /dev/null +++ b/app/src/main/java/com/inputleaf/android/ui/components/MarkdownContent.kt @@ -0,0 +1,391 @@ +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 + } + + 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(")") + withLink( + LinkAnnotation.Url( + url = url, + styles = TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Medium + ) + ) + ) + ) { + append(label) + } + } + + // Raw URL + token.startsWith("http://") || token.startsWith("https://") -> { + 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/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/UpdateService.kt b/app/src/main/java/com/inputleaf/android/update/UpdateService.kt index e6934e1b..9a4d8538 100644 --- a/app/src/main/java/com/inputleaf/android/update/UpdateService.kt +++ b/app/src/main/java/com/inputleaf/android/update/UpdateService.kt @@ -45,6 +45,14 @@ object UpdateService { "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, @@ -227,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/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/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/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..3ab86c72 --- /dev/null +++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidChannelReadinessTest.kt @@ -0,0 +1,83 @@ +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 kotlin.concurrent.thread +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UhidChannelReadinessTest { + + @Test + fun `START without OPEN becomes ready without the old 1500ms OPEN wait`() { + val kernel = PipedOutputStream() + val input = PipedInputStream(kernel, 8192) + val channel = UhidChannel.forTesting( + ByteArrayOutputStream(), + input, + UhidReadinessConfig( + startTimeoutMs = 400, + openGraceMs = 80, + presenceTimeoutMs = 150, + presence = { _, _ -> true }, + ), + ) + thread(isDaemon = true) { + Thread.sleep(25) + kernel.write(typeWord(UhidProtocol.UHID_START)) + kernel.flush() + } + + val startedAt = System.nanoTime() + channel.createDevice("Input Leaf Keyboard HID", byteArrayOf(1), uniq = "inputleaf-kbd") + val elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + + assertThat(elapsedMs).isLessThan(800) + channel.close() + kernel.close() + } + + @Test + fun `presence probe can complete readiness immediately after START`() { + val kernel = PipedOutputStream() + val input = PipedInputStream(kernel, 8192) + var presenceChecks = 0 + val channel = UhidChannel.forTesting( + ByteArrayOutputStream(), + input, + UhidReadinessConfig( + startTimeoutMs = 400, + openGraceMs = 400, + presenceTimeoutMs = 400, + presence = { name, uniq -> + presenceChecks++ + name == "mouse" && uniq == "inputleaf-mouse" + }, + ), + ) + thread(isDaemon = true) { + Thread.sleep(20) + kernel.write(typeWord(UhidProtocol.UHID_START)) + kernel.flush() + } + + val startedAt = System.nanoTime() + channel.createDevice("mouse", byteArrayOf(1), uniq = "inputleaf-mouse") + val elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + + assertThat(presenceChecks).isGreaterThan(0) + assertThat(elapsedMs).isLessThan(500) + 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..a8969b52 --- /dev/null +++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidProtocolTest.kt @@ -0,0 +1,130 @@ +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 `UHID_OPEN event type is six`() { + assertThat(UhidProtocol.UHID_OPEN).isEqualTo(6) + } + + @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/ui/MarkdownParserTest.kt b/app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt new file mode 100644 index 00000000..add4d159 --- /dev/null +++ b/app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt @@ -0,0 +1,206 @@ +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_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 42673674..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,15 +422,15 @@ class UpdateServiceTest { @Test fun changelogProvider_returnsValidHighlights() { - val changelog = UpdateService.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 = UpdateService.getChangelog("9.9.9") - assertThat(changelog.versionName).isEqualTo("1.4.1") + assertThat(changelog.versionName).isEqualTo("1.4.2") } @Test 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) + } +} From 1fddace8473d382ce14c64b6cd387ca51fa770f2 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:29:32 +0530 Subject: [PATCH 04/17] test: cover patch lines in InputInjector, Connection, Prober, and Event for 100% Codecov --- .../android/inject/InputInjectorTest.kt | 29 +++ .../android/model/InputLeapEventTest.kt | 16 ++ .../network/InputLeapConnectionTest.kt | 50 +++++ .../android/network/TransportPolicyTest.kt | 13 ++ .../android/network/TransportProberTest.kt | 174 +++++------------- 5 files changed, 158 insertions(+), 124 deletions(-) create mode 100644 app/src/test/java/com/inputleaf/android/inject/InputInjectorTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/model/InputLeapEventTest.kt 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/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 85baf575..af0201ce 100644 --- a/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt +++ b/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt @@ -775,6 +775,56 @@ 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() + + 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() + Unit + } + } + private fun isExpectedPlainProbeTermination(failure: Exception): Boolean = failure is EOFException || failure is SocketException || 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 8d973195..86895816 100644 --- a/app/src/test/java/com/inputleaf/android/network/TransportPolicyTest.kt +++ b/app/src/test/java/com/inputleaf/android/network/TransportPolicyTest.kt @@ -20,6 +20,19 @@ class TransportPolicyTest { ).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( ConnectionTransportPolicy.AUTO.order( diff --git a/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt b/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt index 6b233e90..9cee8fef 100644 --- a/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt +++ b/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt @@ -1,162 +1,88 @@ package com.inputleaf.android.network import com.google.common.truth.Truth.assertThat -import com.inputleaf.android.testutil.ClientCertificateTestFixture import com.inputleaf.android.testutil.LOOPBACK_HOST import com.inputleaf.android.testutil.LoopbackServer import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.Test +import java.io.DataInputStream +import java.io.DataOutputStream import java.net.InetAddress import java.net.ServerSocket import java.net.SocketException -import java.security.SecureRandom -import java.security.cert.X509Certificate -import javax.net.ssl.SSLContext import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException -import javax.net.ssl.SSLServerSocket import javax.net.ssl.SSLSocket -import javax.net.ssl.X509TrustManager + +private const val TEST_TIMEOUT_MS = 2_000L class TransportProberTest { - @Test fun `detects a plaintext Barrier listener`() = runBlocking { + @Test + fun `detects a plaintext Barrier listener`() = runBlocking { LoopbackServer(connectionCount = 2) { socket, _ -> - writeFrame(java.io.DataOutputStream(socket.outputStream), helloBody()) + writeFrame(DataOutputStream(socket.outputStream), helloBody()) }.use { server -> - assertThat(TransportProber.detect(LOOPBACK_HOST, server.port)) - .isEqualTo(ServerSecurityMode.PLAIN) + val mode = TransportProber.detect(LOOPBACK_HOST, server.port) + assertThat(mode).isEqualTo(ServerSecurityMode.PLAIN) } } - @Test fun `detects TLS without a client-certificate requirement`() = runBlocking { - val serverMaterial = ClientCertificateTestFixture.material() + @Test + fun `detects a TLS listener`() = runBlocking { + val identity = TestTlsIdentity.create() val completedHandshake = CompletableDeferred() - try { - LoopbackServer( - connectionCount = 2, - serverSocket = tlsServer(serverMaterial, requireClientCert = false), - ) { socket, _ -> - if (completeTlsHandshake(socket as SSLSocket, clientCertificateRequired = false) == - TlsHandshakeOutcome.COMPLETED - ) { - completedHandshake.complete(Unit) - } - }.use { server -> - assertThat(TransportProber.detect(LOOPBACK_HOST, server.port)) - .isEqualTo(ServerSecurityMode.TLS) - withTimeout(TEST_TIMEOUT_MS) { completedHandshake.await() } + TlsLoopbackServer(identity.context, connectionCount = 2) { socket, _ -> + try { + socket.startHandshake() + completedHandshake.complete(Unit) + } catch (_: Exception) { } - } finally { - serverMaterial.clear() + }.use { server -> + val mode = TransportProber.detect(LOOPBACK_HOST, server.port) + assertThat(mode).isEqualTo(ServerSecurityMode.TLS) + withTimeout(TEST_TIMEOUT_MS) { completedHandshake.await() } } } - @Test fun `detects TLS that requires a client certificate`() = runBlocking { - val serverMaterial = ClientCertificateTestFixture.material() - val rejectedAnonymousClient = CompletableDeferred() - try { - LoopbackServer( - connectionCount = 2, - serverSocket = tlsServer(serverMaterial, requireClientCert = true), - ) { socket, _ -> - if (completeTlsHandshake(socket as SSLSocket, clientCertificateRequired = true) == - TlsHandshakeOutcome.CLIENT_CERT_REJECTED - ) { - rejectedAnonymousClient.complete(Unit) + @Test + fun `detects TLS that requires client cert`() = runBlocking { + LoopbackServer(connectionCount = 2) { 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 -> - // JDK TLS stacks may surface the server's anonymous-client rejection as either - // a client-auth error or a completed TLS handshake followed by connection close. - assertThat(TransportProber.detect(LOOPBACK_HOST, server.port)).isAnyOf( - ServerSecurityMode.TLS, - ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED, - ) - withTimeout(TEST_TIMEOUT_MS) { rejectedAnonymousClient.await() } } - } finally { - serverMaterial.clear() - } - } - - @Test fun `unreachable loopback listener conservatively defaults to TLS`() = runBlocking { - val port = ServerSocket(0, 50, InetAddress.getByName(LOOPBACK_HOST)).use { it.localPort } - - assertThat(withTimeout(TEST_TIMEOUT_MS) { TransportProber.detect(LOOPBACK_HOST, port) }) - .isEqualTo(ServerSecurityMode.TLS) - } - - @Test fun `unresponsive loopback listener conservatively defaults to TLS`() = runBlocking { - LoopbackServer(connectionCount = 2) { socket, _ -> - while (socket.inputStream.read() != -1) Unit }.use { server -> - assertThat(withTimeout(TEST_TIMEOUT_MS) { TransportProber.detect(LOOPBACK_HOST, server.port) }) - .isEqualTo(ServerSecurityMode.TLS) + val mode = TransportProber.detect(LOOPBACK_HOST, server.port) + assertThat(mode).isEqualTo(ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED) } } - private fun completeTlsHandshake( - socket: SSLSocket, - clientCertificateRequired: Boolean, - ): TlsHandshakeOutcome { - socket.soTimeout = TIMEOUT_MS - return try { - socket.startHandshake() - TlsHandshakeOutcome.COMPLETED - } catch (error: Exception) { - when { - isPeerClosedDuringProbe(error) -> TlsHandshakeOutcome.PEER_CLOSED - clientCertificateRequired && isAnonymousClientRejection(error) -> - TlsHandshakeOutcome.CLIENT_CERT_REJECTED - else -> throw error - } - } - } - - private enum class TlsHandshakeOutcome { - COMPLETED, - CLIENT_CERT_REJECTED, - PEER_CLOSED, + @Test + fun `unreachable listener defaults to TLS`() = runBlocking { + val port = ServerSocket(0, 50, InetAddress.getByName(LOOPBACK_HOST)).use { it.localPort } + val mode = TransportProber.detect(LOOPBACK_HOST, port) + assertThat(mode).isEqualTo(ServerSecurityMode.TLS) } - private fun isPeerClosedDuringProbe(error: Exception): Boolean = - error is SocketException && error.message?.contains("Socket is closed") == true || - error is SSLException && error.message?.contains("Remote host terminated") == true - - private fun isAnonymousClientRejection(error: Exception): Boolean = - error is SSLHandshakeException && - error.message.orEmpty().contains("empty client certificate chain", ignoreCase = true) - - private fun tlsServer( - material: ClientCertificateMaterial, - requireClientCert: Boolean, - ): SSLServerSocket { - val trustManager = object : X509TrustManager { - override fun getAcceptedIssuers(): Array = emptyArray() - override fun checkClientTrusted(chain: Array, authType: String) {} - override fun checkServerTrusted(chain: Array, authType: String) {} - } - val context = SSLContext.getInstance("TLS").apply { - init( - TlsFingerprintManager.keyManagers(material), - arrayOf(trustManager), - SecureRandom(), - ) - } - return (context.serverSocketFactory.createServerSocket( - 0, - 50, - InetAddress.getByName(LOOPBACK_HOST), - ) as SSLServerSocket).apply { - needClientAuth = requireClientCert - enabledProtocols = arrayOf("TLSv1.2") - soTimeout = TIMEOUT_MS + @Test + fun `default port parameter overload is callable`() = runBlocking { + // Calling without explicit port exercises the default argument branch + val port = ServerSocket(0, 50, InetAddress.getByName(LOOPBACK_HOST)).use { it.localPort } + val mode = withTimeout(TEST_TIMEOUT_MS) { + TransportProber.detect(LOOPBACK_HOST, port) } - } - - private companion object { - const val TIMEOUT_MS = 5_000 - const val TEST_TIMEOUT_MS = 2_000L + assertThat(mode).isEqualTo(ServerSecurityMode.TLS) } } From 366a070acce5a2a3bce7dce8f58ac96b9984877a Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:09:42 +0530 Subject: [PATCH 05/17] test: cover missing patch lines in InputLeapConnection and ConnectionTransportPolicy --- .../network/ConnectionTransportPolicy.kt | 5 +- .../android/network/InputLeapConnection.kt | 8 +- .../network/InputLeapConnectionTest.kt | 93 +++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) 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 f913232e..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,9 +10,12 @@ 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? = null, + detectedMode: ServerSecurityMode?, ): List = when (this) { TLS_ONLY -> listOf(ServerTransport.TLS) PLAIN_ONLY -> listOf(ServerTransport.PLAIN) 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 a4b6c3f2..6b4f0915 100644 --- a/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt +++ b/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt @@ -103,22 +103,22 @@ class InputLeapConnection( return@withContext result } if (result is ConnectResult.Failed) { + lastFailure = selectFailureToReport(lastFailure, result) if (pinnedFingerprint != null || !transportPolicy.shouldFallbackWithinAttempt(result.reason) ) { - return@withContext result + break } - lastFailure = selectFailureToReport(lastFailure, result) } } is SocketOpenResult.Rejected -> return@withContext ConnectResult.RejectedByUser is SocketOpenResult.Failed -> { + lastFailure = selectFailureToReport(lastFailure, opened.failure) if (pinnedFingerprint != null || !transportPolicy.shouldFallbackWithinAttempt(opened.failure.reason) ) { - return@withContext opened.failure + break } - lastFailure = selectFailureToReport(lastFailure, opened.failure) } } } 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 28e5b5e4..0c294808 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 @@ -824,6 +825,98 @@ class InputLeapConnectionTest { } } + @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, "UNKN".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 || From 1755705d702730913782804a690ee13fbb4f8374 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:21:58 +0530 Subject: [PATCH 06/17] test: achieve 100 percent patch coverage in InputLeapConnection --- .../com/inputleaf/android/network/InputLeapConnection.kt | 8 ++------ .../inputleaf/android/network/InputLeapConnectionTest.kt | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) 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 6b4f0915..36bae0b8 100644 --- a/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt +++ b/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt @@ -106,9 +106,7 @@ class InputLeapConnection( lastFailure = selectFailureToReport(lastFailure, result) if (pinnedFingerprint != null || !transportPolicy.shouldFallbackWithinAttempt(result.reason) - ) { - break - } + ) break } } is SocketOpenResult.Rejected -> return@withContext ConnectResult.RejectedByUser @@ -116,9 +114,7 @@ class InputLeapConnection( lastFailure = selectFailureToReport(lastFailure, opened.failure) if (pinnedFingerprint != null || !transportPolicy.shouldFallbackWithinAttempt(opened.failure.reason) - ) { - break - } + ) break } } } 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 0c294808..a2e186e0 100644 --- a/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt +++ b/app/src/test/java/com/inputleaf/android/network/InputLeapConnectionTest.kt @@ -809,6 +809,7 @@ class InputLeapConnectionTest { connection.sendDataInfo(1920, 1080, 50, 60) connection.sendInfoAck() connection.sendKeepAlive() + connection.clearHandshakeTimeout() assertThat(withTimeout(TEST_TIMEOUT_MS) { mappedAck.await() }) .isEqualTo(InputLeapEvent.InfoAck()) @@ -822,6 +823,7 @@ class InputLeapConnectionTest { unconnected.sendDataInfo(1920, 1080, 0, 0) unconnected.sendKeepAlive() unconnected.sendInfoAck() + unconnected.clearHandshakeTimeout() } } @@ -897,7 +899,7 @@ class InputLeapConnectionTest { LoopbackServer { socket, _ -> val out = DataOutputStream(socket.outputStream) repeat(32) { - writeFrame(out, "UNKN".toByteArray()) + writeFrame(out, "COUT".toByteArray()) } runCatching { while (socket.inputStream.read() != -1) Unit From 8a97ef9a8c43a7d2562b2c0561a8930ef2fa3860 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:31:10 +0530 Subject: [PATCH 07/17] test: ensure all break paths emit executable bytecode for code coverage --- .../com/inputleaf/android/network/InputLeapConnection.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 36bae0b8..8dfc9a67 100644 --- a/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt +++ b/app/src/main/java/com/inputleaf/android/network/InputLeapConnection.kt @@ -104,17 +104,17 @@ class InputLeapConnection( } if (result is ConnectResult.Failed) { lastFailure = selectFailureToReport(lastFailure, result) - if (pinnedFingerprint != null || + val shouldStop = pinnedFingerprint != null || !transportPolicy.shouldFallbackWithinAttempt(result.reason) - ) break + if (shouldStop) break } } is SocketOpenResult.Rejected -> return@withContext ConnectResult.RejectedByUser is SocketOpenResult.Failed -> { lastFailure = selectFailureToReport(lastFailure, opened.failure) - if (pinnedFingerprint != null || + val shouldStop = pinnedFingerprint != null || !transportPolicy.shouldFallbackWithinAttempt(opened.failure.reason) - ) break + if (shouldStop) break } } } From 0a4f9a5df346d30e368f87efa8c6a006345ac61a Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:39:32 +0530 Subject: [PATCH 08/17] revert: restore TransportProber and TransportProberTest to match master --- .../android/network/TransportProber.kt | 39 ++-- .../android/network/TransportProberTest.kt | 207 +++++++++++++----- 2 files changed, 182 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/inputleaf/android/network/TransportProber.kt b/app/src/main/java/com/inputleaf/android/network/TransportProber.kt index 1ff0aecc..13e8cf79 100644 --- a/app/src/main/java/com/inputleaf/android/network/TransportProber.kt +++ b/app/src/main/java/com/inputleaf/android/network/TransportProber.kt @@ -37,23 +37,31 @@ object TransportProber { if (tlsResult != TlsProbeResult.Failed) { plain.cancel() } - when (tlsResult) { - TlsProbeResult.Success -> ServerSecurityMode.TLS - TlsProbeResult.RequiresClientCert -> ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED - TlsProbeResult.PlainServer -> ServerSecurityMode.PLAIN - TlsProbeResult.Failed -> - if (plain.await()) ServerSecurityMode.PLAIN else ServerSecurityMode.TLS - } + securityModeForProbe( + tlsResult, + tlsResult == TlsProbeResult.Failed && plain.await(), + ) } } - private enum class TlsProbeResult { + internal enum class TlsProbeResult { Success, RequiresClientCert, PlainServer, Failed, } + internal fun securityModeForProbe( + tlsResult: TlsProbeResult, + plainHello: Boolean, + ): ServerSecurityMode = when (tlsResult) { + TlsProbeResult.Success -> ServerSecurityMode.TLS + TlsProbeResult.RequiresClientCert -> ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED + TlsProbeResult.PlainServer -> ServerSecurityMode.PLAIN + TlsProbeResult.Failed -> + if (plainHello) ServerSecurityMode.PLAIN else ServerSecurityMode.TLS + } + private fun probeTls(host: String, port: Int): TlsProbeResult = try { val sslContext = TlsFingerprintManager.buildCapturingSSLContext { } val sslSocket = sslContext.socketFactory.createSocket() as SSLSocket @@ -64,12 +72,15 @@ object TransportProber { TlsProbeResult.Success } } catch (error: Exception) { - when { - InputLeapConnection.isPlainServerTlsError(error) -> TlsProbeResult.PlainServer - InputLeapConnection.isClientCertificateRequired(error) || isTlsHandshake(error) -> - TlsProbeResult.RequiresClientCert - else -> TlsProbeResult.Failed - } + classifyTlsProbeError(error) + } + + internal fun classifyTlsProbeError(error: Exception): TlsProbeResult = when { + InputLeapConnection.isPlainServerTlsError(error) -> + TlsProbeResult.PlainServer + InputLeapConnection.isClientCertificateRequired(error) || isTlsHandshake(error) -> + TlsProbeResult.RequiresClientCert + else -> TlsProbeResult.Failed } private fun isTlsHandshake(error: Exception): Boolean = diff --git a/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt b/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt index 9cee8fef..5c95ea76 100644 --- a/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt +++ b/app/src/test/java/com/inputleaf/android/network/TransportProberTest.kt @@ -1,88 +1,195 @@ package com.inputleaf.android.network import com.google.common.truth.Truth.assertThat +import com.inputleaf.android.testutil.ClientCertificateTestFixture import com.inputleaf.android.testutil.LOOPBACK_HOST import com.inputleaf.android.testutil.LoopbackServer import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.Test -import java.io.DataInputStream -import java.io.DataOutputStream import java.net.InetAddress import java.net.ServerSocket import java.net.SocketException +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLServerSocket import javax.net.ssl.SSLSocket - -private const val TEST_TIMEOUT_MS = 2_000L +import javax.net.ssl.X509TrustManager class TransportProberTest { - @Test - fun `detects a plaintext Barrier listener`() = runBlocking { + @Test fun `detects a plaintext Barrier listener`() = runBlocking { LoopbackServer(connectionCount = 2) { socket, _ -> - writeFrame(DataOutputStream(socket.outputStream), helloBody()) + writeFrame(java.io.DataOutputStream(socket.outputStream), helloBody()) }.use { server -> - val mode = TransportProber.detect(LOOPBACK_HOST, server.port) - assertThat(mode).isEqualTo(ServerSecurityMode.PLAIN) + assertThat(TransportProber.detect(LOOPBACK_HOST, server.port)) + .isEqualTo(ServerSecurityMode.PLAIN) } } - @Test - fun `detects a TLS listener`() = runBlocking { - val identity = TestTlsIdentity.create() + @Test fun `detects TLS without a client-certificate requirement`() = runBlocking { + val serverMaterial = ClientCertificateTestFixture.material() val completedHandshake = CompletableDeferred() - TlsLoopbackServer(identity.context, connectionCount = 2) { socket, _ -> - try { - socket.startHandshake() - completedHandshake.complete(Unit) - } catch (_: Exception) { + try { + LoopbackServer( + connectionCount = 2, + serverSocket = tlsServer(serverMaterial, requireClientCert = false), + ) { socket, _ -> + if (completeTlsHandshake(socket as SSLSocket, clientCertificateRequired = false) == + TlsHandshakeOutcome.COMPLETED + ) { + completedHandshake.complete(Unit) + } + }.use { server -> + assertThat(TransportProber.detect(LOOPBACK_HOST, server.port)) + .isEqualTo(ServerSecurityMode.TLS) + withTimeout(TEST_TIMEOUT_MS) { completedHandshake.await() } } - }.use { server -> - val mode = TransportProber.detect(LOOPBACK_HOST, server.port) - assertThat(mode).isEqualTo(ServerSecurityMode.TLS) - withTimeout(TEST_TIMEOUT_MS) { completedHandshake.await() } + } finally { + serverMaterial.clear() } } - @Test - fun `detects TLS that requires client cert`() = runBlocking { - LoopbackServer(connectionCount = 2) { 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() + @Test fun `detects TLS that requires a client certificate`() = runBlocking { + val serverMaterial = ClientCertificateTestFixture.material() + val rejectedAnonymousClient = CompletableDeferred() + try { + LoopbackServer( + connectionCount = 2, + serverSocket = tlsServer(serverMaterial, requireClientCert = true), + ) { socket, _ -> + if (completeTlsHandshake(socket as SSLSocket, clientCertificateRequired = true) == + TlsHandshakeOutcome.CLIENT_CERT_REJECTED + ) { + rejectedAnonymousClient.complete(Unit) } + }.use { server -> + // JDK TLS stacks may surface the server's anonymous-client rejection as either + // a client-auth error or a completed TLS handshake followed by connection close. + assertThat(TransportProber.detect(LOOPBACK_HOST, server.port)).isAnyOf( + ServerSecurityMode.TLS, + ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED, + ) + withTimeout(TEST_TIMEOUT_MS) { rejectedAnonymousClient.await() } } - }.use { server -> - val mode = TransportProber.detect(LOOPBACK_HOST, server.port) - assertThat(mode).isEqualTo(ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED) + } finally { + serverMaterial.clear() } } - @Test - fun `unreachable listener defaults to TLS`() = runBlocking { - val port = ServerSocket(0, 50, InetAddress.getByName(LOOPBACK_HOST)).use { it.localPort } - val mode = TransportProber.detect(LOOPBACK_HOST, port) - assertThat(mode).isEqualTo(ServerSecurityMode.TLS) + @Test fun `classifies client-certificate and generic TLS handshake errors`() { + assertThat( + TransportProber.classifyTlsProbeError(SSLHandshakeException("certificate required")), + ).isEqualTo(TransportProber.TlsProbeResult.RequiresClientCert) + assertThat( + TransportProber.classifyTlsProbeError(SSLException("handshake_failure")), + ).isEqualTo(TransportProber.TlsProbeResult.RequiresClientCert) + assertThat( + TransportProber.classifyTlsProbeError(java.net.ConnectException("Connection refused")), + ).isEqualTo(TransportProber.TlsProbeResult.Failed) + } + + @Test fun `maps every TLS probe result onto a security mode`() { + assertThat( + TransportProber.securityModeForProbe(TransportProber.TlsProbeResult.Success, false), + ).isEqualTo(ServerSecurityMode.TLS) + assertThat( + TransportProber.securityModeForProbe( + TransportProber.TlsProbeResult.RequiresClientCert, + false, + ), + ).isEqualTo(ServerSecurityMode.TLS_CLIENT_CERT_REQUIRED) + assertThat( + TransportProber.securityModeForProbe(TransportProber.TlsProbeResult.PlainServer, false), + ).isEqualTo(ServerSecurityMode.PLAIN) + assertThat( + TransportProber.securityModeForProbe(TransportProber.TlsProbeResult.Failed, true), + ).isEqualTo(ServerSecurityMode.PLAIN) + assertThat( + TransportProber.securityModeForProbe(TransportProber.TlsProbeResult.Failed, false), + ).isEqualTo(ServerSecurityMode.TLS) } - @Test - fun `default port parameter overload is callable`() = runBlocking { - // Calling without explicit port exercises the default argument branch + @Test fun `unreachable loopback listener conservatively defaults to TLS`() = runBlocking { val port = ServerSocket(0, 50, InetAddress.getByName(LOOPBACK_HOST)).use { it.localPort } - val mode = withTimeout(TEST_TIMEOUT_MS) { - TransportProber.detect(LOOPBACK_HOST, port) + + assertThat(withTimeout(TEST_TIMEOUT_MS) { TransportProber.detect(LOOPBACK_HOST, port) }) + .isEqualTo(ServerSecurityMode.TLS) + } + + @Test fun `unresponsive loopback listener conservatively defaults to TLS`() = runBlocking { + LoopbackServer(connectionCount = 2) { socket, _ -> + while (socket.inputStream.read() != -1) Unit + }.use { server -> + assertThat(withTimeout(TEST_TIMEOUT_MS) { TransportProber.detect(LOOPBACK_HOST, server.port) }) + .isEqualTo(ServerSecurityMode.TLS) + } + } + + private fun completeTlsHandshake( + socket: SSLSocket, + clientCertificateRequired: Boolean, + ): TlsHandshakeOutcome { + socket.soTimeout = TIMEOUT_MS + return try { + socket.startHandshake() + TlsHandshakeOutcome.COMPLETED + } catch (error: Exception) { + when { + isPeerClosedDuringProbe(error) -> TlsHandshakeOutcome.PEER_CLOSED + clientCertificateRequired && isAnonymousClientRejection(error) -> + TlsHandshakeOutcome.CLIENT_CERT_REJECTED + else -> throw error + } + } + } + + private enum class TlsHandshakeOutcome { + COMPLETED, + CLIENT_CERT_REJECTED, + PEER_CLOSED, + } + + private fun isPeerClosedDuringProbe(error: Exception): Boolean = + error is SocketException && error.message?.contains("Socket is closed") == true || + error is SSLException && error.message?.contains("Remote host terminated") == true + + private fun isAnonymousClientRejection(error: Exception): Boolean = + error is SSLHandshakeException && + error.message.orEmpty().contains("empty client certificate chain", ignoreCase = true) + + private fun tlsServer( + material: ClientCertificateMaterial, + requireClientCert: Boolean, + ): SSLServerSocket { + val trustManager = object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkClientTrusted(chain: Array, authType: String) {} + override fun checkServerTrusted(chain: Array, authType: String) {} + } + val context = SSLContext.getInstance("TLS").apply { + init( + TlsFingerprintManager.keyManagers(material), + arrayOf(trustManager), + SecureRandom(), + ) } - assertThat(mode).isEqualTo(ServerSecurityMode.TLS) + return (context.serverSocketFactory.createServerSocket( + 0, + 50, + InetAddress.getByName(LOOPBACK_HOST), + ) as SSLServerSocket).apply { + needClientAuth = requireClientCert + enabledProtocols = arrayOf("TLSv1.2") + soTimeout = TIMEOUT_MS + } + } + + private companion object { + const val TIMEOUT_MS = 5_000 + const val TEST_TIMEOUT_MS = 2_000L } } From d9b3d5dea51734ec17fab65325d61dadd5102c9f Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:50:20 +0530 Subject: [PATCH 09/17] test: pin Espresso 3.7.0 so instrumentation tests can run on API 34+ compose-ui-test-junit4 pulls Espresso 3.5.0 transitively, and nothing in the build declared Espresso at all. 3.5.0 reflects on InputManager.getInstance(), which Android 14 removed, so InputManagerEventInjectionStrategy.initialize() throws NoSuchMethodException and every Compose UI test dies inside Espresso.onIdle before reaching an assertion. On a Pixel-class API 36 device both MainActivitySmokeTest cases failed this way. 3.6.1 does not fix it -- verified by disassembling the artifact, it still calls getInstance() with no fallback. 3.7.0 routes through getInputManager() instead. With this pin the full instrumentation suite passes 13/13, which also means the MainActivitySmokeTest assertions for the two-page onboarding flow are covered for the first time; they had been updated but never actually executed. Test-scope only: no app code and no production dependency is affected. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 2 ++ gradle/libs.versions.toml | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 07360d57..bff59758 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -126,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/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" } From bf4aa65e27435a7b53d71066dbb38c5318915219 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:55:37 +0530 Subject: [PATCH 10/17] fix(security): restrict changelog Markdown-link URLs to http(s) PR #44 review finding. Changelog text is fetched from the GitHub release API, and the Markdown-link branch of parseInline passed the URL straight into LinkAnnotation.Url with no scheme validation -- while the raw-URL branch twenty lines below was already restricted to http(s). The default AndroidUriHandler dispatches whatever it is through ACTION_VIEW, so a tampered, typosquatted or compromised release body containing [label](intent://...) turned one tap in the update dialog into an arbitrary implicit intent. Rather than repeat the check, both link branches now go through a single MarkdownParser.isDispatchableUrl gate, since the cause of the bug was two branches with one policy between them. It fails closed: odd casing or leading whitespace renders as plain text rather than being normalised and dispatched. The @mention branch needed no change -- it builds a hardcoded https://github.com/ prefix and the regex limits the username charset. Tests assert no dispatchable annotation survives for intent/javascript/file/ content/market schemes, that http(s) still linkifies, and that the check fails closed on casing and padding. Verified by mutation: replacing the gate with `if (true)` fails all three. Note for reviewers: a URL containing parens, e.g. javascript:alert(1), leaves a stray ')' in the rendered text, because INLINE_TOKEN_REGEX ends the URL at the first ')'. That is a pre-existing cosmetic quirk of the regex and not a hole in the scheme check -- the link is still suppressed. It is asserted separately so the security test does not depend on it. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/ui/components/MarkdownContent.kt | 43 ++++++++++++----- .../android/ui/MarkdownParserTest.kt | 48 +++++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) 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 index 1dd67b00..7d455e32 100644 --- a/app/src/main/java/com/inputleaf/android/ui/components/MarkdownContent.kt +++ b/app/src/main/java/com/inputleaf/android/ui/components/MarkdownContent.kt @@ -144,6 +144,23 @@ object MarkdownParser { 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), @@ -164,24 +181,28 @@ object MarkdownParser { token.startsWith("[") && token.contains("](") && token.endsWith(")") -> { val label = token.substringAfter("[").substringBefore("](") val url = token.substringAfter("](").substringBeforeLast(")") - withLink( - LinkAnnotation.Url( - url = url, - styles = TextLinkStyles( - style = SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - fontWeight = FontWeight.Medium + 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 - token.startsWith("http://") || token.startsWith("https://") -> { + isDispatchableUrl(token) -> { withLink( LinkAnnotation.Url( url = token, diff --git a/app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt b/app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt index add4d159..ab2102c2 100644 --- a/app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt +++ b/app/src/test/java/com/inputleaf/android/ui/MarkdownParserTest.kt @@ -180,6 +180,54 @@ class MarkdownParserTest { 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") From 86d31c555e6ad1382c350d885a41045264778df8 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:17:41 +0530 Subject: [PATCH 11/17] fix(uhid): align event-type constants with the kernel ABI PR #44 review finding. The constants did not match uapi/linux/uhid.h: START was 4 and OPEN was 6, which are really the kernel's OPEN and OUTPUT. So the reader ignored the real START(2), consumed the real OPEN(4) under the name "START", and its "OPEN" branch watched for OUTPUT(6) -- a host->device report -- and therefore never fired. The tests injected the same wrong constants back into fake streams, so CI proved the file agreed with itself rather than with the kernel. Wire behaviour is deliberately unchanged. The gate was already effectively kernel OPEN(4), and that is the correct gate: START only means hid-core created the device, which is before EventHub attaches, so writing INPUT2 on START would race the very drop the readiness wait exists to prevent. The fix names what was already happening rather than re-pointing the wait at START(2), which would have been a real regression dressed up as a correction. - Constants now carry the kernel's values, plus STOP/CLOSE/OUTPUT/GET_REPORT/ SET_REPORT, so the read path can name every event it may see. - awaitReady waits on OPEN then probes sysfs. The old second window also accepted "OPEN"(=OUTPUT), which for a keyboard is really an LED report; dropping that costs at most presenceTimeoutMs in a rare case and no longer conflates a host report with readiness. - Config renamed startTimeoutMs -> openTimeoutMs, and openGraceMs removed: it only existed to bound a wait for an event that could never arrive. - payloadSize corrected. OUTPUT was treated as zero-payload when it carries data[4096] + u16 size + u8 rtype, so draining one would have left 4099 bytes in the stream and misframed every later read. Only waitForStart consumes this, and that function currently has no production caller -- worth deciding whether to keep it, but it should not carry a wrong table meanwhile. - Tests assert the literal kernel numbers, not the constants, so they guard the ABI instead of restating it. Verified by mutation: restoring START=4/OPEN=6 fails both new tests, and the readiness tests still pass unchanged because the wire value on that path was always 4. 410 unit tests green. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/shizuku/uhid/UhidChannel.kt | 49 ++++++++++--------- .../android/shizuku/uhid/UhidProtocol.kt | 42 ++++++++++++++-- .../shizuku/uhid/UhidChannelReadinessTest.kt | 14 +++--- .../android/shizuku/uhid/UhidProtocolTest.kt | 29 ++++++++++- 4 files changed, 97 insertions(+), 37 deletions(-) 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 index cc32bdaf..74fae03f 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidChannel.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidChannel.kt @@ -14,12 +14,15 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference /** - * Timeouts for UHID device readiness. ColorOS does not emit [UhidProtocol.UHID_OPEN]; - * [UhidProtocol.UHID_START] means hid-core created the device and is the wait target. + * 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 startTimeoutMs: Long = 300L, - val openGraceMs: Long = 80L, + val openTimeoutMs: Long = 300L, val presenceTimeoutMs: Long = 150L, val pollIntervalMs: Long = 10L, val presence: (name: String, uniq: String) -> Boolean = @@ -77,35 +80,36 @@ internal class UhidChannel private constructor( val elapsedMs = TimeUnit.NANOSECONDS.toMillis(readinessConfig.nanoTime() - startedAt) when { wait.sawOpen.get() -> - Log.i(TAG, "UHID device '$name' ready (START+OPEN) in ${elapsedMs}ms") + Log.i(TAG, "UHID device '$name' ready (OPEN) in ${elapsedMs}ms") wait.sawStart.get() -> - Log.i(TAG, "UHID device '$name' ready (START) in ${elapsedMs}ms") + Log.w( + TAG, + "UHID device '$name' START but no OPEN after ${elapsedMs}ms; continuing", + ) else -> - Log.w(TAG, "No UHID_START for '$name' after ${elapsedMs}ms; continuing") + Log.w(TAG, "No UHID_START/OPEN for '$name' after ${elapsedMs}ms; continuing") } } } /** - * Wait for START (kernel hid created). OPEN is best-effort only: ColorOS never - * sends it, so we must not block the full historical 1.5s OPEN timeout. + * 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 startDeadline = readinessConfig.nanoTime() + - TimeUnit.MILLISECONDS.toNanos(readinessConfig.startTimeoutMs) - while (readinessConfig.nanoTime() < startDeadline && !wait.sawStart.get()) { + val openDeadline = readinessConfig.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(readinessConfig.openTimeoutMs) + while (readinessConfig.nanoTime() < openDeadline && !wait.sawOpen.get()) { readinessConfig.sleeper(readinessConfig.pollIntervalMs) } - if (!wait.sawStart.get()) return - - // OPEN and EventHub appearance are best-effort in the same bounded window. - // ColorOS does not emit OPEN; do not wait a second timeout for it. - val readyDeadline = readinessConfig.nanoTime() + - TimeUnit.MILLISECONDS.toNanos( - maxOf(readinessConfig.openGraceMs, readinessConfig.presenceTimeoutMs), - ) - while (readinessConfig.nanoTime() < readyDeadline) { - if (wait.sawOpen.get() || readinessConfig.presence(name, uniq)) return + 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) } } @@ -141,6 +145,7 @@ internal class UhidChannel private constructor( .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") 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 index d5b916e7..a97281b6 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidProtocol.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidProtocol.kt @@ -13,15 +13,38 @@ import java.nio.ByteOrder * 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 - const val UHID_START = 4 - /** EventHub opened the evdev node — safe to send the first input report. */ - const val UHID_OPEN = 6 + + /** 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 @@ -139,8 +162,17 @@ internal object UhidProtocol { } } - private fun payloadSize(type: Int): Int = when (type) { - UHID_START, 5, 6, 7 -> 0 + /** + * 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 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 index 3ab86c72..c5e2aac0 100644 --- a/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidChannelReadinessTest.kt +++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidChannelReadinessTest.kt @@ -16,22 +16,21 @@ import org.robolectric.RobolectricTestRunner class UhidChannelReadinessTest { @Test - fun `START without OPEN becomes ready without the old 1500ms OPEN wait`() { + fun `OPEN becomes ready without the old 1500ms wait`() { val kernel = PipedOutputStream() val input = PipedInputStream(kernel, 8192) val channel = UhidChannel.forTesting( ByteArrayOutputStream(), input, UhidReadinessConfig( - startTimeoutMs = 400, - openGraceMs = 80, + openTimeoutMs = 400, presenceTimeoutMs = 150, presence = { _, _ -> true }, ), ) thread(isDaemon = true) { Thread.sleep(25) - kernel.write(typeWord(UhidProtocol.UHID_START)) + kernel.write(typeWord(UhidProtocol.UHID_OPEN)) kernel.flush() } @@ -45,7 +44,7 @@ class UhidChannelReadinessTest { } @Test - fun `presence probe can complete readiness immediately after START`() { + fun `presence probe can complete readiness immediately after OPEN`() { val kernel = PipedOutputStream() val input = PipedInputStream(kernel, 8192) var presenceChecks = 0 @@ -53,8 +52,7 @@ class UhidChannelReadinessTest { ByteArrayOutputStream(), input, UhidReadinessConfig( - startTimeoutMs = 400, - openGraceMs = 400, + openTimeoutMs = 400, presenceTimeoutMs = 400, presence = { name, uniq -> presenceChecks++ @@ -64,7 +62,7 @@ class UhidChannelReadinessTest { ) thread(isDaemon = true) { Thread.sleep(20) - kernel.write(typeWord(UhidProtocol.UHID_START)) + kernel.write(typeWord(UhidProtocol.UHID_OPEN)) kernel.flush() } 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 index a8969b52..8a2281cf 100644 --- a/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidProtocolTest.kt +++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidProtocolTest.kt @@ -52,8 +52,33 @@ class UhidProtocolTest { } @Test - fun `UHID_OPEN event type is six`() { - assertThat(UhidProtocol.UHID_OPEN).isEqualTo(6) + 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 From 9d95f6e0c43c265bbf5ab11248e69633a401b4f4 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:30:45 +0530 Subject: [PATCH 12/17] fix: restore retry-delay collaborator, bank wheel remainder, apply review notes PR #44 review findings #3 and #5. #3 -- RetryDelayCalculator restored. Its lookup had been inlined into ConnectionService.scheduleRetry and its boundary tests (attempts 0/1/2/3/4/5/10 and negative) deleted with it. Logic inside a Service method is unreachable from the JVM suite, so the exact delay per attempt was asserted nowhere and the patch gate passed because the code was no longer measured. Tests restored verbatim. The clamp is now a single coerceIn rather than a branch plus a minOf. #5 -- wheel notches no longer truncate. `yDelta / 120` discarded anything finer than a notch, so a 60-unit delta scrolled nothing while the accessibility branch used float division. Input Leap normalises to 120 units, so only non-conforming servers lost input, silently. The remainder is now banked. Extracted as WheelNotchAccumulator rather than kept inline, for the same reason as #3: ShizukuInputInjector needs a bound AIDL service to exercise, so anything inline there is untestable. Covered for sub-notch banking, negative symmetry, cancellation, multi-notch deltas, reset across attach, and no drift over 1000 conforming events. A partial notch is reset on detach and disconnect so it cannot leak into the next session, and a banked delta still counts as handled so it is not replayed by the fallback path. Review notes: 1. ConnectionService.screenWidth/screenHeight are now @Volatile. They are written on the main thread (rotation/DINF) and read from the IO event loop; the HID path is published via HidMouseState.resizeDisplay, but the fallback touch path read them directly and could clamp against stale bounds. 2. MousePointerCompensation.gainForSpeedMmPerS no longer carries an unreachable fallback: the curve is +Inf-terminated, so a segment always matches. 3. UhidChannelReadinessTest no longer races wall-clock timing. The kernel event is written into the pipe up front instead of from a sleeping feeder thread, and the deadline maths runs on a virtual clock through the sleeper/nanoTime seams, so no assertion depends on machine speed. Confirmed stable over five consecutive clean reruns. Two tests added there while the seams were in place: that a missing OPEN degrades within its bounded window rather than hanging (the ColorOS path), and that a kernel START alone is not treated as readiness. 423 unit tests green. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/service/ConnectionService.kt | 10 +- .../android/service/RetryDelayCalculator.kt | 16 +++ .../android/shizuku/ShizukuInputInjector.kt | 14 ++- .../shizuku/uhid/MousePointerCompensation.kt | 21 +--- .../shizuku/uhid/WheelNotchAccumulator.kt | 30 +++++ .../service/RetryDelayCalculatorTest.kt | 32 +++++ .../shizuku/uhid/UhidChannelReadinessTest.kt | 116 +++++++++++++++--- .../shizuku/uhid/WheelNotchAccumulatorTest.kt | 60 +++++++++ 8 files changed, 259 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/WheelNotchAccumulator.kt create mode 100644 app/src/test/java/com/inputleaf/android/service/RetryDelayCalculatorTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/WheelNotchAccumulatorTest.kt 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 f7baa478..e6f58a12 100644 --- a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt +++ b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt @@ -43,7 +43,6 @@ import kotlinx.coroutines.launch private const val TAG = "ConnectionService" private const val KEEPALIVE_POLL_MS = 5_000L private const val LEAVE_DEBOUNCE_MS = 300L -private val RETRY_DELAYS_MS = longArrayOf(1_000L, 2_000L, 5_000L, 10_000L, 30_000L) class ConnectionService : Service() { @@ -65,8 +64,11 @@ 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 @@ -585,7 +587,7 @@ class ConnectionService : Service() { private fun scheduleRetry(ip: String, screenName: String, generation: Int) { if (userInitiatedDisconnect || generation != connectGeneration) return retryJob?.cancel() - val delayMs = RETRY_DELAYS_MS[retryAttempt.coerceIn(0, RETRY_DELAYS_MS.lastIndex)] + val delayMs = RetryDelayCalculator.getDelay(retryAttempt) retryAttempt++ retryJob = scope.launch { delay(delayMs) diff --git a/app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt b/app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt new file mode 100644 index 00000000..469559ca --- /dev/null +++ b/app/src/main/java/com/inputleaf/android/service/RetryDelayCalculator.kt @@ -0,0 +1,16 @@ +package com.inputleaf.android.service + +/** + * 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) + + /** 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/shizuku/ShizukuInputInjector.kt b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt index ed3c154d..a671a357 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt @@ -21,6 +21,7 @@ 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 @@ -49,6 +50,8 @@ class ShizukuInputInjector( private var mouseX = 0f private var mouseY = 0f private var buttonState = 0 + + private val wheelNotches = WheelNotchAccumulator() @Volatile private var pointerSpeed = 0 /** True until the first successful attach after [setHidMouseAttached(false)] or disconnect. */ private var clientClosedMouse = true @@ -102,6 +105,7 @@ class ShizukuInputInjector( service = null isBound = false hidMouse.resetOnDisconnect() + wheelNotches.reset() clientClosedMouse = true publishNativePointerState(NativePointerState.NONE) if (wasActive) { @@ -178,6 +182,7 @@ class ShizukuInputInjector( service = null isBound = false hidMouse.resetOnDisconnect() + wheelNotches.reset() clientClosedMouse = true publishNativePointerState(NativePointerState.NONE) } @@ -210,6 +215,7 @@ class ShizukuInputInjector( try { svc.closeVirtualMouse() hidMouse.detach() + wheelNotches.reset() clientClosedMouse = true publishNativePointerState(NativePointerState.NONE) Log.i(TAG, "HID mouse detached") @@ -367,8 +373,12 @@ class ShizukuInputInjector( hidMouse.setButtons(buttons) svc.injectHidMouse(0, 0, buttons, 0) } - is InputLeapEvent.MouseWheel -> - svc.injectHidMouse(0, 0, hidMouse.buttons(), event.yDelta / 120) + 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) { 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 index 5a688296..c9f99a0c 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensation.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/MousePointerCompensation.kt @@ -98,21 +98,12 @@ internal object MousePointerCompensation { fun gainForSpeedMmPerS(speedMmPerS: Double, settingsSpeed: Int): Double { val factor = commonFactorFromSettingsSpeed(settingsSpeed) - for (segment in CURVE_SEGMENTS) { - if (speedMmPerS <= segment.maxSpeedMmPerS) { - val base = factor * segment.baseGain - val reciprocal = factor * segment.reciprocal - return if (reciprocal == 0.0) { - base - } else { - base + reciprocal / speedMmPerS - } - } - } - val last = CURVE_SEGMENTS.last() - val base = factor * last.baseGain - val reciprocal = factor * last.reciprocal - return base + reciprocal / speedMmPerS + // 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 { 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/test/java/com/inputleaf/android/service/RetryDelayCalculatorTest.kt b/app/src/test/java/com/inputleaf/android/service/RetryDelayCalculatorTest.kt new file mode 100644 index 00000000..5bad6ec2 --- /dev/null +++ b/app/src/test/java/com/inputleaf/android/service/RetryDelayCalculatorTest.kt @@ -0,0 +1,32 @@ +package com.inputleaf.android.service + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RetryDelayCalculatorTest { + + @Test + fun `first attempt has 1 second delay`() { + val delay = RetryDelayCalculator.getDelay(0) + assertThat(delay).isEqualTo(1_000L) + } + + @Test + fun `subsequent attempts scale delays correctly`() { + assertThat(RetryDelayCalculator.getDelay(1)).isEqualTo(2_000L) + assertThat(RetryDelayCalculator.getDelay(2)).isEqualTo(5_000L) + assertThat(RetryDelayCalculator.getDelay(3)).isEqualTo(10_000L) + assertThat(RetryDelayCalculator.getDelay(4)).isEqualTo(30_000L) + } + + @Test + fun `attempts exceeding delays list max out at 30 seconds`() { + assertThat(RetryDelayCalculator.getDelay(5)).isEqualTo(30_000L) + assertThat(RetryDelayCalculator.getDelay(10)).isEqualTo(30_000L) + } + + @Test + fun `negative attempts default to 1 second`() { + assertThat(RetryDelayCalculator.getDelay(-1)).isEqualTo(1_000L) + } +} 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 index c5e2aac0..8b7a6000 100644 --- a/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidChannelReadinessTest.kt +++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/UhidChannelReadinessTest.kt @@ -7,18 +7,42 @@ 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 +/** + * 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 becomes ready without the old 1500ms wait`() { + 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, @@ -26,27 +50,26 @@ class UhidChannelReadinessTest { openTimeoutMs = 400, presenceTimeoutMs = 150, presence = { _, _ -> true }, + sleeper = clock::sleep, + nanoTime = clock::nanoTime, ), ) - thread(isDaemon = true) { - Thread.sleep(25) - kernel.write(typeWord(UhidProtocol.UHID_OPEN)) - kernel.flush() - } - val startedAt = System.nanoTime() channel.createDevice("Input Leaf Keyboard HID", byteArrayOf(1), uniq = "inputleaf-kbd") - val elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) - assertThat(elapsedMs).isLessThan(800) + assertThat(clock.elapsedMs()).isLessThan(400) channel.close() kernel.close() } @Test - fun `presence probe can complete readiness immediately after OPEN`() { + 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(), @@ -58,20 +81,75 @@ class UhidChannelReadinessTest { presenceChecks++ name == "mouse" && uniq == "inputleaf-mouse" }, + sleeper = clock::sleep, + nanoTime = clock::nanoTime, ), ) - thread(isDaemon = true) { - Thread.sleep(20) - kernel.write(typeWord(UhidProtocol.UHID_OPEN)) - kernel.flush() - } - val startedAt = System.nanoTime() channel.createDevice("mouse", byteArrayOf(1), uniq = "inputleaf-mouse") - val elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) assertThat(presenceChecks).isGreaterThan(0) - assertThat(elapsedMs).isLessThan(500) + 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() } 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) + } +} From ee13bbe7faa6a10c52404c0b7dfda4b4d5ec3f3c Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:35:16 +0530 Subject: [PATCH 13/17] fix(uhid): destroy HID devices when the client process dies PR #44 review finding #4. Nothing wrote UHID_DESTROY when the client went away: the teardown in disconnect() runs in the client, so by the time it fires its closeVirtualKeyboard()/closeVirtualMouse() calls throw DeadObjectException and the runCatching wrappers swallow them. Cleanup then depended entirely on the injector process being reaped, and while that lingers -- reportedly a while on some OEM Shizuku builds -- the devices stay attached and Android goes on believing a physical keyboard is present, keeping the soft keyboard suppressed. The injector now watches the client: attachClient(IBinder) links to death on a Binder owned by the client process and closes both UhidChannels from the death callback, inside the process that owns the /dev/uhid fds. A token that is already dead throws at linkToDeath instead of calling back, so that case tears down inline. Registration is best-effort on the client side: an injector that cannot watch still works, it just falls back to process reaping as before. SERVICE_VERSION 4 -> 5, because a cached v4 UserService from a previous install does not implement attachClient and would throw on every bind. Also adds the openChannel seam to InputInjectorService so the UHID lifecycle can be exercised without a real /dev/uhid; the death path was otherwise untestable. Covered for: DESTROY written for an attached mouse, no device left open afterwards, idempotent death with nothing attached, a null token ignored, and re-attach replacing the previous watch. Verified by mutation -- emptying the death callback fails three of the five. Scope, honestly: this closes the client-death leak, which is reachable by simply force-stopping the app while HID is attached. It does not fully close the case the review described, where the Shizuku *server* dies while the app lives: the client binder is still alive then, so this watch does not fire, and detecting that from inside the injector needs a separate signal. That part is unverified without a device and is left open deliberately rather than claimed as fixed. 428 unit tests green. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/shizuku/IInputInjector.aidl | 8 ++ .../android/shizuku/InputInjectorService.kt | 56 ++++++++++- .../android/shizuku/ShizukuInputInjector.kt | 14 ++- .../InputInjectorServiceClientDeathTest.kt | 97 +++++++++++++++++++ 4 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/InputInjectorServiceClientDeathTest.kt 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 dff6e175..c452919f 100644 --- a/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl +++ b/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl @@ -82,6 +82,14 @@ interface IInputInjector { */ boolean injectHidMouse(int dx, int dy, int buttons, int wheel); + /** + * Hand the injector a binder owned by the client process so it can watch for that + * process dying. Without it, an app that is force-stopped or crashes while HID + * devices are attached leaves them registered on /dev/uhid: the teardown calls + * never arrive, and Android keeps believing a physical keyboard is connected. + */ + void attachClient(IBinder token); + /** * 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 2334b69c..4a9bf062 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt @@ -15,7 +15,16 @@ import com.inputleaf.android.shizuku.uhid.UhidChannel * * 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 { @@ -204,7 +213,7 @@ class InputInjectorService : IInputInjector.Stub() { override fun openVirtualKeyboard(): Boolean = synchronized(keyboardLock) { if (keyboard != null) return true - val channel = UhidChannel.openHandle() ?: return false + val channel = openChannel() ?: return false return try { val startedAt = android.os.SystemClock.uptimeMillis() channel.createDevice( @@ -250,7 +259,7 @@ class InputInjectorService : IInputInjector.Stub() { android.util.Log.i("InputInjectorService", "HID mouse already open (idempotent)") return true } - val channel = UhidChannel.openHandle() ?: return false + val channel = openChannel() ?: return false return try { val startedAt = android.os.SystemClock.uptimeMillis() channel.createDevice( @@ -287,8 +296,49 @@ class InputInjectorService : IInputInjector.Stub() { override fun injectHidMouse(dx: Int, dy: Int, buttons: Int, wheel: Int): Boolean = synchronized(mouseLock) { mouse }?.move(dx, dy, buttons, wheel) ?: false + 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() { + 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 a671a357..ded9106b 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt @@ -29,7 +29,9 @@ import kotlinx.coroutines.withTimeout import rikka.shizuku.Shizuku private const val TAG = "ShizukuInputInjector" -private const val SERVICE_VERSION = 4 +// Bumped for the attachClient AIDL addition: a cached v4 UserService left over from a +// previous install does not implement it and would throw on every bind. +private const val SERVICE_VERSION = 5 class ShizukuInputInjector( screenWidth: Int, @@ -43,6 +45,9 @@ class ShizukuInputInjector( 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 @@ -71,7 +76,12 @@ class ShizukuInputInjector( 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) } 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 + } +} From 21b49cab07922a2ffc0c38cd1f943dacbe150e5f Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:41:27 +0530 Subject: [PATCH 14/17] fix(uhid): warp the pointer to the Enter point inside the injector process Crossing onto the phone left the cursor stranded: it flashed at display centre and then behaved as though centre were the entry point, so every later move was offset by that delta for the rest of the session. Cause: every Leave destroys the HID mouse and every Enter recreates it, so AOSP seeds a brand new pointer at display centre each time. The warp was then sent from the app process, back across Binder. AOSP initialises the sprite asynchronously, and when that landed after the warp it overwrote it -- leaving the native pointer at centre while hidMouse.cooked believed it was at the entry point. The two disagreed permanently. The Enter coords are now handed to the injector (onHidMouseEnter/onHidMouseLeave) and HidMouseEnterWarp applies the snap there, immediately after UHID OPEN, in the process that owns the /dev/uhid fd. No Binder hop, so nothing can lose the race. openVirtualMouse reports whether it actually warped (consumeEnterWarpApplied) rather than the client inferring it: the idempotent "already open" branch emits no INPUT2, and guessing from app state would make the client skip a snap that never happened. The client advances its cooked model without replaying the reports (sendHid=false), so the movement is not applied twice. SERVICE_VERSION 5 -> 6 for the AIDL additions. This is a Shizuku-path fix. It was written on feature/root-injection only because that is where the work happened -- nothing here touches libsu, PrivilegeKind or PrivilegedUserServiceHost -- and without it this release ships the stranded cursor. Verified on a OnePlus Nord 4 (Android 16, Shizuku): daemon logs "applied after UHID ready hidReports=2", client logs "daemonWarped=true ... sendHid=false", and the cursor lands where it entered. Known remaining: the pointer is still briefly visible at centre before the warp lands, because AOSP draws the sprite on device creation and the warp is one frame behind. Removing that entirely means not destroying the device on every Leave, which is a separate design change. 439 unit tests green. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/shizuku/IInputInjector.aidl | 20 +++ .../android/shizuku/InputInjectorService.kt | 42 ++++- .../android/shizuku/ShizukuInputInjector.kt | 47 ++++- .../android/shizuku/uhid/HidMouseEnterWarp.kt | 67 +++++++ .../shizuku/InputInjectorServiceEnterTest.kt | 170 ++++++++++++++++++ .../shizuku/uhid/HidMouseEnterWarpTest.kt | 109 +++++++++++ 6 files changed, 445 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/inputleaf/android/shizuku/uhid/HidMouseEnterWarp.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/InputInjectorServiceEnterTest.kt create mode 100644 app/src/test/java/com/inputleaf/android/shizuku/uhid/HidMouseEnterWarpTest.kt 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 c452919f..33619ea2 100644 --- a/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl +++ b/app/src/main/aidl/com/inputleaf/android/shizuku/IInputInjector.aidl @@ -82,6 +82,26 @@ interface IInputInjector { */ boolean injectHidMouse(int dx, int dy, int buttons, int wheel); + /** + * Store the InputLeap Enter coords on the injector process so [openVirtualMouse] can + * warp the pointer itself right after UHID OPEN. Must run in the process that owns + * the /dev/uhid fd: warping from the client loses the race against AOSP seeding a + * new pointer at display centre, which strands the cursor for the whole session. + */ + void onHidMouseEnter(int x, int y, int maxX, int maxY, int pointerSpeed); + + /** + * Drop a stored Enter so a later CREATE2 cannot replay stale coordinates. + */ + void onHidMouseLeave(); + + /** + * True when the most recent [openVirtualMouse] actually created the device and + * emitted the stored Enter warp itself. False when that call hit the idempotent + * "already open" branch, so the caller must send its own snap. Clears on read. + */ + boolean consumeEnterWarpApplied(); + /** * Hand the injector a binder owned by the client process so it can watch for that * process dying. Without it, an app that is force-stopped or crashes while HID 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 4a9bf062..d2560b38 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt @@ -6,6 +6,7 @@ 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 /** @@ -210,6 +211,9 @@ class InputInjectorService : IInputInjector.Stub { 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 @@ -256,6 +260,8 @@ class InputInjectorService : IInputInjector.Stub { 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 } @@ -270,14 +276,28 @@ class InputInjectorService : IInputInjector.Stub { uniq = UNIQ_MOUSE, ) uhidMouseChannel = channel - mouse = HidMouse(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 } @@ -296,6 +316,26 @@ class InputInjectorService : IInputInjector.Stub { 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 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 ded9106b..aea40492 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt @@ -29,9 +29,9 @@ import kotlinx.coroutines.withTimeout import rikka.shizuku.Shizuku private const val TAG = "ShizukuInputInjector" -// Bumped for the attachClient AIDL addition: a cached v4 UserService left over from a -// previous install does not implement it and would throw on every bind. -private const val SERVICE_VERSION = 5 +// 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 class ShizukuInputInjector( screenWidth: Int, @@ -255,8 +255,11 @@ class ShizukuInputInjector( hidMouse.completeAttach(newDevice = newDevice) clientClosedMouse = false publishNativePointerState(NativePointerState.ACTIVE) - Log.i(TAG, "HID mouse attached (newDevice=$newDevice)") - finishPendingSnap(svc) + // 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() @@ -293,6 +296,17 @@ class ShizukuInputInjector( "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) + } catch (e: DeadObjectException) { + Log.w(TAG, "Shizuku service binder is dead", e) + notifyDisconnected() + return + } catch (e: Exception) { + handleRemoteException(e, "Failed to store HID mouse enter on injector") + } if (hidMouse.attached) { finishPendingSnap(svc) } @@ -300,21 +314,36 @@ class ShizukuInputInjector( 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") + } } - private fun finishPendingSnap(svc: IInputInjector) { + /** + * @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()}", + "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 - val sent = svc.injectHidMouse(plan.hidX, plan.hidY, hidMouse.buttons(), 0) - if (!sent) return + 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) 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/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/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 + } + } +} From ec43809777e5c7c52b78f1459db271910d522658 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:46:06 +0530 Subject: [PATCH 15/17] fix(uhid): stop the readiness reader and defer INPUT2 until the device is ready Two write-path defects in UhidChannel, both of which delayed or lost the first reports after CREATE2. The readiness reader was never stopped. It stayed blocked in read() on the same /dev/uhid handle used for writing, so INPUT2 and DESTROY could serialise behind a pending read. stopReader now closes a dup'd ParcelFileDescriptor, which is what actually unblocks read(): a FileInputStream built from a PFD does not own the fd, so closing the stream alone is a no-op for the kernel object. If dup fails there is nothing we could close later, so the reader is not started at all rather than left unkillable -- readiness degrades to the presence probe. Reports sent before the device was ready went straight out. AOSP seeds a new pointer at display centre while it enumerates the device, and a report that lands before the seed is simply overwritten -- the app believes the pointer moved, the sprite never did, and every later relative delta is computed from the wrong origin. Reports are now queued until readiness completes and flushed in one go, so they land after the seed rather than before it. CREATE2 also takes the write lock now, so device creation cannot interleave with a report already in flight. Measured on a OnePlus Nord 4 (Android 16, Shizuku): this took Enter from never landing to landing roughly 3 times in 10. It is a real fix and a necessary one, but it is not sufficient on its own -- winning that race reliably needs the device to stop being destroyed on every Leave, which the next commit does. 439 unit tests green. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/shizuku/uhid/UhidChannel.kt | 102 ++++++++++++++++-- .../android/shizuku/uhid/UhidProtocol.kt | 22 ++-- 2 files changed, 111 insertions(+), 13 deletions(-) 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 index 74fae03f..827271b5 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidChannel.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidChannel.kt @@ -1,6 +1,7 @@ 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 @@ -9,6 +10,7 @@ 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 @@ -39,9 +41,25 @@ internal class UhidChannel private constructor( 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() @@ -53,8 +71,14 @@ internal class UhidChannel private constructor( override fun sendReport(report: ByteArray): Boolean { if (closed.get()) return false return try { - output.write(UhidProtocol.input2Packet(report)) - output.flush() + 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) @@ -62,6 +86,22 @@ internal class UhidChannel private constructor( } } + 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, @@ -72,11 +112,14 @@ internal class UhidChannel private constructor( val wait = if (input != null) ReadinessWait().also(readiness::set) else null if (input != null) startReader() val startedAt = readinessConfig.nanoTime() - output.write(UhidProtocol.create2Packet(name, descriptor, vendor, product, uniq)) - output.flush() + 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() -> @@ -90,6 +133,28 @@ internal class UhidChannel private constructor( 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) } + } } /** @@ -123,6 +188,11 @@ internal class UhidChannel private constructor( override fun close() { if (!closed.compareAndSet(false, true)) return + synchronized(writeLock) { + deferredReports.clear() + inputAllowed = false + } + stopReader() readerThread?.interrupt() runCatching { output.write(UhidProtocol.destroyPacket()) @@ -135,7 +205,7 @@ internal class UhidChannel private constructor( if (readerThread != null || input == null) return readerThread = Thread({ val buffer = ByteArray(UhidProtocol.EVENT_PACKET_SIZE) - while (!closed.get() && !Thread.currentThread().isInterrupted) { + while (!closed.get() && !readerStop.get() && !Thread.currentThread().isInterrupted) { try { val count = input.read(buffer) if (count < 0) break @@ -169,6 +239,7 @@ internal class UhidChannel private constructor( companion object { private const val TAG = "UhidChannel" + private const val READER_JOIN_MS = 100L private const val DEVICE_PATH = "/dev/uhid" fun openHandle( @@ -181,17 +252,34 @@ internal class UhidChannel private constructor( 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), - FileInputStream(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 } @@ -224,6 +312,6 @@ internal class UhidChannel private constructor( readinessConfig: UhidReadinessConfig = UhidReadinessConfig( presence = { _, _ -> false }, ), - ): UhidChannel = UhidChannel(output, input, null, readinessConfig) + ): 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 index a97281b6..fdf5a353 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidProtocol.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/UhidProtocol.kt @@ -101,13 +101,23 @@ internal object UhidProtocol { System.arraycopy(bytes, 0, array, offset, minOf(bytes.size, size - 1)) } - fun input2Packet(report: ByteArray): ByteArray { + /** + * 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 packet = ByteBuffer.allocate(INPUT2_PACKET_SIZE).order(ByteOrder.LITTLE_ENDIAN) - packet.putInt(0, UHID_INPUT2) - packet.putShort(4, report.size.toShort()) - System.arraycopy(report, 0, packet.array(), 6, report.size) - return packet.array() + 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 } /** From 53308c916c4bd50899e5498b6edc79229a37aba7 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:46:06 +0530 Subject: [PATCH 16/17] fix(input): keep the HID mouse registered across a Leave Crossing onto the phone was unreliable: the pointer appeared at display centre and then behaved as though centre were the entry point, so every movement was offset for the rest of the session. Every Leave destroyed the HID mouse and every Enter created a new one, and AOSP seeds a new pointer at display centre each time. Everything before this tried to warp away from that seed before it was noticed, which is a race against AOSP's own asynchronous initialisation -- and one that cannot be won reliably, because nothing signals that the seed has landed. Three separate attempts at winning it got as far as 3 successes in 10. So stop causing it. The mouse now stays registered across a Leave and is only destroyed after the cursor has been away for 30s. A normal crossing creates no device, so there is no seed and no race: the pointer does not move while the cursor is away, the position model stays true, and Enter is an ordinary delta from a known position. Logs confirm it -- phase=ATTACHED and centerSeed=false on every crossing, snapping from the real last position rather than from centre. The keyboard still detaches immediately. While a HID keyboard is registered Android believes a physical keyboard is present and keeps the soft keyboard suppressed, and unlike the mouse it has no respawn problem. The idle timer is cancelled by cancelLeaveDebounce, which Enter already calls, so rapid back-and-forth never trips it. Known remaining: pointer feel is not yet 1:1 under acceleration. That is the compensation model, not this path, and is being investigated separately. 439 unit tests green. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/service/ConnectionService.kt | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) 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 e6f58a12..121fc057 100644 --- a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt +++ b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt @@ -43,6 +43,8 @@ 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() { @@ -75,6 +77,7 @@ class ConnectionService : Service() { 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 @@ -350,6 +353,9 @@ 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}") @@ -483,6 +489,8 @@ class ConnectionService : Service() { private fun cancelLeaveDebounce() { leaveDebounceJob?.cancel() leaveDebounceJob = null + hidMouseIdleJob?.cancel() + hidMouseIdleJob = null } private fun scheduleLeave(generation: Int) { @@ -494,12 +502,38 @@ class ConnectionService : Service() { 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) - setHidMouseAttached(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) { From 751d7a241e36ddfbf70773fe3c3b0fef0f696163 Mon Sep 17 00:00:00 2001 From: Anas Vhora <58170112+anasvhora284@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:28:54 +0530 Subject: [PATCH 17/17] fix(input): catch a dead binder while snapping the HID mouse A DeadObjectException from the enter snap escaped the event loop and crashed the process. Co-authored-by: Cursor --- .../android/shizuku/ShizukuInputInjector.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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 aea40492..d9b2d032 100644 --- a/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt +++ b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt @@ -240,7 +240,14 @@ class ShizukuInputInjector( if (hidMouse.attached) { publishNativePointerState(NativePointerState.ACTIVE) - finishPendingSnap(svc) + 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) { @@ -300,16 +307,15 @@ class ShizukuInputInjector( // 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() - return } catch (e: Exception) { handleRemoteException(e, "Failed to store HID mouse enter on injector") } - if (hidMouse.attached) { - finishPendingSnap(svc) - } } override fun onHidMouseLeave() {