Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5445fbd
chore: drop unused UHID sidecar and one-caller wrappers.
anasvhora284 Sep 15, 2026
ee754f2
chore: drop unused wizard, resolver, and stale docs.
anasvhora284 Sep 15, 2026
7cfb0ca
[UPD]: Release v1.4.2 with UHID attachment, cursor overlay fixes, and…
anasvhora284 Sep 17, 2026
1fddace
test: cover patch lines in InputInjector, Connection, Prober, and Eve…
anasvhora284 Sep 18, 2026
8a6456f
Merge branch 'master' into release/1.4.2
anasvhora284 Sep 18, 2026
366a070
test: cover missing patch lines in InputLeapConnection and Connection…
anasvhora284 Sep 18, 2026
1755705
test: achieve 100 percent patch coverage in InputLeapConnection
anasvhora284 Sep 18, 2026
8a97ef9
test: ensure all break paths emit executable bytecode for code coverage
anasvhora284 Sep 18, 2026
0a4f9a5
revert: restore TransportProber and TransportProberTest to match master
anasvhora284 Sep 18, 2026
d9b3d5d
test: pin Espresso 3.7.0 so instrumentation tests can run on API 34+
anasvhora284 Sep 22, 2026
bf4aa65
fix(security): restrict changelog Markdown-link URLs to http(s)
anasvhora284 Sep 22, 2026
86d31c5
fix(uhid): align event-type constants with the kernel ABI
anasvhora284 Sep 22, 2026
9d95f6e
fix: restore retry-delay collaborator, bank wheel remainder, apply re…
anasvhora284 Sep 22, 2026
ee13bbe
fix(uhid): destroy HID devices when the client process dies
anasvhora284 Sep 22, 2026
21b49ca
fix(uhid): warp the pointer to the Enter point inside the injector pr…
anasvhora284 Sep 22, 2026
ec43809
fix(uhid): stop the readiness reader and defer INPUT2 until the devic…
anasvhora284 Sep 22, 2026
53308c9
fix(input): keep the HID mouse registered across a Leave
anasvhora284 Sep 22, 2026
751d7a2
fix(input): catch a dead binder while snapping the HID mouse
anasvhora284 Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ 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
with:
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.
Expand All @@ -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

Expand Down
20 changes: 4 additions & 16 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ android {
applicationId = "com.inputleaf.android"
minSdk = 26
targetSdk = 34
versionCode = 7
versionName = "1.4.1"
versionCode = 8
versionName = "1.4.2"
// JUnit4 runner so the androidTest classes are discovered on the emulator
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down Expand Up @@ -60,12 +60,6 @@ android {
aidl = true // Enable AIDL for Shizuku IPC
}

sourceSets {
getByName("main").assets.srcDir(
project(":uhid-server").layout.buildDirectory.dir("generated/assets/uhid").get().asFile
)
}

packaging {
jniLibs {
useLegacyPackaging = true
Expand Down Expand Up @@ -102,14 +96,6 @@ androidComponents {
}
}

tasks.matching { it.name.startsWith("merge") && it.name.endsWith("Assets") }.configureEach {
dependsOn(":uhid-server:buildDex")
}

tasks.matching { it.name.contains("lintVital", ignoreCase = true) }.configureEach {
dependsOn(":uhid-server:buildDex")
}

dependencies {
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
Expand Down Expand Up @@ -140,6 +126,8 @@ dependencies {

androidTestImplementation(composeBom)
androidTestImplementation(libs.compose.ui.test.junit4)
// Forces the Android 14+ compatible Espresso over the 3.5.0 the Compose BOM drags in.
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
Expand Down
7 changes: 2 additions & 5 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,14 @@
android:label="Input Leaf"
android:theme="@style/Theme.InputLeaf">
<activity
android:name=".ui.SplashActivity"
android:theme="@style/Theme.InputLeaf.Splash"
android:name=".ui.MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|touchscreen"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".ui.MainActivity"
android:exported="false" />
<service
android:name=".service.ConnectionService"
android:foregroundServiceType="connectedDevice"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,74 @@ 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);

/**
* 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
* 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand All @@ -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) {
Expand All @@ -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 ->
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading