Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,17 @@ jobs:
- name: Run fast JVM tests and generate coverage
run: ./gradlew --no-daemon --stacktrace :app:testDebugUnitTest :koverXmlReportDebugJvm

# Lines with no JaCoCo counters are Codecov skipped coverage (-1). See codecov.yml.
- name: Skip JaCoCo lines with no instructions
run: python3 scripts/codecov_skip_unprobed.py build/reports/kover/coverage-debug-jvm.xml build/reports/kover/unprobed.json

- 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
files: build/reports/kover/coverage-debug-jvm.xml,build/reports/kover/unprobed.json
flags: jvm
disable_search: true
# Codecov returns an authentication error until the upstream repository is enabled there.
Expand Down
153 changes: 138 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ Instead of switching hands between your physical keyboard and phone touch screen

### Why Input Leaf?

- **No root required** — Works out-of-the-box using Shizuku or Android Accessibility APIs.
- **No root required** — Works out-of-the-box using Shizuku or Android Accessibility APIs. Rooted devices can skip Shizuku and grant `su` for the same native HID path.
- **Ultra-low latency** — Shizuku engine injects native system-level input events directly for zero perceptible lag.
- **Full mouse and keyboard sharing** — Smooth cursor control, left/right/middle click, wheel scrolling, and hardware modifier keys (`Ctrl`, `Alt`, `Shift`, `Meta`).
- **Compatible with Input Leap & Deskflow** — Speaks the standard open-source KVM protocol over TCP.
- **100% local and private** — All communication stays on your local area network (LAN); no cloud relays or external servers.
- **mDNS server auto-discovery** — Finds compatible servers on your local Wi-Fi automatically.
- **Encrypted TLS connections** — Supports TLS encryption with Trust-On-First-Use (TOFU) certificate pinning.
- **Battery efficient and resilient** — Automatic exponential-backoff reconnects when network interruptions occur.
- **Material 3 Expressive design** — Dynamic color on Android 12+, light and dark themes, and edge-to-edge screens.

---

Expand Down Expand Up @@ -68,7 +69,7 @@ Input Leaf acts as the **client** on your Android device. You will need a compat
│ │ │
│ ┌──────────┴──────────┐ │
│ ▼ ▼ │
│ Shizuku Engine Accessibility │
│ Shizuku or root Accessibility │
│ (System Injection) (Stock Fallback) │
│ • Lowest latency • Zero extra apps │
│ • Full shortcuts • Touch emulation │
Expand Down Expand Up @@ -103,8 +104,9 @@ Download and install either:

### 4. Choose an Input Injection Engine
Open Input Leaf and complete the quick onboarding wizard:
- **Shizuku (Recommended):** Open the [Shizuku app](https://shizuku.rikka.app/), start it via Wireless Debugging, and allow Input Leaf when prompted.
- **Accessibility Service (Fallback):** Enable the Input Leaf Accessibility Service and Keyboard in Android **Settings -> Accessibility**.
- **Shizuku (Recommended on unrooted devices):** Open the [Shizuku app](https://shizuku.rikka.app/), start it via Wireless Debugging, and allow Input Leaf when prompted.
- **Root (rooted devices):** Grant the Magisk/KernelSU `su` prompt. You do not need Shizuku if root is granted.
- **Accessibility Service (Fallback):** Enable the Input Leaf Accessibility Service and Keyboard in Android **Settings -> Accessibility**. Native HID still needs Shizuku or root.

### 5. Connect
1. Input Leaf will automatically discover the server on your local Wi-Fi. You can also tap **+** to enter the PC's IP address manually.
Expand Down Expand Up @@ -151,33 +153,154 @@ When using **Shizuku Mode**, Input Leaf passes through hardware-level keyboard s

## Screenshots

Material 3 Expressive UI with dynamic color (Android 12+) and a leaf-green fallback theme.

### Onboarding

<table align="center">
<tr>
<td align="center" width="33%">
<img src="docs/01_splash_screen.jpg" width="220" alt="Input Leaf Splash Screen"><br>
<b>Splash Screen</b>
<img src="docs/screenshots/01_onboarding_welcome.jpg" width="220" alt="Onboarding: Welcome"><br>
<b>Welcome</b>
</td>
<td align="center" width="33%">
<img src="docs/02_setup_flow.jpg" width="220" alt="Guided Setup Flow"><br>
<b>Guided Onboarding</b>
<img src="docs/screenshots/02_onboarding_input_method.jpg" width="220" alt="Onboarding: Choose an Input Method"><br>
<b>Choose an Input Method</b>
</td>
<td align="center" width="33%">
<img src="docs/03_shizuku_setup.jpg" width="220" alt="Shizuku Authorization"><br>
<b>Shizuku Permission</b>
<img src="docs/screenshots/03_onboarding_accessibility.jpg" width="220" alt="Onboarding: Accessibility Fallback"><br>
<b>Accessibility Fallback</b>
</td>
</tr>
</table>

### Home

<table align="center">
<tr>
<td align="center" width="33%">
<img src="docs/04_overlay_permission.jpg" width="220" alt="Overlay Permission"><br>
<b>System Overlay</b>
<img src="docs/screenshots/04_home_disconnected.jpg" width="220" alt="Home: Disconnected"><br>
<b>Disconnected</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/05_home_connecting.jpg" width="220" alt="Home: Connecting"><br>
<b>Connecting</b>
</td>
<td align="center" width="33%">
<img src="docs/06_home_screen.jpg" width="220" alt="Main Connection Screen"><br>
<img src="docs/screenshots/06_home_connected.jpg" width="220" alt="Home: Connected & Ready"><br>
<b>Connected & Ready</b>
</td>
</tr>
</table>

### Servers

<table align="center">
<tr>
<td align="center" width="33%">
<img src="docs/screenshots/07_servers_discovered.jpg" width="220" alt="Servers: Discovered Servers"><br>
<b>Discovered Servers</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/08_servers_favorite.jpg" width="220" alt="Servers: Favorites"><br>
<b>Favorites</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/11_servers_connected.jpg" width="220" alt="Servers: Connected Server"><br>
<b>Connected Server</b>
</td>
</tr>
<tr>
<td align="center" width="33%">
<img src="docs/screenshots/09_servers_add_manual.jpg" width="220" alt="Servers: Add Manually"><br>
<b>Add Manually</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/10_servers_trust_fingerprint.jpg" width="220" alt="Servers: Trust on First Use"><br>
<b>Trust on First Use</b>
</td>
</tr>
</table>

### Permissions

<table align="center">
<tr>
<td align="center" width="33%">
<img src="docs/screenshots/12_permissions_shizuku.jpg" width="220" alt="Permissions: Shizuku Ready"><br>
<b>Shizuku Ready</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/13_permissions_root.jpg" width="220" alt="Permissions: Root Access"><br>
<b>Root Access</b>
</td>
</tr>
</table>

### Settings

<table align="center">
<tr>
<td align="center" width="33%">
<img src="docs/screenshots/14_settings_connection_display.jpg" width="220" alt="Settings: Connection & Display"><br>
<b>Connection & Display</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/15_settings_security.jpg" width="220" alt="Settings: Security"><br>
<b>Security</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/16_settings_about.jpg" width="220" alt="Settings: About & Community"><br>
<b>About & Community</b>
</td>
</tr>
<tr>
<td align="center" width="33%">
<img src="docs/screenshots/17_dialog_connection_security.jpg" width="220" alt="Settings: Connection Security"><br>
<b>Connection Security</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/18_dialog_input_method.jpg" width="220" alt="Settings: Input Method"><br>
<b>Input Method</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/19_dialog_cursor_style.jpg" width="220" alt="Settings: Cursor Style"><br>
<b>Cursor Style</b>
</td>
</tr>
<tr>
<td align="center" width="33%">
<img src="docs/screenshots/20_dialog_screen_name.jpg" width="220" alt="Settings: Screen Name"><br>
<b>Screen Name</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/21_dialog_author.jpg" width="220" alt="Settings: Author Info"><br>
<b>Author Info</b>
</td>
</tr>
</table>

### Light Theme

<table align="center">
<tr>
<td align="center" width="33%">
<img src="docs/screenshots/22_light_home.jpg" width="220" alt="Light Theme: Home"><br>
<b>Home</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/23_light_servers.jpg" width="220" alt="Light Theme: Servers"><br>
<b>Servers</b>
</td>
<td align="center" width="33%">
<img src="docs/screenshots/24_light_permissions.jpg" width="220" alt="Light Theme: Permissions"><br>
<b>Permissions</b>
</td>
</tr>
<tr>
<td align="center" width="33%">
<img src="docs/07_settings_screen.jpg" width="220" alt="Settings Screen"><br>
<b>Custom Settings</b>
<img src="docs/screenshots/25_light_settings.jpg" width="220" alt="Light Theme: Settings"><br>
<b>Settings</b>
</td>
</tr>
</table>
Expand Down
8 changes: 6 additions & 2 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 = 8
versionName = "1.4.2"
versionCode = 9
versionName = "1.4.3"
// JUnit4 runner so the androidTest classes are discovered on the emulator
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down Expand Up @@ -114,6 +114,10 @@ dependencies {
// Shizuku for privileged input injection without root
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)

// libsu RootService: same IInputInjector AIDL as Shizuku, running as uid 0
implementation(libs.libsu.core)
implementation(libs.libsu.service)

testImplementation(libs.junit)
testImplementation(libs.mockito.core)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ class MainActivitySmokeTest {
}

@Test
fun nextButtonAdvancesToShizukuPermissionPage() {
fun nextButtonAdvancesToPermissionMethodsPage() {
waitAndAssertText("Welcome to Input Leaf")

composeRule.onNodeWithText("Next").performClick()

waitAndAssertText("Shizuku Setup (Optional)")
// The page heading is on screen. "Non-root Accessibility" is the third
// accordion row and sits below the fold, so assertIsDisplayed fails on it.
waitAndAssertText("Choose an input method")
}
}
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.ACCESS_SUPERUSER"/>

<application
android:name=".InputLeafApplication"
Expand All @@ -28,6 +29,10 @@
android:name=".service.ConnectionService"
android:foregroundServiceType="connectedDevice"
android:exported="false"/>
<!-- libsu RootService: no android:process; the library starts uid 0 itself. -->
<service
android:name=".privilege.RootInputInjectorService"
android:exported="false"/>
<service
android:name=".service.CursorOverlayService"
android:exported="false"/>
Expand Down
13 changes: 10 additions & 3 deletions app/src/main/java/com/inputleaf/android/InputLeafApplication.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
package com.inputleaf.android

import android.app.Application
import android.util.Log
import android.widget.Toast
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import com.topjohnwu.superuser.Shell

class InputLeafApplication : Application() {
override fun onCreate() {
super.onCreate()


runCatching {
Shell.setDefaultBuilder(
Shell.Builder.create().setTimeout(10)
)
}

val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Log.e("InputLeafCrash", "Uncaught exception in thread ${thread.name}", throwable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import android.provider.Settings
import android.util.Log
import android.view.KeyEvent
import com.inputleaf.android.model.InputLeapEvent
import com.inputleaf.android.privilege.PrivilegeKind
import com.inputleaf.android.privilege.PrivilegedInjectorFactory
import com.inputleaf.android.privilege.snapshotForLatentHid
import com.inputleaf.android.shizuku.ShizukuInputInjector
import kotlinx.coroutines.delay

Expand All @@ -13,11 +16,19 @@ private const val TAG = "AccessibilityInputInjector"
class AccessibilityInputInjector(
private val context: Context,
private var screenWidth: Int,
private var screenHeight: Int
private var screenHeight: Int,
private val hidKeyboard: ShizukuInputInjector = PrivilegedInjectorFactory.create(
context,
screenWidth,
screenHeight,
snapshot = snapshotForLatentHid(PrivilegedInjectorFactory.currentSnapshot()),
),
) : InputInjector {

override val name: String = "Accessibility Service (no extra app)"

override fun privilegeKind(): PrivilegeKind = hidKeyboard.privilegeKind()

override fun updateScreenSize(width: Int, height: Int) {
screenWidth = width
screenHeight = height
Expand All @@ -32,7 +43,6 @@ class AccessibilityInputInjector(
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 @@ -62,7 +72,8 @@ class AccessibilityInputInjector(
if (connected) {
Log.d(TAG, "Accessibility service connected successfully")
if (hidKeyboard.isAvailable()) {
hidKeyboard.connect()
val hidBound = hidKeyboard.connect()
Log.i(TAG, "Privileged HID bind after accessibility: $hidBound kind=${hidKeyboard.privilegeKind()}")
}
} else {
Log.e(TAG, "Accessibility service connection timeout")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.inputleaf.android.inject

import com.inputleaf.android.model.InputLeapEvent
import com.inputleaf.android.privilege.PrivilegeKind

interface InputInjector {
suspend fun connect(): Boolean
fun send(event: InputLeapEvent)
fun disconnect()
fun isAvailable(): Boolean
val name: String
fun privilegeKind(): PrivilegeKind = PrivilegeKind.NONE
fun setHidKeyboardAttached(attached: Boolean) {}
fun setHidMouseAttached(attached: Boolean) {}
fun usesNativePointer(): Boolean = false
Expand Down
Loading
Loading