diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4f131a57..8ca5bf97 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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.
diff --git a/README.md b/README.md
index f548ebe6..e94d9c4f 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ 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.
@@ -32,6 +32,7 @@ Instead of switching hands between your physical keyboard and phone touch screen
- **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.
---
@@ -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 │
@@ -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.
@@ -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
+
- 
- Splash Screen
+ 
+ Welcome
|
- 
- Guided Onboarding
+ 
+ Choose an Input Method
|
- 
- Shizuku Permission
+ 
+ Accessibility Fallback
|
+
+
+### Home
+
+
- 
- System Overlay
+ 
+ Disconnected
+ |
+
+ 
+ Connecting
|
- 
+ 
Connected & Ready
|
+
+
+
+### Servers
+
+
+
+
+ 
+ Discovered Servers
+ |
+
+ 
+ Favorites
+ |
+
+ 
+ Connected Server
+ |
+
+
+
+ 
+ Add Manually
+ |
+
+ 
+ Trust on First Use
+ |
+
+
+
+### Permissions
+
+
+
+
+ 
+ Shizuku Ready
+ |
+
+ 
+ Root Access
+ |
+
+
+
+### Settings
+
+
+
+
+ 
+ Connection & Display
+ |
+
+ 
+ Security
+ |
+
+ 
+ About & Community
+ |
+
+
+
+ 
+ Connection Security
+ |
+
+ 
+ Input Method
+ |
+
+ 
+ Cursor Style
+ |
+
+
+
+ 
+ Screen Name
+ |
+
+ 
+ Author Info
+ |
+
+
+
+### Light Theme
+
+
+
+
+ 
+ Home
+ |
+
+ 
+ Servers
+ |
+
+ 
+ Permissions
+ |
+
+
- 
- Custom Settings
+ 
+ Settings
|
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bff59758..df897253 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -22,8 +22,8 @@ android {
applicationId = "com.inputleaf.android"
minSdk = 26
targetSdk = 34
- versionCode = 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"
}
@@ -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)
diff --git a/app/src/androidTest/java/com/inputleaf/android/ui/MainActivitySmokeTest.kt b/app/src/androidTest/java/com/inputleaf/android/ui/MainActivitySmokeTest.kt
index 0d3671ed..5d366153 100644
--- a/app/src/androidTest/java/com/inputleaf/android/ui/MainActivitySmokeTest.kt
+++ b/app/src/androidTest/java/com/inputleaf/android/ui/MainActivitySmokeTest.kt
@@ -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")
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5ac17aed..3f3fe327 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
+
+
diff --git a/app/src/main/java/com/inputleaf/android/InputLeafApplication.kt b/app/src/main/java/com/inputleaf/android/InputLeafApplication.kt
index 10970723..7298efb0 100644
--- a/app/src/main/java/com/inputleaf/android/InputLeafApplication.kt
+++ b/app/src/main/java/com/inputleaf/android/InputLeafApplication.kt
@@ -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)
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 dd568fe8..472b24fb 100644
--- a/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputInjector.kt
+++ b/app/src/main/java/com/inputleaf/android/inject/AccessibilityInputInjector.kt
@@ -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
@@ -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
@@ -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 {
@@ -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")
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 81d595e1..949d6370 100644
--- a/app/src/main/java/com/inputleaf/android/inject/InputInjector.kt
+++ b/app/src/main/java/com/inputleaf/android/inject/InputInjector.kt
@@ -1,6 +1,7 @@
package com.inputleaf.android.inject
import com.inputleaf.android.model.InputLeapEvent
+import com.inputleaf.android.privilege.PrivilegeKind
interface InputInjector {
suspend fun connect(): Boolean
@@ -8,6 +9,7 @@ interface InputInjector {
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
diff --git a/app/src/main/java/com/inputleaf/android/privilege/PrivilegeKind.kt b/app/src/main/java/com/inputleaf/android/privilege/PrivilegeKind.kt
new file mode 100644
index 00000000..655a57c5
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/privilege/PrivilegeKind.kt
@@ -0,0 +1,29 @@
+package com.inputleaf.android.privilege
+
+enum class PrivilegeKind {
+ SHIZUKU,
+ ROOT,
+ NONE,
+}
+
+enum class RootAvailability {
+ MISSING,
+ UNKNOWN,
+ DENIED,
+ GRANTED,
+ ;
+
+ fun canUse(): Boolean = this == UNKNOWN || this == GRANTED
+}
+
+data class PrivilegeSnapshot(
+ val shizukuReady: Boolean,
+ val root: RootAvailability,
+)
+
+enum class ResolvedInjector {
+ PRIVILEGED_SHIZUKU,
+ PRIVILEGED_ROOT,
+ ACCESSIBILITY,
+ NONE,
+}
diff --git a/app/src/main/java/com/inputleaf/android/privilege/PrivilegeSelector.kt b/app/src/main/java/com/inputleaf/android/privilege/PrivilegeSelector.kt
new file mode 100644
index 00000000..a603cc70
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/privilege/PrivilegeSelector.kt
@@ -0,0 +1,67 @@
+package com.inputleaf.android.privilege
+
+/**
+ * Chooses how Input Leaf opens `/dev/uhid` and injects events.
+ *
+ * Preference is Shizuku (already working) then root (`su`), then none.
+ * Accessibility is not a privilege path; the caller may still use it as
+ * overlay/IME fallback when this returns [PrivilegeKind.NONE].
+ */
+/**
+ * Snapshot used when Accessibility keeps a latent HID injector.
+ * [RootAvailability.UNKNOWN] still [RootAvailability.canUse], but binding it runs
+ * `su` and can prompt. Only an already granted shell may attach.
+ */
+internal fun snapshotForLatentHid(snapshot: PrivilegeSnapshot): PrivilegeSnapshot {
+ if (snapshot.root == RootAvailability.GRANTED) return snapshot
+ return snapshot.copy(root = RootAvailability.MISSING)
+}
+
+object PrivilegeSelector {
+ fun select(snapshot: PrivilegeSnapshot): PrivilegeKind {
+ return when {
+ snapshot.shizukuReady -> PrivilegeKind.SHIZUKU
+ snapshot.root.canUse() -> PrivilegeKind.ROOT
+ else -> PrivilegeKind.NONE
+ }
+ }
+}
+
+object InjectorMethodResolver {
+ fun resolve(
+ method: String,
+ snapshot: PrivilegeSnapshot,
+ accessibilityAvailable: Boolean,
+ ): ResolvedInjector {
+ return when (method) {
+ "shizuku" ->
+ if (snapshot.shizukuReady) {
+ ResolvedInjector.PRIVILEGED_SHIZUKU
+ } else {
+ ResolvedInjector.NONE
+ }
+ "root" ->
+ if (snapshot.root.canUse()) {
+ ResolvedInjector.PRIVILEGED_ROOT
+ } else {
+ ResolvedInjector.NONE
+ }
+ "accessibility" ->
+ if (accessibilityAvailable) {
+ ResolvedInjector.ACCESSIBILITY
+ } else {
+ ResolvedInjector.NONE
+ }
+ else -> when (PrivilegeSelector.select(snapshot)) {
+ PrivilegeKind.SHIZUKU -> ResolvedInjector.PRIVILEGED_SHIZUKU
+ PrivilegeKind.ROOT -> ResolvedInjector.PRIVILEGED_ROOT
+ PrivilegeKind.NONE ->
+ if (accessibilityAvailable) {
+ ResolvedInjector.ACCESSIBILITY
+ } else {
+ ResolvedInjector.NONE
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/privilege/PrivilegedUserServiceHost.kt b/app/src/main/java/com/inputleaf/android/privilege/PrivilegedUserServiceHost.kt
new file mode 100644
index 00000000..9d6b81a4
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/privilege/PrivilegedUserServiceHost.kt
@@ -0,0 +1,154 @@
+package com.inputleaf.android.privilege
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.ServiceConnection
+import android.content.pm.PackageManager
+import android.os.Handler
+import android.os.Looper
+import com.inputleaf.android.shizuku.InputInjectorService
+import com.inputleaf.android.shizuku.ShizukuInputInjector
+import com.topjohnwu.superuser.ipc.RootService
+import rikka.shizuku.Shizuku
+
+/**
+ * Starts the privileged [com.inputleaf.android.shizuku.IInputInjector] process.
+ * After bind, enter/leave/HID reports are the same AIDL calls regardless of [kind].
+ */
+internal interface PrivilegedUserServiceHost {
+ val kind: PrivilegeKind
+ val displayName: String
+ val bindTimeoutMs: Long
+
+ /** How many times [bind] is worth retrying before the caller gives up. */
+ val bindAttempts: Int get() = 3
+ fun isAvailable(): Boolean
+
+ /** @return false when the bind could not even be started, so the caller fails fast. */
+ fun bind(connection: ServiceConnection): Boolean
+ fun unbind(connection: ServiceConnection, destroy: Boolean)
+}
+
+internal class ShizukuUserServiceHost : PrivilegedUserServiceHost {
+ override val kind: PrivilegeKind = PrivilegeKind.SHIZUKU
+ override val displayName: String = "Shizuku (ADB-level injection)"
+ override val bindTimeoutMs: Long = 10_000L
+
+ private val serviceArgs = Shizuku.UserServiceArgs(
+ ComponentName(
+ "com.inputleaf.android",
+ InputInjectorService::class.java.name,
+ ),
+ ).daemon(false).processNameSuffix("input_injector").version(SHIZUKU_SERVICE_VERSION)
+
+ override fun isAvailable(): Boolean {
+ return try {
+ Shizuku.pingBinder() &&
+ Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ override fun bind(connection: ServiceConnection): Boolean {
+ return runCatching { Shizuku.bindUserService(serviceArgs, connection) }.isSuccess
+ }
+
+ override fun unbind(connection: ServiceConnection, destroy: Boolean) {
+ Shizuku.unbindUserService(serviceArgs, connection, destroy)
+ }
+
+ companion object {
+ // 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 SHIZUKU_SERVICE_VERSION = 6
+ }
+}
+
+internal class RootUserServiceHost(
+ context: Context,
+ private val rootAccess: RootAccess = LibSuRootAccess,
+) : PrivilegedUserServiceHost {
+ override val kind: PrivilegeKind = PrivilegeKind.ROOT
+ override val displayName: String = "Root (physical HID devices)"
+ // One su fork plus a root process start; three 60s attempts would freeze connect()
+ // for three minutes on a device where root is simply never granted.
+ override val bindTimeoutMs: Long = 30_000L
+ override val bindAttempts: Int = 2
+
+ private val appContext = context.applicationContext ?: context
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val bindIntent = RootInputInjectorService.bindIntent(appContext)
+
+ override fun isAvailable(): Boolean = rootAccess.availability().canUse()
+
+ override fun bind(connection: ServiceConnection): Boolean {
+ // RootService.bind must run on the main thread and throws there on a bad intent,
+ // which would crash the app instead of failing this attempt.
+ runOnMain {
+ runCatching { RootService.bind(bindIntent, connection) }
+ .onFailure { android.util.Log.w("RootUserServiceHost", "RootService.bind failed", it) }
+ }
+ return true
+ }
+
+ override fun unbind(connection: ServiceConnection, destroy: Boolean) {
+ runOnMain {
+ runCatching { RootService.unbind(connection) }
+ if (destroy) {
+ runCatching { RootService.stop(bindIntent) }
+ }
+ }
+ }
+
+ private fun runOnMain(block: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ block()
+ } else {
+ mainHandler.post(block)
+ }
+ }
+}
+
+internal object PrivilegedInjectorFactory {
+ fun create(
+ context: Context,
+ screenWidth: Int,
+ screenHeight: Int,
+ snapshot: PrivilegeSnapshot = currentSnapshot(),
+ method: String = "auto",
+ rootAccess: RootAccess = LibSuRootAccess,
+ ): ShizukuInputInjector {
+ val host = hostFor(context, kindFor(method, snapshot), rootAccess)
+ return ShizukuInputInjector(screenWidth, screenHeight, host)
+ }
+
+ fun kindFor(method: String, snapshot: PrivilegeSnapshot): PrivilegeKind {
+ return when (InjectorMethodResolver.resolve(method, snapshot, accessibilityAvailable = false)) {
+ ResolvedInjector.PRIVILEGED_ROOT -> PrivilegeKind.ROOT
+ ResolvedInjector.PRIVILEGED_SHIZUKU -> PrivilegeKind.SHIZUKU
+ // Falling back to PrivilegeSelector here would turn an explicit "shizuku"
+ // preference into ROOT and pop an su prompt the user never asked for.
+ ResolvedInjector.ACCESSIBILITY, ResolvedInjector.NONE -> PrivilegeKind.NONE
+ }
+ }
+
+ fun hostFor(
+ context: Context,
+ kind: PrivilegeKind,
+ rootAccess: RootAccess = LibSuRootAccess,
+ ): PrivilegedUserServiceHost {
+ return when (kind) {
+ PrivilegeKind.SHIZUKU -> ShizukuUserServiceHost()
+ PrivilegeKind.ROOT -> RootUserServiceHost(context, rootAccess)
+ PrivilegeKind.NONE -> ShizukuUserServiceHost()
+ }
+ }
+
+ fun currentSnapshot(rootAccess: RootAccess = LibSuRootAccess): PrivilegeSnapshot {
+ return PrivilegeSnapshot(
+ shizukuReady = ShizukuUserServiceHost().isAvailable(),
+ root = rootAccess.availability(),
+ )
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/privilege/RootAccess.kt b/app/src/main/java/com/inputleaf/android/privilege/RootAccess.kt
new file mode 100644
index 00000000..bf895b9d
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/privilege/RootAccess.kt
@@ -0,0 +1,85 @@
+package com.inputleaf.android.privilege
+
+import com.topjohnwu.superuser.Shell
+import java.io.File
+
+interface RootAccess {
+ fun availability(): RootAvailability
+ fun requestAccess(): Boolean
+}
+
+/**
+ * libsu-backed root probe. [availability] never prompts; [requestAccess] may show
+ * the Magisk/KernelSU `su` dialog and must not run on the main thread.
+ *
+ * KernelSU hides `/system/bin/su` from ungranted app UIDs, so [File.exists] alone
+ * would report [RootAvailability.MISSING] on a rooted phone and skip HID attach.
+ */
+object LibSuRootAccess : RootAccess {
+ /** Overridable in tests so availability can see a marker without a real su binary. */
+ internal var pathExists: (String) -> Boolean = { File(it).exists() }
+
+ private val suPaths = listOf(
+ "/system/bin/su",
+ "/system/xbin/su",
+ "/sbin/su",
+ "/su/bin/su",
+ "/vendor/bin/su",
+ "/debug_ramdisk/su",
+ "/system/bin/.ext/su",
+ "/odm/bin/su",
+ )
+
+ private val rootedMarkers = listOf(
+ "/sys/module/kernelsu",
+ "/proc/ksu",
+ "/debug_ramdisk/.magisk",
+ "/sbin/.magisk",
+ "/data/adb/magisk",
+ )
+
+ override fun availability(): RootAvailability {
+ return when (grantedOrNull()) {
+ true -> RootAvailability.GRANTED
+ false -> RootAvailability.DENIED
+ null -> if (isSuPresent() || hasRootMarker()) {
+ RootAvailability.UNKNOWN
+ } else {
+ RootAvailability.MISSING
+ }
+ }
+ }
+
+ override fun requestAccess(): Boolean {
+ // libsu caches the main shell process-wide. A denied or timed-out su prompt caches
+ // a non-root shell, and every later getShell() would hand that same one back — so
+ // "Grant root" could never succeed again without an app restart. Drop it first.
+ runCatching {
+ val cached = Shell.getCachedShell()
+ if (cached != null && !cached.isRoot) {
+ cached.close()
+ }
+ }
+ return try {
+ Shell.getShell().isRoot
+ } catch (_: Throwable) {
+ false
+ }
+ }
+
+ internal fun isSuPresent(exists: (String) -> Boolean = pathExists): Boolean {
+ return suPaths.any(exists)
+ }
+
+ internal fun hasRootMarker(exists: (String) -> Boolean = pathExists): Boolean {
+ return rootedMarkers.any(exists)
+ }
+
+ private fun grantedOrNull(): Boolean? {
+ return try {
+ Shell.isAppGrantedRoot()
+ } catch (_: Throwable) {
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/privilege/RootInputInjectorService.kt b/app/src/main/java/com/inputleaf/android/privilege/RootInputInjectorService.kt
new file mode 100644
index 00000000..973d9111
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/privilege/RootInputInjectorService.kt
@@ -0,0 +1,52 @@
+package com.inputleaf.android.privilege
+
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import com.inputleaf.android.shizuku.InputInjectorService
+import com.topjohnwu.superuser.ipc.RootService
+
+/**
+ * Long-lived uid-0 host for the same [InputInjectorService] AIDL stub Shizuku uses.
+ * Root's only job is opening `/dev/uhid`; enter/leave stay CREATE2/DESTROY on that stub.
+ *
+ * Daemon mode keeps the process warm while the app is bound so Enter/Leave never
+ * re-fork `su`. Bump [SERVICE_VERSION] if the stub contract changes so a leaked
+ * root process is not reused.
+ */
+class RootInputInjectorService : RootService() {
+
+ private val stub = InputInjectorService()
+
+ override fun onBind(intent: Intent): IBinder {
+ android.util.Log.i(
+ TAG,
+ "onBind version=$SERVICE_VERSION pid=${android.os.Process.myPid()} uid=${android.os.Process.myUid()}",
+ )
+ return stub
+ }
+
+ override fun onUnbind(intent: Intent): Boolean {
+ // Daemon stays warm. Leave DESTROY is the client's closeVirtual* AIDL, not unbind.
+ android.util.Log.i(TAG, "onUnbind pid=${android.os.Process.myPid()}")
+ return true
+ }
+
+ override fun onDestroy() {
+ stub.destroy()
+ }
+
+ companion object {
+ private const val TAG = "RootInputInjector"
+ // 5: stub now includes attachClient and the 1.4.2 HID lifecycle. A daemon
+ // left over from the parked branch (v4) must not be reused.
+ const val SERVICE_VERSION = 5
+ const val CATEGORY_VERSION = "com.inputleaf.android.root.injector.v$SERVICE_VERSION"
+
+ fun bindIntent(context: Context): Intent {
+ return Intent(context, RootInputInjectorService::class.java)
+ .addCategory(CATEGORY_VERSION)
+ .addCategory(RootService.CATEGORY_DAEMON_MODE)
+ }
+ }
+}
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 121fc057..d1942ec8 100644
--- a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt
+++ b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt
@@ -24,6 +24,8 @@ import com.inputleaf.android.network.ConnectionTransportPolicy
import com.inputleaf.android.network.InputLeapConnection
import com.inputleaf.android.network.ServerTransport
import com.inputleaf.android.network.TlsFingerprintManager
+import com.inputleaf.android.privilege.PrivilegeKind
+import com.inputleaf.android.privilege.PrivilegedInjectorFactory
import com.inputleaf.android.shizuku.ShizukuInputInjector
import com.inputleaf.android.storage.AppPreferences
import com.inputleaf.android.storage.ClientCertificateStore
@@ -363,9 +365,9 @@ class ConnectionService : Service() {
stateMachine.onKeepAlive()
injector?.updatePointerSpeed(readPointerSpeed())
injector?.onHidMouseEnter(event.x, event.y)
- applyCursorOverlay()
setHidKeyboardAttached(keyboardEnabled)
setHidMouseAttached(mouseEnabled)
+ applyCursorOverlay()
}
is InputLeapEvent.Leave -> {
injector?.onHidMouseLeave()
@@ -452,6 +454,11 @@ class ConnectionService : Service() {
native = inj?.nativePointerState() ?: NativePointerState.NONE,
expectsNativePointer = inj?.expectsNativePointer() == true,
)
+ Log.i(
+ TAG,
+ "Cursor overlay show=$show native=${inj?.nativePointerState()} " +
+ "expectsNative=${inj?.expectsNativePointer() == true} onScreen=$pointerOnScreen",
+ )
if (show) {
showCursorOverlay()
} else {
@@ -580,7 +587,7 @@ class ConnectionService : Service() {
}
if (injector is ShizukuInputInjector) {
injector.onServiceDisconnectedCallback = {
- handleShizukuServiceDisconnected()
+ handlePrivilegedServiceDisconnected()
}
}
if (pointerOnScreen) {
@@ -764,50 +771,68 @@ class ConnectionService : Service() {
}
private fun handleShizukuDied() {
- if (injector is ShizukuInputInjector) {
+ // AccessibilityInputInjector also reports SHIZUKU (it holds a latent Shizuku host),
+ // so the kind alone would tear down a working Accessibility session.
+ val inj = injector
+ if (inj is ShizukuInputInjector && inj.privilegeKind() == PrivilegeKind.SHIZUKU) {
Log.w(TAG, "Shizuku binder died while using Shizuku injector; disconnecting injector")
injector?.disconnect()
- handleShizukuServiceDisconnected()
+ handlePrivilegedServiceDisconnected()
}
}
- private fun handleShizukuServiceDisconnected() {
- Log.w(TAG, "Shizuku UserService disconnected mid-session")
- triggerShizukuRecovery(delayMs = 300L)
+ private fun handlePrivilegedServiceDisconnected() {
+ Log.w(TAG, "Privileged UserService disconnected mid-session")
+ triggerPrivilegedRecovery(delayMs = 300L)
}
private fun handleShizukuRestarted() {
+ if (shouldIgnoreShizukuRestart(injector)) {
+ Log.d(TAG, "Ignoring Shizuku restart because root HID injector is active")
+ return
+ }
Log.i(TAG, "Shizuku service restarted")
- triggerShizukuRecovery(delayMs = 600L)
+ triggerPrivilegedRecovery(delayMs = 600L)
}
- private fun triggerShizukuRecovery(delayMs: Long) {
+ private fun triggerPrivilegedRecovery(delayMs: Long) {
val ip = activeServerIp ?: return
val name = activeScreenName ?: return
if (userInitiatedDisconnect) return
shizukuRecoveryJob?.cancel()
shizukuRecoveryJob = scope.launch {
- // Check if user specifically configured accessibility mode in prefs
val preferredMethod = prefs.inputMethod.first()
if (preferredMethod == "accessibility") {
- Log.d(TAG, "Skipping Shizuku recovery because preferred method is Accessibility")
+ Log.d(TAG, "Skipping privileged recovery because preferred method is Accessibility")
return@launch
}
delay(delayMs)
if (userInitiatedDisconnect || activeServerIp != ip) return@launch
- Log.i(TAG, "Attempting auto-recovery of Shizuku session to $ip")
+ Log.i(TAG, "Attempting auto-recovery of privileged session to $ip")
val bounds = getScreenBounds()
- val newInjector = ShizukuInputInjector(bounds.width(), bounds.height())
+ val snapshot = PrivilegedInjectorFactory.currentSnapshot()
+ val recoveryKind = privilegedRecoveryKind(preferredMethod, snapshot)
+ if (recoveryKind == null) {
+ Log.w(TAG, "Skipping privileged recovery; $preferredMethod cannot attach")
+ return@launch
+ }
+ val newInjector = PrivilegedInjectorFactory.create(
+ this@ConnectionService,
+ bounds.width(),
+ bounds.height(),
+ snapshot,
+ preferredMethod,
+ )
if (newInjector.isAvailable() && newInjector.connect()) {
- Log.i(TAG, "Shizuku injector recovered successfully; reconnecting session to $ip")
+ Log.i(TAG, "Privileged injector recovered (${newInjector.name}); reconnecting session to $ip")
setInjector(newInjector)
reconnect(ip, name)
} else {
- Log.w(TAG, "Could not recover Shizuku injector (not ready or permission missing)")
+ Log.w(TAG, "Could not recover privileged injector (Shizuku/root not ready)")
newInjector.disconnect()
}
}
@@ -843,6 +868,23 @@ internal enum class ConnectAttemptOutcome {
TerminalFailure,
}
+/** True only for the root host itself. Accessibility reports that host's kind too. */
+internal fun shouldIgnoreShizukuRestart(injector: com.inputleaf.android.inject.InputInjector?): Boolean {
+ return injector is ShizukuInputInjector && injector.privilegeKind() == PrivilegeKind.ROOT
+}
+
+/**
+ * Kind to reconnect, or null when the saved method cannot attach.
+ * [PrivilegeKind.NONE] must not fall through to the other host.
+ */
+internal fun privilegedRecoveryKind(
+ method: String,
+ snapshot: com.inputleaf.android.privilege.PrivilegeSnapshot,
+): PrivilegeKind? {
+ val kind = PrivilegedInjectorFactory.kindFor(method, snapshot)
+ return if (kind == PrivilegeKind.NONE) null else kind
+}
+
internal fun shouldClearActiveSession(outcome: ConnectAttemptOutcome): Boolean =
outcome == ConnectAttemptOutcome.Rejected ||
outcome == ConnectAttemptOutcome.TerminalFailure
diff --git a/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt b/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt
index d2560b38..3591c2d1 100644
--- a/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt
+++ b/app/src/main/java/com/inputleaf/android/shizuku/InputInjectorService.kt
@@ -10,11 +10,12 @@ import com.inputleaf.android.shizuku.uhid.HidMouseEnterWarp
import com.inputleaf.android.shizuku.uhid.UhidChannel
/**
- * Shizuku UserService that runs with shell (ADB) privileges.
+ * Privileged injector stub. Shizuku runs it as a UserService with shell privileges.
+ * [com.inputleaf.android.privilege.RootInputInjectorService] hosts the same stub at uid 0;
+ * root's only job is opening `/dev/uhid`, and enter/leave/HID reports stay these AIDL calls.
+ *
* This service can call InputManager.injectInputEvent() because the shell user
* has the INJECT_EVENTS permission.
- *
- * This class is instantiated by Shizuku in a separate process with elevated privileges.
*/
class InputInjectorService : IInputInjector.Stub {
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 d9b2d032..4a0c92d8 100644
--- a/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt
+++ b/app/src/main/java/com/inputleaf/android/shizuku/ShizukuInputInjector.kt
@@ -2,7 +2,6 @@ package com.inputleaf.android.shizuku
import android.content.ComponentName
import android.content.ServiceConnection
-import android.content.pm.PackageManager
import android.os.DeadObjectException
import android.os.IBinder
import android.os.RemoteException
@@ -19,6 +18,9 @@ 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.privilege.PrivilegeKind
+import com.inputleaf.android.privilege.PrivilegedUserServiceHost
+import com.inputleaf.android.privilege.ShizukuUserServiceHost
import com.inputleaf.android.shizuku.uhid.HidMouseState
import com.inputleaf.android.shizuku.uhid.MouseEdgeAnchor
import com.inputleaf.android.shizuku.uhid.WheelNotchAccumulator
@@ -26,18 +28,23 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
-import rikka.shizuku.Shizuku
private const val TAG = "ShizukuInputInjector"
-// Bumped for the attachClient and Enter-warp AIDL additions: a cached older UserService
-// does not implement them and would throw on every bind.
-private const val SERVICE_VERSION = 6
-class ShizukuInputInjector(
+class ShizukuInputInjector internal constructor(
screenWidth: Int,
screenHeight: Int,
+ private val host: PrivilegedUserServiceHost,
) : InputInjector {
- override val name: String = "Shizuku (ADB-level injection)"
+ constructor(screenWidth: Int, screenHeight: Int) : this(
+ screenWidth,
+ screenHeight,
+ ShizukuUserServiceHost(),
+ )
+
+ override val name: String get() = host.displayName
+
+ override fun privilegeKind(): PrivilegeKind = host.kind
private var screenWidth = screenWidth
private var screenHeight = screenHeight
@@ -66,16 +73,9 @@ class ShizukuInputInjector(
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").version(SERVICE_VERSION)
-
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
- Log.d(TAG, "Shizuku service connected")
+ Log.d(TAG, "${host.kind} service connected")
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.
@@ -87,7 +87,7 @@ class ShizukuInputInjector(
}
override fun onServiceDisconnected(name: ComponentName?) {
- Log.d(TAG, "Shizuku service disconnected")
+ Log.d(TAG, "${host.kind} service disconnected")
notifyDisconnected()
connectDeferred?.complete(false)
}
@@ -128,35 +128,31 @@ class ShizukuInputInjector(
nativePointerListener?.invoke(state)
}
- override fun isAvailable(): Boolean {
- return try {
- Shizuku.pingBinder() &&
- Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
- } catch (e: Exception) {
- false
- }
- }
+ override fun isAvailable(): Boolean = host.isAvailable()
override suspend fun connect(): Boolean {
if (!isAvailable()) {
- Log.e(TAG, "Shizuku not available or permission not granted")
+ Log.e(TAG, "${host.kind} not available or permission not granted")
return false
}
if (isBound && service != null) {
return true
}
- repeat(3) { attempt ->
+ val attempts = host.bindAttempts.coerceAtLeast(1)
+ repeat(attempts) { attempt ->
if (bindOnce()) return true
- Log.w(TAG, "Shizuku bind attempt ${attempt + 1}/3 failed")
- runCatching {
- Shizuku.unbindUserService(serviceArgs, serviceConnection, true)
- }
+ Log.w(TAG, "${host.kind} bind attempt ${attempt + 1}/$attempts failed")
+ runCatching { host.unbind(serviceConnection, destroy = false) }
isBound = false
service = null
delay(400)
- if (!isAvailable()) return false
+ if (!isAvailable()) {
+ runCatching { host.unbind(serviceConnection, destroy = true) }
+ return false
+ }
}
+ runCatching { host.unbind(serviceConnection, destroy = true) }
return false
}
@@ -164,13 +160,16 @@ class ShizukuInputInjector(
val deferred = CompletableDeferred()
connectDeferred = deferred
return try {
- Shizuku.bindUserService(serviceArgs, serviceConnection)
- withTimeout(10_000) { deferred.await() }
+ if (!host.bind(serviceConnection)) {
+ Log.e(TAG, "${host.kind} bind could not be started")
+ return false
+ }
+ withTimeout(host.bindTimeoutMs) { deferred.await() }
} catch (e: TimeoutCancellationException) {
- Log.e(TAG, "Shizuku service bind timeout")
+ Log.e(TAG, "${host.kind} service bind timeout")
false
} catch (e: Exception) {
- Log.e(TAG, "Failed to bind Shizuku service", e)
+ Log.e(TAG, "Failed to bind ${host.kind} service", e)
false
} finally {
connectDeferred = null
@@ -185,9 +184,9 @@ class ShizukuInputInjector(
runCatching { service?.closeVirtualMouse() }
hidMouse.detach()
service?.destroy()
- Shizuku.unbindUserService(serviceArgs, serviceConnection, true)
+ host.unbind(serviceConnection, destroy = true)
} catch (e: Exception) {
- Log.w(TAG, "Error unbinding Shizuku service", e)
+ Log.w(TAG, "Error unbinding ${host.kind} service", e)
}
service = null
isBound = false
@@ -199,8 +198,11 @@ class ShizukuInputInjector(
}
override fun setHidKeyboardAttached(attached: Boolean) {
- val svc = service ?: return
- try {
+ if (service == null) {
+ Log.w(TAG, "HID keyboard attach=$attached skipped; ${host.kind} service not bound")
+ return
+ }
+ remote("Failed to update HID keyboard attachment", Unit) { svc ->
if (attached) {
if (svc.openVirtualKeyboard()) {
Log.i(TAG, "HID keyboard attached")
@@ -211,42 +213,35 @@ class ShizukuInputInjector(
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
+ val svc = service
+ if (svc == null) {
+ if (attached) {
+ publishNativePointerState(NativePointerState.FALLBACK)
+ }
+ Log.w(TAG, "HID mouse attach=$attached skipped; ${host.kind} service not bound")
+ return
+ }
if (!attached) {
- try {
- svc.closeVirtualMouse()
+ remote("Failed to detach HID mouse", Unit) { s ->
+ runCatching { s.onHidMouseLeave() }
+ s.closeVirtualMouse()
hidMouse.detach()
wheelNotches.reset()
clientClosedMouse = true
publishNativePointerState(NativePointerState.NONE)
Log.i(TAG, "HID mouse detached")
- } catch (e: DeadObjectException) {
- Log.w(TAG, "Shizuku service binder is dead", e)
- notifyDisconnected()
- } catch (e: Exception) {
- handleRemoteException(e, "Failed to detach HID mouse")
}
return
}
if (hidMouse.attached) {
publishNativePointerState(NativePointerState.ACTIVE)
- try {
- finishPendingSnap(svc)
- } catch (e: DeadObjectException) {
- Log.w(TAG, "Shizuku service binder is dead", e)
- notifyDisconnected()
- } catch (e: Exception) {
- handleRemoteException(e, "Failed to snap HID mouse")
+ remote("Failed to snap HID mouse", Unit) { s ->
+ finishPendingSnap(s, sendHid = true)
}
return
}
@@ -262,8 +257,10 @@ class ShizukuInputInjector(
hidMouse.completeAttach(newDevice = newDevice)
clientClosedMouse = false
publishNativePointerState(NativePointerState.ACTIVE)
- // Only the injector knows whether this call created the device and
- // emitted the warp; inferring it from app state strands the cursor.
+ // Only the privileged process knows whether this call created the device
+ // and emitted the Enter warp. A warm root daemon returns through the
+ // idempotent branch without warping, and guessing 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)
@@ -273,13 +270,9 @@ class ShizukuInputInjector(
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)
+ if (service != null) publishNativePointerState(NativePointerState.FALLBACK)
handleRemoteException(e, "Failed to attach HID mouse")
}
}
@@ -302,32 +295,21 @@ class ShizukuInputInjector(
TAG,
"HID mouse enter $x,$y phase=${hidMouse.phase} cooked=${hidMouse.cookedX},${hidMouse.cookedY}",
)
- val svc = service ?: return
// Hand the coords to the injector so a CREATE2 can warp there itself. Warping
// from here loses the race against AOSP seeding the new pointer at centre.
- try {
- svc.onHidMouseEnter(x, y, pointerMaxX(), pointerMaxY(), pointerSpeed)
- if (hidMouse.attached) {
- finishPendingSnap(svc)
+ remote("Failed to store HID mouse enter on injector", Unit) {
+ it.onHidMouseEnter(x, y, pointerMaxX(), pointerMaxY(), pointerSpeed)
+ }
+ if (hidMouse.attached) {
+ remote("Failed to snap HID mouse", Unit) { s ->
+ finishPendingSnap(s, sendHid = true)
}
- } catch (e: DeadObjectException) {
- Log.w(TAG, "Shizuku service binder is dead", e)
- notifyDisconnected()
- } catch (e: Exception) {
- handleRemoteException(e, "Failed to store HID mouse enter on injector")
}
}
override fun onHidMouseLeave() {
hidMouse.markLeave()
- try {
- service?.onHidMouseLeave()
- } catch (e: DeadObjectException) {
- Log.w(TAG, "Shizuku service binder is dead", e)
- notifyDisconnected()
- } catch (e: Exception) {
- handleRemoteException(e, "Failed to clear HID mouse enter on injector")
- }
+ remote("Failed to clear HID mouse enter on injector", Unit) { it.onHidMouseLeave() }
}
/**
@@ -366,18 +348,8 @@ class ShizukuInputInjector(
}
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
- }
+ return remote(null, false) { it.injectHidKey(evdevCode, isDown) }
}
fun tryHidMouse(event: InputLeapEvent): Boolean {
@@ -426,10 +398,6 @@ class ShizukuInputInjector(
}
else -> false
}
- } catch (e: DeadObjectException) {
- Log.w(TAG, "Shizuku service binder is dead", e)
- notifyDisconnected()
- false
} catch (e: Exception) {
handleRemoteException(e, null)
false
@@ -517,17 +485,34 @@ class ShizukuInputInjector(
else -> Unit
}
- } catch (e: DeadObjectException) {
- Log.w(TAG, "Shizuku service binder is dead", e)
- notifyDisconnected()
} catch (e: Exception) {
handleRemoteException(e, "Failed to inject event")
}
}
+ /**
+ * Runs one AIDL call against the bound privileged service.
+ *
+ * A dead binder means the privileged process is gone, so it must drop the session
+ * rather than be logged and ignored; anything else is a per-call failure. Every
+ * remote call routes through here so the two are never confused, and so the log
+ * names the actual host instead of always saying Shizuku.
+ */
+ private inline fun remote(what: String?, fallback: T, call: (IInputInjector) -> T): T {
+ val svc = service ?: return fallback
+ return try {
+ call(svc)
+ } catch (e: Exception) {
+ handleRemoteException(e, what)
+ fallback
+ }
+ }
+
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)
+ if (e is DeadObjectException || e is RemoteException ||
+ e.cause is DeadObjectException || e.cause is RemoteException
+ ) {
+ Log.w(TAG, "${host.kind} service binder is dead (${message ?: "remote call"})", e)
notifyDisconnected()
} else if (message != null) {
Log.w(TAG, message, e)
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
index 61826d3b..85049d76 100644
--- a/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresence.kt
+++ b/app/src/main/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresence.kt
@@ -9,7 +9,17 @@ import java.io.File
*/
internal object HidSysfsPresence {
- fun present(name: String, uniq: String, root: File = File("/sys/class/input")): Boolean {
+ fun present(
+ name: String,
+ uniq: String,
+ root: File = File("/sys/class/input"),
+ procDevices: File = File("/proc/bus/input/devices"),
+ ): Boolean {
+ if (presentInSysfs(name, uniq, root)) return true
+ return presentInProc(name, uniq, procDevices)
+ }
+
+ private fun presentInSysfs(name: String, uniq: String, root: File): Boolean {
val nodes = root.listFiles() ?: return false
for (node in nodes) {
if (!node.name.startsWith("event")) continue
@@ -24,6 +34,40 @@ internal object HidSysfsPresence {
return false
}
+ /**
+ * Root can read `/proc/bus/input/devices`; ColorOS denies it to shell.
+ * A matching Name/Uniq means EventHub can see the evdev node.
+ */
+ internal fun presentInProc(name: String, uniq: String, procDevices: File): Boolean {
+ if (!procDevices.isFile) return false
+ val text = runCatching { procDevices.readText() }.getOrDefault("")
+ if (text.isEmpty()) return false
+ for (block in text.split(Regex("\n\n+"))) {
+ var blockName = ""
+ var blockUniq = ""
+ for (line in block.lineSequence()) {
+ when {
+ line.startsWith("N: Name=") ->
+ blockName = unquote(line.removePrefix("N: Name="))
+ line.startsWith("U: Uniq=") ->
+ blockUniq = line.removePrefix("U: Uniq=").trim()
+ }
+ }
+ if (uniq.isNotEmpty() && blockUniq == uniq) return true
+ if (blockName == name) return true
+ }
+ return false
+ }
+
+ private fun unquote(value: String): String {
+ val trimmed = value.trim()
+ return if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
+ trimmed.substring(1, trimmed.length - 1)
+ } else {
+ trimmed
+ }
+ }
+
private fun readTrim(file: File): String =
runCatching { file.readText().trim() }.getOrDefault("")
}
diff --git a/app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt b/app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt
index 60a5ff81..37cf3230 100644
--- a/app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt
+++ b/app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt
@@ -34,6 +34,11 @@ class AppPreferences(private val context: Context) {
private val KEY_CURSOR_STYLE = stringPreferencesKey("cursor_style")
private val KEY_LAST_SEEN_VERSION_CODE = intPreferencesKey("last_seen_version_code")
+ // Set once su has actually been granted. libsu cannot tell "granted" from
+ // "never asked" until a shell exists, so this is what lets the app re-probe
+ // silently at startup instead of showing a false "Setup Required".
+ private val KEY_ROOT_GRANT_REMEMBERED = booleanPreferencesKey("root_grant_remembered")
+
/**
* Get a sanitized device name suitable for use as screen name.
* Removes trailing spaces and special characters that might cause issues.
@@ -82,6 +87,9 @@ class AppPreferences(private val context: Context) {
val inputMethod: Flow =
context.dataStore.data.map { it[KEY_INPUT_METHOD] ?: "auto" }
+ val rootGrantRemembered: Flow =
+ context.dataStore.data.map { it[KEY_ROOT_GRANT_REMEMBERED] ?: false }
+
val cursorStyle: Flow =
context.dataStore.data.map { it[KEY_CURSOR_STYLE] ?: "default" }
@@ -139,6 +147,10 @@ class AppPreferences(private val context: Context) {
it[KEY_INPUT_METHOD] = method
}
+ suspend fun saveRootGrantRemembered(remembered: Boolean) = context.dataStore.edit {
+ it[KEY_ROOT_GRANT_REMEMBERED] = remembered
+ }
+
suspend fun saveCursorStyle(style: String) = context.dataStore.edit {
it[KEY_CURSOR_STYLE] = style
}
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 bbb09191..f2e959a8 100644
--- a/app/src/main/java/com/inputleaf/android/ui/LeafNavigation.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/LeafNavigation.kt
@@ -8,6 +8,8 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -18,11 +20,10 @@ 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.ShortNavigationBar
+import androidx.compose.material3.ShortNavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
@@ -30,7 +31,6 @@ 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.UpdateAvailableDialog
@@ -66,6 +66,7 @@ fun LeafNavigation(viewModel: MainViewModel) {
val batteryOptimizationExempt by viewModel.batteryOptimizationExempt.collectAsStateWithLifecycle()
val fingerprints by viewModel.fingerprints.collectAsState(initial = emptyMap())
val shizukuStatus by viewModel.shizukuStatus.collectAsStateWithLifecycle()
+ val rootStatus by viewModel.rootStatus.collectAsStateWithLifecycle()
val themeMode by viewModel.themeMode.collectAsState(initial = "SYSTEM")
val onboardingComplete by viewModel.onboardingComplete.collectAsState(initial = true)
val mouseEnabled by viewModel.mouseEnabled.collectAsState(initial = true)
@@ -78,7 +79,10 @@ fun LeafNavigation(viewModel: MainViewModel) {
val clientCertificateSummary by viewModel.clientCertificateSummary.collectAsStateWithLifecycle()
val cursorStyle by viewModel.cursorStyle.collectAsState(initial = "default")
val shizukuAvailable by viewModel.shizukuAvailable.collectAsState(initial = false)
+ val rootGranted by viewModel.rootGranted.collectAsState(initial = false)
+ val rootUsable by viewModel.rootUsable.collectAsState(initial = false)
val accessibilityAvailable by viewModel.accessibilityAvailable.collectAsState(initial = false)
+ val activeMethod by viewModel.activeMethod.collectAsState()
val imeEnabledAndSelected by viewModel.imeEnabledAndSelected.collectAsStateWithLifecycle(initialValue = false)
val updateCheckResult by viewModel.updateCheckResult.collectAsStateWithLifecycle()
val isCheckingUpdate by viewModel.isCheckingUpdate.collectAsStateWithLifecycle()
@@ -215,11 +219,13 @@ fun LeafNavigation(viewModel: MainViewModel) {
if (!onboardingComplete) {
OnboardingScreen(
shizukuStatus = shizukuStatus,
+ rootStatus = rootStatus,
accessibilityAvailable = accessibilityAvailable,
canDrawOverlays = canDrawOverlays,
batteryOptimizationExempt = batteryOptimizationExempt,
imeEnabledAndSelected = imeEnabledAndSelected,
onRequestShizukuPermission = { viewModel.requestShizukuPermission() },
+ onRequestRootAccess = { viewModel.requestRootAccess() },
onRequestOverlayPermission = {
context.startActivity(
Intent(
@@ -230,6 +236,7 @@ fun LeafNavigation(viewModel: MainViewModel) {
},
onRequestBatteryOptimization = { BatteryOptimizationHelper.requestExemption(context) },
onRequestImeSetup = { openImeSetup(context) },
+ onRequestAccessibilityService = { openAccessibilitySettings(context) },
onComplete = { viewModel.completeOnboarding() },
)
return
@@ -245,13 +252,9 @@ fun LeafNavigation(viewModel: MainViewModel) {
Scaffold(
bottomBar = {
- NavigationBar(
- modifier = Modifier.fillMaxWidth(),
- containerColor = MaterialTheme.colorScheme.surfaceContainer,
- tonalElevation = 0.dp,
- ) {
+ ShortNavigationBar {
navItems.forEachIndexed { index, item ->
- NavigationBarItem(
+ ShortNavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = selectedIndex == index,
@@ -260,8 +263,9 @@ fun LeafNavigation(viewModel: MainViewModel) {
}
}
},
+ contentWindowInsets = WindowInsets(0),
) { innerPadding ->
- Box(Modifier.fillMaxSize().padding(innerPadding)) {
+ Box(Modifier.fillMaxSize().padding(innerPadding).consumeWindowInsets(innerPadding)) {
when (screen) {
LeafRoute.Home.key -> MainScreen(
connectionState = connectionState,
@@ -269,6 +273,7 @@ fun LeafNavigation(viewModel: MainViewModel) {
isScanning = isScanning,
screenName = screenName,
shizukuStatus = shizukuStatus,
+ rootStatus = rootStatus,
accessibilityAvailable = accessibilityAvailable,
mouseEnabled = mouseEnabled,
keyboardEnabled = keyboardEnabled,
@@ -278,6 +283,7 @@ fun LeafNavigation(viewModel: MainViewModel) {
onDisconnect = { viewModel.disconnect() },
onAddManual = { viewModel.addManualServer(it) },
onRequestShizukuPermission = { viewModel.requestShizukuPermission() },
+ onRequestRootAccess = { viewModel.requestRootAccess() },
onRequestAccessibilityService = { openAccessibilitySettings(context) },
onScreenNameChange = { viewModel.saveScreenName(it) },
onToggleMouse = { viewModel.toggleMouseEnabled(it) },
@@ -296,12 +302,15 @@ fun LeafNavigation(viewModel: MainViewModel) {
pendingConnectIp = pendingConnectIp,
)
LeafRoute.Setup.key -> SetupScreen(
+ activeMethod = activeMethod,
shizukuStatus = shizukuStatus,
+ rootStatus = rootStatus,
accessibilityAvailable = accessibilityAvailable,
canDrawOverlays = canDrawOverlays,
batteryOptimizationExempt = batteryOptimizationExempt,
imeEnabledAndSelected = imeEnabledAndSelected,
onRequestShizukuPermission = { viewModel.requestShizukuPermission() },
+ onRequestRootAccess = { viewModel.requestRootAccess() },
onRequestOverlayPermission = {
context.startActivity(
Intent(
@@ -323,6 +332,8 @@ fun LeafNavigation(viewModel: MainViewModel) {
connectionTransportPolicy = connectionTransportPolicy,
cursorStyle = cursorStyle,
shizukuAvailable = shizukuAvailable,
+ rootGranted = rootGranted,
+ rootUsable = rootUsable,
accessibilityAvailable = accessibilityAvailable,
canDrawOverlays = canDrawOverlays,
fingerprints = fingerprints,
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 7b5229d8..fe692a74 100644
--- a/app/src/main/java/com/inputleaf/android/ui/MainActivity.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/MainActivity.kt
@@ -2,6 +2,10 @@ package com.inputleaf.android.ui
import android.Manifest
import android.content.pm.PackageManager
+import android.graphics.Color
+import androidx.activity.SystemBarStyle
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.DisposableEffect
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -14,16 +18,12 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.launch
import androidx.compose.material3.Surface
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
+import com.inputleaf.android.ui.theme.InputLeafTheme
class MainActivity : ComponentActivity() {
@@ -59,47 +59,16 @@ class MainActivity : ComponentActivity() {
val isDarkTheme = when (themeMode) {
"LIGHT" -> false
"DARK" -> true
- else -> androidx.compose.foundation.isSystemInDarkTheme()
+ else -> isSystemInDarkTheme()
}
- val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- // Android 12+ Material You dynamic colors
- if (isDarkTheme) {
- androidx.compose.material3.dynamicDarkColorScheme(this@MainActivity)
- } else {
- dynamicLightColorScheme(this@MainActivity)
- }
- } else {
- // Fallback for older Android versions
- if (isDarkTheme) {
- androidx.compose.material3.darkColorScheme(
- primary = com.inputleaf.android.ui.theme.LeafBright,
- primaryContainer = com.inputleaf.android.ui.theme.LeafForest,
- onPrimary = androidx.compose.ui.graphics.Color.White,
- secondary = com.inputleaf.android.ui.theme.LeafLight,
- tertiary = com.inputleaf.android.ui.theme.Success400,
- background = com.inputleaf.android.ui.theme.TealDark,
- surface = com.inputleaf.android.ui.theme.TealDeep,
- onBackground = androidx.compose.ui.graphics.Color(0xFFE8F5E9),
- onSurface = androidx.compose.ui.graphics.Color(0xFFE8F5E9)
- )
- } else {
- lightColorScheme(
- primary = com.inputleaf.android.ui.theme.LeafGreen,
- primaryContainer = com.inputleaf.android.ui.theme.Purple100,
- onPrimary = androidx.compose.ui.graphics.Color.White,
- secondary = com.inputleaf.android.ui.theme.LeafBright,
- tertiary = com.inputleaf.android.ui.theme.Success500,
- background = com.inputleaf.android.ui.theme.Background,
- surface = com.inputleaf.android.ui.theme.Surface
- )
- }
+ DisposableEffect(isDarkTheme) {
+ val barStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { isDarkTheme }
+ enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle)
+ onDispose {}
}
- MaterialTheme(
- colorScheme = colorScheme,
- shapes = com.inputleaf.android.ui.theme.InputLeafShapes
- ) {
+ InputLeafTheme(darkTheme = isDarkTheme) {
Surface(Modifier.fillMaxSize()) {
LeafNavigation(viewModel)
}
@@ -111,6 +80,7 @@ class MainActivity : ComponentActivity() {
super.onResume()
// Re-check Shizuku status when returning to app
viewModel.checkShizukuStatus()
+ viewModel.checkRootStatus()
// Re-check overlay permission (user may have granted it in settings)
viewModel.checkOverlayPermission()
// Re-check battery optimization
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 1fc653ed..b9581dc1 100644
--- a/app/src/main/java/com/inputleaf/android/ui/MainScreen.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/MainScreen.kt
@@ -1,14 +1,11 @@
package com.inputleaf.android.ui
import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
@@ -19,14 +16,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-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 com.inputleaf.android.model.ConnectionState
import com.inputleaf.android.model.ServerInfo
+import com.inputleaf.android.ui.components.CircularAvatar
import com.inputleaf.android.ui.components.FeatureToggleCard
-import com.inputleaf.android.ui.components.GradientCard
+import com.inputleaf.android.ui.components.SectionHeader
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -36,6 +32,7 @@ fun MainScreen(
isScanning: Boolean,
screenName: String,
shizukuStatus: ShizukuStatus,
+ rootStatus: RootStatus,
accessibilityAvailable: Boolean,
mouseEnabled: Boolean,
keyboardEnabled: Boolean,
@@ -45,6 +42,7 @@ fun MainScreen(
onDisconnect: () -> Unit,
onAddManual: (String) -> Unit,
onRequestShizukuPermission: () -> Unit,
+ onRequestRootAccess: () -> Unit,
onRequestAccessibilityService: () -> Unit,
onScreenNameChange: (String) -> Unit,
onToggleMouse: (Boolean) -> Unit,
@@ -57,13 +55,8 @@ fun MainScreen(
if (showEditNameDialog) {
AlertDialog(
onDismissRequest = { showEditNameDialog = false },
- title = {
- Text(
- text = "Rename Device",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
- )
- },
+ icon = { Icon(Icons.Rounded.Edit, contentDescription = null) },
+ title = { Text("Rename Device") },
text = {
OutlinedTextField(
value = tempName,
@@ -150,118 +143,66 @@ fun MainScreen(
val favorites = discoveredServers.filter { favoriteServers.contains(it.ip) }
if (favorites.isNotEmpty()) {
item {
- Text(
- text = "Quick Connect",
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
- )
- }
- items(favorites) { server ->
+ SectionHeader("Quick Connect")
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
+ Column(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ favorites.forEachIndexed { index, server ->
+ val isConnected = when (connectionState) {
+ is ConnectionState.Idle -> connectionState.serverIp == server.ip
+ is ConnectionState.Active -> connectionState.serverIp == server.ip
+ else -> false
+ }
+ ServerListItem(
+ server = server,
+ isConnected = isConnected,
+ onServerClick = onConnect,
+ shapes = ListItemDefaults.segmentedShapes(index, favorites.size),
+ isConnecting = connectingIp == server.ip,
+ enabled = connectingIp == null,
+ )
+ }
}
- ServerListItem(
- server = server,
- isConnected = isConnected,
- onServerClick = onConnect,
- isConnecting = connectingIp == server.ip,
- enabled = connectingIp == null,
- )
}
}
// Setup options section if neither Shizuku nor Accessibility is enabled/ready
- val isInputInjectionReady = shizukuStatus == ShizukuStatus.READY || accessibilityAvailable
+ // AVAILABLE only means an su binary exists; nothing can inject until it is
+ // GRANTED, so treating it as ready hides the card the user needs to tap.
+ val rootUsable = rootStatus == RootStatus.GRANTED
+ val isInputInjectionReady =
+ shizukuStatus == ShizukuStatus.READY || rootUsable || accessibilityAvailable
val isSessionActive = connectionState is ConnectionState.Active
if (!isInputInjectionReady && !isSessionActive) {
- item {
- Text(
- text = "Setup Required",
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
- )
- }
+ item { SectionHeader("Setup Required") }
- // Shizuku setup card
item {
- GradientCard(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 4.dp)
- .clickable { onRequestShizukuPermission() },
- backgroundColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f),
- cornerRadius = 20.dp,
- padding = 16.dp
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Icon(
- imageVector = Icons.Rounded.FlashOn,
- contentDescription = "Shizuku",
- tint = MaterialTheme.colorScheme.error,
- modifier = Modifier.size(28.dp)
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Enable Shizuku Mode",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onErrorContainer
- )
- Text(
- text = "Recommended for precise mouse injection. Requires Shizuku background service.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
+ SetupActionCard(
+ icon = Icons.Rounded.FlashOn,
+ title = "Enable Shizuku or grant root",
+ body = "Recommended for the system cursor and HID keyboard. Shizuku or su — you do not need both.",
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ onClick = {
+ if (rootStatus == RootStatus.DENIED || rootStatus == RootStatus.AVAILABLE) {
+ onRequestRootAccess()
+ } else {
+ onRequestShizukuPermission()
}
- }
- }
+ },
+ )
}
- // Accessibility setup card
item {
- GradientCard(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 4.dp)
- .clickable { onRequestAccessibilityService() },
- backgroundColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f),
- cornerRadius = 20.dp,
- padding = 16.dp
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Icon(
- imageVector = Icons.Rounded.Accessibility,
- contentDescription = "Accessibility",
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(28.dp)
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Enable Accessibility Mode",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimaryContainer
- )
- Text(
- text = "Easy rootless touch & keyboard simulation. Works out of the box on any device.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
+ SetupActionCard(
+ icon = Icons.Rounded.Accessibility,
+ title = "Enable Accessibility Mode",
+ body = "Easy rootless touch & keyboard simulation. Works out of the box on any device.",
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ onClick = onRequestAccessibilityService,
+ )
}
}
@@ -281,27 +222,16 @@ fun MainScreen(
val brandLogoRes = remember { com.inputleaf.android.util.DeviceIdentity.getBrandLogoRes() }
val brandColor = remember { com.inputleaf.android.util.DeviceIdentity.getBrandColor() }
- GradientCard(
+ Card(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 6.dp),
- backgroundColor = brandColor.copy(alpha = 0.08f),
- cornerRadius = 24.dp,
- elevation = 0.dp,
- padding = 0.dp
+ .padding(horizontal = 16.dp, vertical = 4.dp),
+ shape = MaterialTheme.shapes.extraLarge,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ),
) {
Box(modifier = Modifier.fillMaxWidth()) {
- // Brand watermark — large, subtle background logo
- Icon(
- painter = painterResource(id = brandLogoRes),
- contentDescription = null,
- modifier = Modifier
- .size(120.dp)
- .align(Alignment.CenterEnd)
- .offset(x = 16.dp, y = 0.dp),
- tint = brandColor.copy(alpha = 0.06f)
- )
-
Column(
modifier = Modifier
.fillMaxWidth()
@@ -327,9 +257,7 @@ fun MainScreen(
) {
Text(
text = marketingName,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleMediumEmphasized,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
@@ -338,23 +266,20 @@ fun MainScreen(
painter = painterResource(id = brandLogoRes),
contentDescription = manufacturer,
modifier = Modifier.size(16.dp),
- tint = brandColor.copy(alpha = 0.8f)
+ tint = brandColor
)
}
- // Internal model code (subtle)
if (internalCode != marketingName && !marketingName.contains(internalCode, ignoreCase = true)) {
Text(
text = internalCode,
style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
- letterSpacing = 0.5.sp
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(6.dp))
- // Screen name row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
@@ -362,64 +287,31 @@ fun MainScreen(
Text(
text = "Screen: ",
style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = screenName,
style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
- IconButton(
- onClick = { showEditNameDialog = true },
- colors = IconButtonDefaults.iconButtonColors(
- containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)
- )
- ) {
- Icon(
- imageVector = Icons.Rounded.Edit,
- contentDescription = "Edit Name",
- modifier = Modifier.size(18.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
+ FilledTonalIconButton(onClick = { showEditNameDialog = true }) {
+ Icon(Icons.Rounded.Edit, contentDescription = "Edit Name")
}
}
Spacer(modifier = Modifier.height(12.dp))
- // Info pills row
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- // Android version pill
- Surface(
- shape = RoundedCornerShape(8.dp),
- color = brandColor.copy(alpha = 0.1f)
- ) {
- Text(
- text = androidVersion,
- style = MaterialTheme.typography.labelSmall,
- fontWeight = FontWeight.Medium,
- color = brandColor.copy(alpha = 0.9f),
- modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
- )
- }
- // Manufacturer pill
- Surface(
- shape = RoundedCornerShape(8.dp),
- color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
- ) {
- Text(
- text = manufacturer,
- style = MaterialTheme.typography.labelSmall,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
- modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
- )
- }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ InfoPill(
+ text = androidVersion,
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ )
+ InfoPill(
+ text = manufacturer,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ )
}
}
}
@@ -432,117 +324,152 @@ fun MainScreen(
}
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ConnectionStatusCard(
state: ConnectionState,
screenName: String,
onDisconnect: () -> Unit
) {
- val (statusLabel, statusIcon, containerColor, contentColor, serverInfo) = when (state) {
+ val colors = MaterialTheme.colorScheme
+ val (statusLabel, statusIcon, roles, serverInfo) = when (state) {
is ConnectionState.Active -> StatusInfo(
"CONNECTED", Icons.Rounded.CheckCircle,
- Color(0xFF1B5E20).copy(alpha = 0.15f), Color(0xFF4CAF50),
+ StatusRoles(colors.primaryContainer, colors.onPrimaryContainer, colors.primary, colors.onPrimary),
"${state.serverName} • ${state.serverIp}"
)
is ConnectionState.Idle -> StatusInfo(
"IDLE", Icons.Rounded.Pause,
- Color(0xFF0D47A1).copy(alpha = 0.15f), Color(0xFF42A5F5),
+ StatusRoles(colors.secondaryContainer, colors.onSecondaryContainer, colors.secondary, colors.onSecondary),
"${state.serverName} • ${state.serverIp}"
)
is ConnectionState.Connecting -> StatusInfo(
"CONNECTING...", Icons.Rounded.Sync,
- Color(0xFFF57F17).copy(alpha = 0.15f), Color(0xFFFFB300),
+ StatusRoles(colors.tertiaryContainer, colors.onTertiaryContainer, colors.tertiary, colors.onTertiary),
state.serverIp
)
is ConnectionState.Handshaking -> StatusInfo(
"HANDSHAKING...", Icons.Rounded.Sync,
- Color(0xFFF57F17).copy(alpha = 0.15f), Color(0xFFFFB300),
+ StatusRoles(colors.tertiaryContainer, colors.onTertiaryContainer, colors.tertiary, colors.onTertiary),
state.serverIp
)
is ConnectionState.Disconnected -> StatusInfo(
"DISCONNECTED", Icons.Rounded.LinkOff,
- Color(0xFFB71C1C).copy(alpha = 0.12f), Color(0xFFEF5350),
+ StatusRoles(colors.errorContainer, colors.onErrorContainer, colors.error, colors.onError),
"No server connected"
)
}
- val animatedColor by animateColorAsState(
- targetValue = containerColor,
- animationSpec = tween(500),
- label = "status_color"
- )
+ val colorSpec = MaterialTheme.motionScheme.slowEffectsSpec()
+ val containerColor by animateColorAsState(roles.container, colorSpec, label = "status_container")
+ val contentColor by animateColorAsState(roles.onContainer, colorSpec, label = "status_content")
+ val accentColor by animateColorAsState(roles.accent, colorSpec, label = "status_accent")
val isConnected = state is ConnectionState.Active || state is ConnectionState.Idle
+ val isWorking = state is ConnectionState.Connecting || state is ConnectionState.Handshaking
- GradientCard(
+ Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
- backgroundColor = animatedColor,
- cornerRadius = 24.dp,
- elevation = 0.dp,
- padding = 20.dp
+ shape = MaterialTheme.shapes.extraLarge,
+ colors = CardDefaults.cardColors(containerColor = containerColor, contentColor = contentColor),
) {
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
- Surface(
- modifier = Modifier.size(48.dp),
- shape = RoundedCornerShape(14.dp),
- color = contentColor.copy(alpha = 0.2f)
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- imageVector = statusIcon,
- contentDescription = statusLabel,
- modifier = Modifier.size(26.dp),
- tint = contentColor
- )
- }
+ if (isWorking) {
+ ContainedLoadingIndicator(
+ modifier = Modifier.size(48.dp),
+ containerColor = accentColor,
+ indicatorColor = roles.onAccent,
+ )
+ } else {
+ CircularAvatar(
+ icon = statusIcon,
+ size = 48.dp,
+ iconSize = 26.dp,
+ backgroundColor = accentColor,
+ iconTint = roles.onAccent,
+ shape = MaterialShapes.Cookie9Sided.toShape(),
+ )
}
Column(modifier = Modifier.weight(1f)) {
- Text(
- text = statusLabel,
- fontWeight = FontWeight.Bold,
- fontSize = 13.sp,
- color = contentColor,
- letterSpacing = 1.sp
- )
+ Text(text = statusLabel, style = MaterialTheme.typography.labelLargeEmphasized)
Spacer(modifier = Modifier.height(2.dp))
- Text(
- text = serverInfo,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
- )
+ Text(text = serverInfo, style = MaterialTheme.typography.bodySmall)
}
if (isConnected) {
- FilledTonalButton(
+ Button(
onClick = onDisconnect,
- colors = ButtonDefaults.filledTonalButtonColors(
- containerColor = contentColor.copy(alpha = 0.2f),
- contentColor = contentColor
- ),
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ colors = ButtonDefaults.buttonColors(containerColor = accentColor, contentColor = roles.onAccent),
) {
- Text("Disconnect", fontSize = 12.sp)
+ Text("Disconnect")
}
}
}
}
}
+private data class StatusRoles(val container: Color, val onContainer: Color, val accent: Color, val onAccent: Color)
+
private data class StatusInfo(
val label: String,
val icon: ImageVector,
- val containerColor: Color,
- val contentColor: Color,
+ val roles: StatusRoles,
val serverInfo: String
)
+@Composable
+private fun SetupActionCard(
+ icon: ImageVector,
+ title: String,
+ body: String,
+ containerColor: Color,
+ onClick: () -> Unit,
+) {
+ Card(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = containerColor,
+ contentColor = contentColorFor(containerColor),
+ ),
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(28.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text = title, style = MaterialTheme.typography.titleMediumEmphasized)
+ Text(text = body, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoPill(text: String, containerColor: Color) {
+ Surface(shape = MaterialTheme.shapes.small, color = containerColor) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
+ )
+ }
+}
+
@Composable
private fun DeviceVisualRepresentation(
manufacturer: String,
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 21fdbea8..06fc973b 100644
--- a/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/MainViewModel.kt
@@ -21,6 +21,12 @@ import com.inputleaf.android.network.ClientCertificateValidationResult
import com.inputleaf.android.network.ConnectResult
import com.inputleaf.android.network.ConnectionTransportPolicy
import com.inputleaf.android.network.ServerScanner
+import com.inputleaf.android.privilege.InjectorMethodResolver
+import com.inputleaf.android.privilege.PrivilegeKind
+import com.inputleaf.android.privilege.LibSuRootAccess
+import com.inputleaf.android.privilege.PrivilegeSnapshot
+import com.inputleaf.android.privilege.PrivilegedInjectorFactory
+import com.inputleaf.android.privilege.ResolvedInjector
import com.inputleaf.android.service.ConnectionService
import com.inputleaf.android.storage.AppPreferences
import com.inputleaf.android.storage.ClientCertificateStore
@@ -32,7 +38,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
-import rikka.shizuku.Shizuku
+import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.net.InetAddress
import java.net.NetworkInterface
@@ -138,6 +144,13 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
prefs.connectionTransportPolicy
val shizukuAvailable: Flow = permissionProvider.shizukuAvailable
+ /** The method a live session is injecting through; null when nothing is connected. */
+ private val _activeMethod = MutableStateFlow(null)
+ val activeMethod: StateFlow = _activeMethod
+
+ val rootStatus: StateFlow = permissionProvider.rootStatus
+ val rootGranted: Flow = permissionProvider.rootGranted
+ val rootUsable: Flow = permissionProvider.rootUsable
val accessibilityAvailable: Flow = permissionProvider.accessibilityAvailable
val imeEnabledAndSelected: Flow = permissionProvider.imeEnabledAndSelected
@@ -232,7 +245,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val injector = resolveInjector(preferredMethod = method)
if (injector == null) {
- _errorState.value = "Selected input method is not available. Enable Shizuku or Accessibility Service."
+ _errorState.value = "Selected input method is not available. Enable Shizuku, grant root, or turn on Accessibility Service."
disconnect()
return@launch
}
@@ -245,6 +258,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
}
val name = prefs.screenName.first()
+ _activeMethod.value = permissionMethodOf(injector)
service?.setInjector(injector)
service?.reconnect(currentIp, name)
}
@@ -377,17 +391,20 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
init {
bindService()
- // Auto-connect when Shizuku becomes available (e.g. started after app launch or restarted)
+ // Auto-connect when Shizuku or root becomes available
viewModelScope.launch {
- permissionProvider.shizukuAvailable.collect { available ->
- if (available && !userRequestedDisconnect) {
+ combine(permissionProvider.shizukuAvailable, permissionProvider.rootGranted) { shizuku, root ->
+ shizuku || root
+ }.collect { privilegeReady ->
+ if (privilegeReady && !userRequestedDisconnect) {
val auto = prefs.autoConnect.first()
val lastIp = prefs.lastServerIp.first()
val currentState = service?.state?.value ?: _connectionState.value
if (auto && !lastIp.isNullOrBlank() && currentState is ConnectionState.Disconnected) {
- Log.i("InputLeaf", "Shizuku became available — auto-connecting to last server: $lastIp")
+ Log.i("InputLeaf", "Privileged injection became available — auto-connecting to last server: $lastIp")
if (_errorState.value?.contains("input method", ignoreCase = true) == true ||
- _errorState.value?.contains("Shizuku", ignoreCase = true) == true
+ _errorState.value?.contains("Shizuku", ignoreCase = true) == true ||
+ _errorState.value?.contains("root", ignoreCase = true) == true
) {
_errorState.value = null
}
@@ -432,6 +449,23 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
}
fun checkShizukuStatus() = permissionProvider.checkShizukuStatus()
fun requestShizukuPermission() = permissionProvider.requestShizukuPermission()
+ fun checkRootStatus() = permissionProvider.checkRootStatus()
+ fun requestRootAccess() = permissionProvider.requestRootAccess()
+
+ /**
+ * AccessibilityInputInjector reports the privilege kind of its latent HID host, so
+ * the type has to be checked before the kind or a touch-fallback session would show
+ * up as Shizuku.
+ */
+ private fun permissionMethodOf(
+ injector: com.inputleaf.android.inject.InputInjector,
+ ): PermissionMethod = when {
+ injector is com.inputleaf.android.inject.AccessibilityInputInjector ->
+ PermissionMethod.ACCESSIBILITY
+ injector.privilegeKind() == PrivilegeKind.ROOT -> PermissionMethod.ROOT
+ injector.privilegeKind() == PrivilegeKind.SHIZUKU -> PermissionMethod.SHIZUKU
+ else -> PermissionMethod.ACCESSIBILITY
+ }
fun checkOverlayPermission() = permissionProvider.checkOverlayPermission()
fun checkBatteryOptimization() = permissionProvider.checkBatteryOptimization()
@@ -482,15 +516,51 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val method = preferredMethod ?: prefs.inputMethod.first()
val wm = getApplication().getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
val bounds = wm.currentWindowMetrics.bounds
- val shizukuInjector = com.inputleaf.android.shizuku.ShizukuInputInjector(bounds.width(), bounds.height())
- val accessibilityInjector = com.inputleaf.android.inject.AccessibilityInputInjector(getApplication(), bounds.width(), bounds.height())
-
- return when {
- method == "shizuku" && shizukuInjector.isAvailable() -> shizukuInjector
- method == "accessibility" && accessibilityInjector.isAvailable() -> accessibilityInjector
- method == "auto" && shizukuInjector.isAvailable() -> shizukuInjector
- method == "auto" && accessibilityInjector.isAvailable() -> accessibilityInjector
- else -> null
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = permissionProvider.shizukuStatus.value == ShizukuStatus.READY,
+ root = permissionProvider.rootAvailability(),
+ )
+ val accessibilityInjector = com.inputleaf.android.inject.AccessibilityInputInjector(
+ getApplication(),
+ bounds.width(),
+ bounds.height(),
+ )
+
+ return when (InjectorMethodResolver.resolve(method, snapshot, accessibilityInjector.isAvailable())) {
+ ResolvedInjector.PRIVILEGED_ROOT -> {
+ val granted = withContext(Dispatchers.IO) { LibSuRootAccess.requestAccess() }
+ permissionProvider.rememberRootGrant(granted)
+ // The snapshot above may have been a guess (su binary present, never
+ // prompted). Now that the prompt has settled, re-resolve so a denial
+ // falls through to Shizuku or Accessibility instead of failing connect.
+ val settled = snapshot.copy(root = permissionProvider.rootAvailability())
+ when (InjectorMethodResolver.resolve(
+ method,
+ settled,
+ accessibilityInjector.isAvailable(),
+ )) {
+ ResolvedInjector.PRIVILEGED_ROOT, ResolvedInjector.PRIVILEGED_SHIZUKU ->
+ PrivilegedInjectorFactory.create(
+ getApplication(),
+ bounds.width(),
+ bounds.height(),
+ settled,
+ method,
+ )
+ ResolvedInjector.ACCESSIBILITY -> accessibilityInjector
+ ResolvedInjector.NONE -> null
+ }
+ }
+ ResolvedInjector.PRIVILEGED_SHIZUKU ->
+ PrivilegedInjectorFactory.create(
+ getApplication(),
+ bounds.width(),
+ bounds.height(),
+ snapshot,
+ method,
+ )
+ ResolvedInjector.ACCESSIBILITY -> accessibilityInjector
+ ResolvedInjector.NONE -> null
}
}
@@ -513,7 +583,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val injector = resolveInjector()
if (injector == null) {
_pendingConnectIp.value = null
- _errorState.value = "No input method available. Enable Shizuku or Accessibility Service."
+ _errorState.value = "No input method available. Enable Shizuku, grant root, or turn on Accessibility Service."
return@launch
}
@@ -523,7 +593,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
_errorState.value = "Failed to connect to input method: ${injector.name}"
return@launch
}
-
+
+ _activeMethod.value = permissionMethodOf(injector)
service?.setInjector(injector)
if (service == null) {
_pendingConnectIp.value = null
@@ -537,6 +608,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
fun disconnect() {
userRequestedDisconnect = true
_pendingConnectIp.value = null
+ _activeMethod.value = null
service?.disconnect()
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/OnboardingScreen.kt b/app/src/main/java/com/inputleaf/android/ui/OnboardingScreen.kt
index 511725ce..9d3d6910 100644
--- a/app/src/main/java/com/inputleaf/android/ui/OnboardingScreen.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/OnboardingScreen.kt
@@ -1,54 +1,66 @@
package com.inputleaf.android.ui
-import android.content.ComponentName
-import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.provider.Settings
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.*
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+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.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.*
-import androidx.compose.material.icons.filled.Warning
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material.icons.rounded.Keyboard
+import androidx.compose.material.icons.rounded.Lock
+import androidx.compose.material.icons.rounded.Mouse
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.clip
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import com.inputleaf.android.R
+import com.inputleaf.android.ui.components.PermissionMethodsAccordion
@Composable
fun OnboardingScreen(
shizukuStatus: ShizukuStatus,
+ rootStatus: RootStatus,
accessibilityAvailable: Boolean,
canDrawOverlays: Boolean,
batteryOptimizationExempt: Boolean,
imeEnabledAndSelected: Boolean,
onRequestShizukuPermission: () -> Unit,
+ onRequestRootAccess: () -> Unit,
onRequestOverlayPermission: () -> Unit,
onRequestBatteryOptimization: () -> Unit,
onRequestImeSetup: () -> Unit,
+ onRequestAccessibilityService: () -> Unit,
onComplete: () -> Unit
) {
var currentPage by remember { mutableIntStateOf(0) }
- val totalPages = 6
- val context = LocalContext.current
+ val totalPages = 2
Scaffold { padding ->
Column(
@@ -57,7 +69,6 @@ fun OnboardingScreen(
.padding(padding)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
- // Progress indicator
Row(
modifier = Modifier
.fillMaxWidth()
@@ -69,7 +80,7 @@ fun OnboardingScreen(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(if (index == currentPage) 24.dp else 8.dp, 8.dp)
- .clip(RoundedCornerShape(4.dp))
+ .clip(CircleShape)
.background(
if (index <= currentPage) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant
@@ -78,7 +89,6 @@ fun OnboardingScreen(
}
}
- // Page content
Box(
modifier = Modifier
.weight(1f)
@@ -87,94 +97,23 @@ fun OnboardingScreen(
) {
when (currentPage) {
0 -> WelcomePage()
- 1 -> PermissionPage(
- icon = Icons.Rounded.Security,
- title = "Shizuku Setup (Optional)",
- description = "Shizuku lets Input Leaf inject mouse & keyboard events at the system level — the most powerful input method. Skip if you prefer Accessibility Service instead.",
- whyNeeded = "Shizuku provides low-latency, root-equivalent input injection without actually rooting your device. If Shizuku is unavailable, you can use the Accessibility Service on the next page.",
- isGranted = shizukuStatus == ShizukuStatus.READY,
- statusText = when (shizukuStatus) {
- ShizukuStatus.READY -> "Ready ✓"
- ShizukuStatus.NOT_INSTALLED -> "Not installed"
- ShizukuStatus.NOT_RUNNING -> "Not running"
- ShizukuStatus.PERMISSION_REQUIRED -> "Permission needed"
- ShizukuStatus.CHECKING -> "Checking..."
- },
- actionLabel = when (shizukuStatus) {
- ShizukuStatus.NOT_INSTALLED -> "Install Shizuku"
- ShizukuStatus.NOT_RUNNING -> "Open Shizuku"
- ShizukuStatus.PERMISSION_REQUIRED -> "Grant Permission"
- else -> null
- },
- onAction = when (shizukuStatus) {
- ShizukuStatus.NOT_INSTALLED -> ({
- context.startActivity(Intent(Intent.ACTION_VIEW,
- Uri.parse("https://play.google.com/store/apps/details?id=moe.shizuku.privileged.api")))
- })
- ShizukuStatus.NOT_RUNNING -> ({
- context.packageManager.getLaunchIntentForPackage("moe.shizuku.privileged.api")?.let {
- context.startActivity(it)
- }
- Unit
- })
- ShizukuStatus.PERMISSION_REQUIRED -> onRequestShizukuPermission
- else -> ({})
- }
- )
- 2 -> PermissionPage(
- icon = Icons.Rounded.Accessibility,
- title = "Accessibility Service",
- description = "Enable Input Leaf's Accessibility Service — a no-root, no-Shizuku way to inject touch events. Works on any Android device.",
- whyNeeded = "The Accessibility Service lets Input Leaf simulate taps without Shizuku. It works on stock Android with zero extra apps.",
- isGranted = accessibilityAvailable,
- statusText = if (accessibilityAvailable) "Enabled ✓" else "Disabled",
- actionLabel = if (!accessibilityAvailable) "Open Accessibility Settings" else null,
- onAction = {
- val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
- val comp = ComponentName(
- context.packageName,
- com.inputleaf.android.inject.AccessibilityInputService::class.java.name
- )
- putExtra(":settings:show_fragment_args",
- android.os.Bundle().apply { putString(":settings:fragment_args_key", comp.flattenToString()) })
- }
- context.startActivity(intent)
- }
- )
- 3 -> PermissionPage(
- icon = Icons.Default.Warning,
- title = "Virtual Keyboard",
- description = "Required to inject hardware keyboard shortcuts like Ctrl+C and Alt+Tab.",
- whyNeeded = "To fully mimic a physical keyboard without Shizuku, Input Leaf uses a custom Virtual Keyboard. You need to enable it and set it as your active keyboard.",
- isGranted = imeEnabledAndSelected,
- statusText = if (imeEnabledAndSelected) "Selected ✓" else "Not selected",
- actionLabel = if (!imeEnabledAndSelected) "Select Keyboard" else null,
- onAction = onRequestImeSetup
- )
- 4 -> PermissionPage(
- icon = Icons.Rounded.Visibility,
- title = "Overlay Permission",
- description = "Allows Input Leaf to display a cursor on your screen when your computer's mouse moves to this device.",
- whyNeeded = "Android requires explicit permission to draw over other apps. This is needed to show the cursor overlay so you can see where the mouse pointer is on your phone.",
- isGranted = canDrawOverlays,
- statusText = if (canDrawOverlays) "Granted ✓" else "Not granted",
- actionLabel = if (!canDrawOverlays) "Grant Permission" else null,
- onAction = onRequestOverlayPermission
- )
- 5 -> PermissionPage(
- icon = Icons.Rounded.BatteryChargingFull,
- title = "Battery Optimization",
- description = "Prevents Android from killing the connection when your phone goes to sleep.\n\nGo to: Battery usage → Allow background activity",
- whyNeeded = "Android aggressively kills background apps to save battery. Exempting Input Leaf ensures your KVM connection stays alive even when the screen is off.",
- isGranted = batteryOptimizationExempt,
- statusText = if (batteryOptimizationExempt) "Exempted ✓" else "Not exempted",
- actionLabel = if (!batteryOptimizationExempt) "Open App Info" else null,
- onAction = onRequestBatteryOptimization
+ else -> PermissionMethodsPage(
+ shizukuStatus = shizukuStatus,
+ rootStatus = rootStatus,
+ accessibilityAvailable = accessibilityAvailable,
+ canDrawOverlays = canDrawOverlays,
+ batteryOptimizationExempt = batteryOptimizationExempt,
+ imeEnabledAndSelected = imeEnabledAndSelected,
+ onRequestShizukuPermission = onRequestShizukuPermission,
+ onRequestRootAccess = onRequestRootAccess,
+ onRequestOverlayPermission = onRequestOverlayPermission,
+ onRequestBatteryOptimization = onRequestBatteryOptimization,
+ onRequestAccessibilityService = onRequestAccessibilityService,
+ onRequestImeSetup = onRequestImeSetup,
)
}
}
- // Navigation buttons
Row(
modifier = Modifier
.fillMaxWidth()
@@ -205,6 +144,50 @@ fun OnboardingScreen(
}
}
+@Composable
+private fun PermissionMethodsPage(
+ shizukuStatus: ShizukuStatus,
+ rootStatus: RootStatus,
+ accessibilityAvailable: Boolean,
+ canDrawOverlays: Boolean,
+ batteryOptimizationExempt: Boolean,
+ imeEnabledAndSelected: Boolean,
+ onRequestShizukuPermission: () -> Unit,
+ onRequestRootAccess: () -> Unit,
+ onRequestOverlayPermission: () -> Unit,
+ onRequestBatteryOptimization: () -> Unit,
+ onRequestAccessibilityService: () -> Unit,
+ onRequestImeSetup: () -> Unit,
+) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = "Choose an input method",
+ style = MaterialTheme.typography.headlineSmallEmphasized,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Pick one method. Grant Shizuku or root for the system cursor and physical HID keyboard. Accessibility still works as a fallback. You do not need both Shizuku and root.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ PermissionMethodsAccordion(
+ shizukuStatus = shizukuStatus,
+ rootStatus = rootStatus,
+ accessibilityAvailable = accessibilityAvailable,
+ canDrawOverlays = canDrawOverlays,
+ batteryOptimizationExempt = batteryOptimizationExempt,
+ imeEnabledAndSelected = imeEnabledAndSelected,
+ onRequestShizukuPermission = onRequestShizukuPermission,
+ onRequestRootAccess = onRequestRootAccess,
+ onRequestOverlayPermission = onRequestOverlayPermission,
+ onRequestBatteryOptimization = onRequestBatteryOptimization,
+ onRequestAccessibilityService = onRequestAccessibilityService,
+ onRequestImeSetup = onRequestImeSetup,
+ )
+ }
+}
+
@Composable
private fun WelcomePage() {
Column(
@@ -224,8 +207,7 @@ private fun WelcomePage() {
Text(
text = "Welcome to Input Leaf",
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.headlineMediumEmphasized,
textAlign = TextAlign.Center
)
@@ -242,7 +224,7 @@ private fun WelcomePage() {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
- shape = RoundedCornerShape(16.dp)
+ shape = MaterialTheme.shapes.extraLarge
) {
Column(modifier = Modifier.padding(20.dp)) {
FeatureItem(Icons.Rounded.Mouse, "Mouse sharing", "Move your cursor from your PC to your phone")
@@ -271,176 +253,17 @@ private fun FeatureItem(icon: ImageVector, title: String, description: String) {
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = title,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.SemiBold
+ style = MaterialTheme.typography.bodyMediumEmphasized,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
-
-@Composable
-private fun PermissionPage(
- icon: ImageVector,
- title: String,
- description: String,
- whyNeeded: String,
- isGranted: Boolean,
- statusText: String,
- actionLabel: String?,
- onAction: () -> Unit
-) {
- var showWhy by remember { mutableStateOf(false) }
-
- val bgColor by androidx.compose.animation.animateColorAsState(
- targetValue = if (isGranted) MaterialTheme.colorScheme.tertiaryContainer
- else MaterialTheme.colorScheme.secondaryContainer,
- animationSpec = androidx.compose.animation.core.tween(300),
- label = "bg_color"
- )
- val iconTintColor by androidx.compose.animation.animateColorAsState(
- targetValue = if (isGranted) MaterialTheme.colorScheme.onTertiaryContainer
- else MaterialTheme.colorScheme.onSecondaryContainer,
- animationSpec = androidx.compose.animation.core.tween(300),
- label = "icon_tint_color"
- )
-
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Spacer(modifier = Modifier.height(24.dp))
-
- // Icon with status
- Box(contentAlignment = Alignment.Center) {
- Surface(
- modifier = Modifier.size(80.dp),
- shape = CircleShape,
- color = bgColor
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- imageVector = if (isGranted) Icons.Rounded.CheckCircle else icon,
- contentDescription = null,
- modifier = Modifier.size(40.dp),
- tint = iconTintColor
- )
- }
- }
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- Text(
- text = title,
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center
- )
-
- Spacer(modifier = Modifier.height(8.dp))
-
- // Status chip
- Surface(
- color = bgColor,
- shape = RoundedCornerShape(20.dp)
- ) {
- Text(
- text = statusText,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
- style = MaterialTheme.typography.labelMedium,
- color = iconTintColor
- )
- }
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Text(
- text = description,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- // "Why do we need this?" expandable
- TextButton(onClick = { showWhy = !showWhy }) {
- Icon(
- imageVector = if (showWhy) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
- contentDescription = null,
- modifier = Modifier.size(18.dp)
- )
- Spacer(modifier = Modifier.width(4.dp))
- Text("Why do we need this?")
- }
-
- AnimatedVisibility(
- visible = showWhy,
- enter = expandVertically(),
- exit = shrinkVertically()
- ) {
- Surface(
- color = MaterialTheme.colorScheme.surfaceVariant,
- shape = RoundedCornerShape(12.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = whyNeeded,
- modifier = Modifier.padding(16.dp),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- // Action button
- if (actionLabel != null) {
- Button(
- onClick = onAction,
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp),
- shape = RoundedCornerShape(12.dp)
- ) {
- Text(actionLabel)
- }
- } else if (isGranted) {
- Surface(
- color = MaterialTheme.colorScheme.tertiaryContainer,
- shape = RoundedCornerShape(12.dp),
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp)
- ) {
- Box(contentAlignment = Alignment.Center) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- Icons.Rounded.CheckCircle,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onTertiaryContainer
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- "All set!",
- color = MaterialTheme.colorScheme.onTertiaryContainer,
- fontWeight = FontWeight.SemiBold
- )
- }
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/com/inputleaf/android/ui/PermissionMethod.kt b/app/src/main/java/com/inputleaf/android/ui/PermissionMethod.kt
new file mode 100644
index 00000000..f6efd647
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/ui/PermissionMethod.kt
@@ -0,0 +1,60 @@
+package com.inputleaf.android.ui
+
+/**
+ * Input setup methods shown in the permissions accordion.
+ * Order matches the UI: Shizuku, Root, then non-root Accessibility.
+ */
+enum class PermissionMethod {
+ SHIZUKU,
+ ROOT,
+ ACCESSIBILITY,
+}
+
+/**
+ * The method the accordion should open on when the user has not picked one.
+ *
+ * [active] is the method a live session is actually injecting through. With no session,
+ * this falls back to whichever method would be chosen right now, in the same order
+ * [com.inputleaf.android.privilege.PrivilegeSelector] uses, so the open card always
+ * matches what the app would really do.
+ *
+ * @return null when nothing is set up yet, leaving the caller's own default in place.
+ */
+fun activePermissionMethod(
+ active: PermissionMethod?,
+ shizukuReady: Boolean,
+ rootGranted: Boolean,
+ accessibilityAvailable: Boolean,
+): PermissionMethod? {
+ if (active != null) return active
+ return when {
+ shizukuReady -> PermissionMethod.SHIZUKU
+ rootGranted -> PermissionMethod.ROOT
+ accessibilityAvailable -> PermissionMethod.ACCESSIBILITY
+ else -> null
+ }
+}
+
+/**
+ * Exclusive accordion: exactly one method is expanded.
+ *
+ * Opens on the method actually in use, but stops following it the moment the user
+ * taps a header — otherwise connecting or losing Shizuku mid-read would yank the
+ * card out from under them.
+ */
+data class PermissionMethodAccordionState(
+ val expanded: PermissionMethod = PermissionMethod.SHIZUKU,
+ val userChosen: Boolean = false,
+) {
+ fun onHeaderClick(method: PermissionMethod): PermissionMethodAccordionState {
+ return copy(expanded = method, userChosen = true)
+ }
+
+ /** Follows the in-use method until the user expresses a preference. */
+ fun onActiveMethodChanged(method: PermissionMethod?): PermissionMethodAccordionState {
+ if (userChosen || method == null || method == expanded) return this
+ return copy(expanded = method)
+ }
+
+ fun isExpanded(method: PermissionMethod): Boolean = expanded == method
+}
diff --git a/app/src/main/java/com/inputleaf/android/ui/PermissionStatusProvider.kt b/app/src/main/java/com/inputleaf/android/ui/PermissionStatusProvider.kt
index 77b63b7f..5ddbaee0 100644
--- a/app/src/main/java/com/inputleaf/android/ui/PermissionStatusProvider.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/PermissionStatusProvider.kt
@@ -6,8 +6,17 @@ import android.content.pm.PackageManager
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
+import com.inputleaf.android.privilege.LibSuRootAccess
+import com.inputleaf.android.privilege.RootAvailability
+import com.inputleaf.android.storage.AppPreferences
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import rikka.shizuku.Shizuku
enum class ShizukuStatus {
@@ -18,10 +27,28 @@ enum class ShizukuStatus {
READY
}
-class PermissionStatusProvider(private val app: Application) {
+enum class RootStatus {
+ CHECKING,
+ MISSING,
+ AVAILABLE,
+ DENIED,
+ GRANTED,
+}
+
+class PermissionStatusProvider(
+ private val app: Application,
+ private val prefs: AppPreferences = AppPreferences(app),
+) {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _shizukuStatus = MutableStateFlow(ShizukuStatus.CHECKING)
val shizukuStatus: StateFlow = _shizukuStatus
+
+ @Volatile private var rootReprobeStarted = false
+
+ private val _rootStatus = MutableStateFlow(RootStatus.CHECKING)
+ val rootStatus: StateFlow = _rootStatus
private val _canDrawOverlays = MutableStateFlow(false)
val canDrawOverlays: StateFlow = _canDrawOverlays
@@ -31,6 +58,12 @@ class PermissionStatusProvider(private val app: Application) {
val shizukuAvailable: Flow = shizukuStatus.map { it == ShizukuStatus.READY }
+ val rootGranted: Flow = rootStatus.map { it == RootStatus.GRANTED }
+
+ val rootUsable: Flow = rootStatus.map {
+ it == RootStatus.GRANTED || it == RootStatus.AVAILABLE
+ }
+
val accessibilityAvailable: Flow = flow {
val resolver = app.contentResolver
val comp = ComponentName(
@@ -89,6 +122,7 @@ class PermissionStatusProvider(private val app: Application) {
init {
setupShizukuListeners()
checkShizukuStatus()
+ checkRootStatus()
checkOverlayPermission()
checkBatteryOptimization()
}
@@ -126,6 +160,81 @@ class PermissionStatusProvider(private val app: Application) {
}
}
+ fun checkRootStatus() {
+ val availability = LibSuRootAccess.availability()
+ _rootStatus.value = rootStatusFrom(availability)
+ if (availability == RootAvailability.UNKNOWN) {
+ reprobeRememberedGrant()
+ }
+ }
+
+ /**
+ * Resolve an [RootAvailability.UNKNOWN] that is only unknown because libsu has not
+ * built a shell yet.
+ *
+ * `Shell.isAppGrantedRoot()` cannot tell "already granted" from "never asked" before
+ * that, so a device where su was granted long ago still cold-starts as AVAILABLE and
+ * shows a false "Setup Required". Creating the shell settles it, and on an already
+ * granted app the su manager re-grants with no dialog — so this only runs once the
+ * app has actually been granted before, and never prompts anyone out of the blue.
+ */
+ private fun reprobeRememberedGrant() {
+ if (rootReprobeStarted) return
+ rootReprobeStarted = true
+ scope.launch(Dispatchers.IO) {
+ if (!prefs.rootGrantRemembered.first()) return@launch
+ val granted = runCatching { LibSuRootAccess.requestAccess() }.getOrDefault(false)
+ Log.i("InputLeaf", "Silent root re-probe of remembered grant: granted=$granted")
+ updateRootGrant(granted)
+ }
+ }
+
+ fun rootAvailability(): RootAvailability = LibSuRootAccess.availability()
+
+ fun requestRootAccess() {
+ rootReprobeStarted = true
+ scope.launch(Dispatchers.IO) {
+ val granted = try {
+ LibSuRootAccess.requestAccess()
+ } catch (e: Exception) {
+ Log.e("InputLeaf", "Error requesting root access", e)
+ false
+ }
+ updateRootGrant(granted)
+ }
+ }
+
+ /**
+ * Same persistence as the Grant button. Connect prompts through
+ * [LibSuRootAccess.requestAccess] and must not throw that boolean away,
+ * or the next cold start forgets a grant libsu cannot see without a shell.
+ */
+ internal suspend fun rememberRootGrant(granted: Boolean) {
+ updateRootGrant(granted)
+ }
+
+ /** Publishes the settled grant and remembers it so the next cold start can skip the tap. */
+ private suspend fun updateRootGrant(granted: Boolean) {
+ _rootStatus.value = if (granted) {
+ RootStatus.GRANTED
+ } else {
+ val settled = rootStatusFrom(LibSuRootAccess.availability())
+ if (settled == RootStatus.GRANTED) RootStatus.GRANTED else RootStatus.DENIED
+ }
+ // Clearing on a revoke matters as much as setting it: otherwise every later
+ // launch would fire an su prompt the user has already turned down.
+ runCatching { prefs.saveRootGrantRemembered(_rootStatus.value == RootStatus.GRANTED) }
+ }
+
+ private fun rootStatusFrom(availability: RootAvailability): RootStatus {
+ return when (availability) {
+ RootAvailability.MISSING -> RootStatus.MISSING
+ RootAvailability.UNKNOWN -> RootStatus.AVAILABLE
+ RootAvailability.DENIED -> RootStatus.DENIED
+ RootAvailability.GRANTED -> RootStatus.GRANTED
+ }
+ }
+
fun requestShizukuPermission() {
try {
if (Shizuku.pingBinder()) {
@@ -152,6 +261,7 @@ class PermissionStatusProvider(private val app: Application) {
}
fun cleanup() {
+ scope.cancel()
try {
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
Shizuku.removeBinderReceivedListener(shizukuBinderListener)
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 88338b4f..878824ef 100644
--- a/app/src/main/java/com/inputleaf/android/ui/ServerListItem.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/ServerListItem.kt
@@ -1,26 +1,31 @@
package com.inputleaf.android.ui
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Computer
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
+import androidx.compose.material.icons.rounded.Computer
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
-import androidx.compose.material3.*
+import androidx.compose.material3.Badge
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconToggleButton
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.ListItemShapes
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedListItem
+import androidx.compose.material3.Text
+import androidx.compose.material3.toShape
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.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,
@@ -31,104 +36,80 @@ internal fun connectingServerIp(
else -> null
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ServerListItem(
server: ServerInfo,
isConnected: Boolean,
onServerClick: (ServerInfo) -> Unit,
+ shapes: ListItemShapes,
modifier: Modifier = Modifier,
isFavorite: Boolean = false,
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)
- .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,
- padding = 16.dp
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
+ val colors = MaterialTheme.colorScheme
+ SegmentedListItem(
+ onClick = { onServerClick(server) },
+ shapes = shapes,
+ modifier = modifier,
+ enabled = enabled && !isConnecting && !isConnected,
+ colors = if (isConnected) {
+ ListItemDefaults.segmentedColors(
+ containerColor = colors.primaryContainer,
+ contentColor = colors.onPrimaryContainer,
+ supportingContentColor = colors.onPrimaryContainer,
+ disabledContainerColor = colors.primaryContainer,
+ disabledContentColor = colors.onPrimaryContainer,
+ disabledSupportingContentColor = colors.onPrimaryContainer,
+ disabledLeadingContentColor = colors.onPrimaryContainer,
+ disabledTrailingContentColor = colors.onPrimaryContainer,
+ )
+ } else {
+ ListItemDefaults.segmentedColors()
+ },
+ leadingContent = {
CircularAvatar(
icon = Icons.Rounded.Computer,
size = 48.dp,
iconSize = 24.dp,
- backgroundColor = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHighest,
- iconTint = if (isConnected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
- elevation = if (isConnected) 2.dp else 0.dp
+ backgroundColor = if (isConnected) colors.primary else colors.secondaryContainer,
+ shape = if (isConnected) MaterialShapes.Cookie9Sided.toShape() else MaterialShapes.Circle.toShape(),
)
-
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = server.name.ifBlank { server.ip },
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.titleSmall
- )
- Spacer(modifier = Modifier.height(2.dp))
- Text(
- 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
- )
- }
-
- if (onToggleFavorite != null) {
- IconButton(
- onClick = onToggleFavorite,
- enabled = enabled,
- modifier = Modifier.size(32.dp)
- ) {
- Icon(
- imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
- contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
- tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
- modifier = Modifier.size(20.dp)
- )
+ },
+ supportingContent = {
+ Text(
+ when {
+ isConnecting -> "Connecting…"
+ isConnected -> "${server.ip} • Connected"
+ else -> server.ip
+ },
+ )
+ },
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (onToggleFavorite != null) {
+ IconToggleButton(
+ checked = isFavorite,
+ onCheckedChange = { onToggleFavorite() },
+ enabled = enabled,
+ ) {
+ Icon(
+ imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
+ contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
+ )
+ }
}
- }
-
- 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)
- ) {
- Surface(
- modifier = Modifier.fillMaxSize(),
- shape = CircleShape,
- color = MaterialTheme.colorScheme.primary
- ) {}
+ when {
+ isConnecting -> LoadingIndicator(Modifier.size(32.dp))
+ isConnected -> Badge(containerColor = colors.primary)
+ onToggleFavorite == null ->
+ Icon(Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = "Navigate")
}
- } else if (onToggleFavorite == null) {
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
- contentDescription = "Navigate",
- tint = MaterialTheme.colorScheme.outline,
- modifier = Modifier.size(20.dp)
- )
}
- }
+ },
+ ) {
+ Text(server.name.ifBlank { server.ip })
}
}
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 e3383602..ef39e69e 100644
--- a/app/src/main/java/com/inputleaf/android/ui/ServerListScreen.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/ServerListScreen.kt
@@ -2,20 +2,21 @@ package com.inputleaf.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Star
-import androidx.compose.material.icons.rounded.StarBorder
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import com.inputleaf.android.model.ConnectionState
import com.inputleaf.android.model.ServerInfo
+import com.inputleaf.android.ui.components.SectionHeader
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ServerListScreen(
connectionState: ConnectionState,
@@ -34,107 +35,75 @@ fun ServerListScreen(
val connectingIp = connectingServerIp(connectionState, pendingConnectIp)
val favorites = discoveredServers.filter { favoriteServers.contains(it.ip) }
val others = discoveredServers.filter { !favoriteServers.contains(it.ip) }
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
- TopAppBar(
- title = {
- Text(
- text = "Servers",
- fontWeight = FontWeight.Bold,
- style = MaterialTheme.typography.titleLarge,
- letterSpacing = 0.5.sp
- )
- }
+ LargeFlexibleTopAppBar(
+ title = { Text("Servers") },
+ scrollBehavior = scrollBehavior,
)
}
) { padding ->
LazyColumn(
contentPadding = padding,
- modifier = Modifier.fillMaxSize()
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
- // Favorites section
if (favorites.isNotEmpty()) {
- item {
- ListItem(
- headlineContent = {
- Text(
- "Favorites",
- style = MaterialTheme.typography.titleMedium
- )
- },
- leadingContent = {
- Icon(
- Icons.Rounded.Star,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary
- )
- }
- )
- HorizontalDivider()
- }
-
- items(favorites) { server ->
- val isConnected = isServerConnected(connectionState, server)
- val isConnecting = connectingIp == server.ip
+ item { SectionHeader("Favorites") }
+ itemsIndexed(favorites) { index, server ->
ServerListItem(
server = server,
- isConnected = isConnected,
+ isConnected = isServerConnected(connectionState, server),
onServerClick = onConnect,
+ shapes = ListItemDefaults.segmentedShapes(index, favorites.size),
+ modifier = Modifier.padding(horizontal = 16.dp),
isFavorite = true,
onToggleFavorite = { onToggleFavorite(server.ip) },
- isConnecting = isConnecting,
+ isConnecting = connectingIp == server.ip,
enabled = connectingIp == null,
)
}
}
- // Discovered servers
- item {
- ListItem(
- headlineContent = {
- Text(
- "Discovered Servers",
- style = MaterialTheme.typography.titleMedium
- )
- }
- )
- HorizontalDivider()
- }
-
- items(others) { server ->
- val isConnected = isServerConnected(connectionState, server)
- val isConnecting = connectingIp == server.ip
+ item { SectionHeader("Discovered Servers") }
+ itemsIndexed(others) { index, server ->
ServerListItem(
server = server,
- isConnected = isConnected,
+ isConnected = isServerConnected(connectionState, server),
onServerClick = onConnect,
+ shapes = ListItemDefaults.segmentedShapes(index, others.size),
+ modifier = Modifier.padding(horizontal = 16.dp),
isFavorite = false,
onToggleFavorite = { onToggleFavorite(server.ip) },
- isConnecting = isConnecting,
+ isConnecting = connectingIp == server.ip,
enabled = connectingIp == null,
)
}
- // Empty state
- if (discoveredServers.isEmpty() && !isScanning) {
+ if (discoveredServers.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
- contentAlignment = androidx.compose.ui.Alignment.Center
+ contentAlignment = Alignment.Center
) {
- Text(
- text = "No servers found. Try scanning again.",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
+ if (isScanning) {
+ LoadingIndicator()
+ } else {
+ Text(
+ text = "No servers found. Try scanning again.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
}
}
- // Action buttons
item {
Row(
Modifier
@@ -142,15 +111,17 @@ fun ServerListScreen(
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- OutlinedButton(
+ FilledTonalButton(
onClick = onScan,
enabled = !isScanning && connectingIp == null,
modifier = Modifier.weight(1f)
) {
if (isScanning) {
- CircularProgressIndicator(Modifier.size(16.dp))
- Spacer(Modifier.width(8.dp))
+ LoadingIndicator(Modifier.size(ButtonDefaults.IconSize))
+ } else {
+ Icon(Icons.Rounded.Refresh, contentDescription = null, Modifier.size(ButtonDefaults.IconSize))
}
+ Spacer(Modifier.width(ButtonDefaults.IconSpacing))
Text("Scan Again")
}
OutlinedButton(
@@ -158,6 +129,8 @@ fun ServerListScreen(
enabled = connectingIp == null,
modifier = Modifier.weight(1f)
) {
+ Icon(Icons.Rounded.Add, contentDescription = null, Modifier.size(ButtonDefaults.IconSize))
+ Spacer(Modifier.width(ButtonDefaults.IconSpacing))
Text("Add Manually")
}
}
@@ -168,6 +141,7 @@ fun ServerListScreen(
if (showAddDialog) {
AlertDialog(
onDismissRequest = { showAddDialog = false },
+ icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
title = { Text("Add Server") },
text = {
OutlinedTextField(
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 052f4697..6835e668 100644
--- a/app/src/main/java/com/inputleaf/android/ui/SettingsScreen.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/SettingsScreen.kt
@@ -3,44 +3,42 @@ package com.inputleaf.android.ui
import android.content.Intent
import android.net.Uri
import android.widget.Toast
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.platform.LocalContext
-import com.inputleaf.android.update.InstallSource
-import com.inputleaf.android.update.UpdateService
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.*
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
+import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
-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.SectionHeader
-import com.inputleaf.android.ui.components.SettingsRow
-import com.inputleaf.android.ui.components.ThemeModeOption
-import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.foundation.Image
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.BorderStroke
+import androidx.compose.ui.unit.dp
+import com.inputleaf.android.R
import com.inputleaf.android.network.ClientCertificateSummary
import com.inputleaf.android.network.ConnectionTransportPolicy
import com.inputleaf.android.network.TlsFingerprintManager
+import com.inputleaf.android.ui.components.CircularAvatar
+import com.inputleaf.android.ui.components.ConnectedChoiceRow
+import com.inputleaf.android.ui.components.SectionHeader
+import com.inputleaf.android.ui.components.SettingsGroup
+import com.inputleaf.android.ui.components.SettingsRow
+import com.inputleaf.android.ui.components.SettingsSwitchRow
+import com.inputleaf.android.update.InstallSource
+import com.inputleaf.android.update.UpdateService
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsScreen(
screenName: String,
@@ -51,6 +49,8 @@ fun SettingsScreen(
connectionTransportPolicy: ConnectionTransportPolicy,
cursorStyle: String,
shizukuAvailable: Boolean,
+ rootGranted: Boolean,
+ rootUsable: Boolean,
accessibilityAvailable: Boolean,
canDrawOverlays: Boolean,
fingerprints: Map,
@@ -74,9 +74,9 @@ fun SettingsScreen(
val cursorAvailable = canDrawOverlays || accessibilityAvailable
val versionName = remember(context) {
try {
- context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.4.2"
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.4.3"
} catch (_: Exception) {
- "1.4.2"
+ "1.4.3"
}
}
val installSource = remember(context) { UpdateService.getInstallSource(context) }
@@ -88,8 +88,6 @@ fun SettingsScreen(
}
}
- var editingName by remember(screenName) { mutableStateOf(screenName) }
- var showThemeDialog by remember { mutableStateOf(false) }
var showEditNameDialog by remember { mutableStateOf(false) }
var showInputMethodDialog by remember { mutableStateOf(false) }
var showTransportPolicyDialog by remember { mutableStateOf(false) }
@@ -98,22 +96,19 @@ fun SettingsScreen(
var showRegenerateConfirm by remember { mutableStateOf(false) }
var showAuthorDialog by remember { mutableStateOf(false) }
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+
Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
- TopAppBar(
- title = {
- Text(
- text = "Settings",
- fontWeight = FontWeight.Bold,
- style = MaterialTheme.typography.titleLarge,
- letterSpacing = 0.5.sp
- )
- },
+ LargeFlexibleTopAppBar(
+ title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back")
}
- }
+ },
+ scrollBehavior = scrollBehavior,
)
}
) { padding ->
@@ -122,46 +117,30 @@ fun SettingsScreen(
.fillMaxWidth()
.padding(padding)
.verticalScroll(rememberScrollState())
- .padding(horizontal = 20.dp, vertical = 16.dp)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
) {
- // Connection Section
- SectionHeader("CONNECTION")
-
- GradientCard(
- modifier = Modifier.fillMaxWidth(),
- backgroundColor = MaterialTheme.colorScheme.surface,
- cornerRadius = 24.dp,
- padding = 0.dp
- ) {
- Column {
- // Screen Name
+ SectionHeader("Connection")
+ SettingsGroup {
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.Phone,
title = "Screen name",
subtitle = screenName,
+ shapes = shapes,
onClick = { showEditNameDialog = true }
)
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant,
- thickness = 1.dp,
- modifier = Modifier.padding(start = 72.dp)
- )
- // Auto-connect
- SettingsRow(
+ }
+ row { shapes ->
+ SettingsSwitchRow(
icon = Icons.Rounded.Build,
title = "Auto-connect on launch",
- trailingContent = {
- Switch(
- checked = autoConnect,
- onCheckedChange = onAutoConnectChange,
- )
- }
- )
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant,
- thickness = 1.dp,
- modifier = Modifier.padding(start = 72.dp)
+ checked = autoConnect,
+ onCheckedChange = onAutoConnectChange,
+ shapes = shapes,
)
+ }
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.Lock,
title = "Connection security",
@@ -170,445 +149,245 @@ fun SettingsScreen(
ConnectionTransportPolicy.TLS_ONLY -> "TLS only"
ConnectionTransportPolicy.PLAIN_ONLY -> "Plain only"
},
+ shapes = shapes,
onClick = { showTransportPolicyDialog = true }
)
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant,
- thickness = 1.dp,
- modifier = Modifier.padding(start = 72.dp)
- )
- // Input Method
+ }
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.Keyboard,
title = "Input method",
subtitle = when (inputMethod) {
"shizuku" -> "Shizuku (ADB-level injection)"
- "accessibility" -> "Accessibility Service (no extra app)"
- else -> "Auto (Recommended)"
+ "root" -> "Root (su HID injection)"
+ "accessibility" -> "Accessibility Service (overlay fallback)"
+ else -> "Auto (Shizuku, then root, then Accessibility)"
},
+ shapes = shapes,
onClick = { showInputMethodDialog = true }
)
}
}
- Spacer(modifier = Modifier.height(24.dp))
-
- // Display Section
- SectionHeader("DISPLAY")
-
- GradientCard(
- modifier = Modifier.fillMaxWidth(),
- backgroundColor = MaterialTheme.colorScheme.surface,
- cornerRadius = 24.dp,
- padding = 0.dp
- ) {
- Column {
- if (!cursorAvailable) {
- // Permission warning
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(MaterialTheme.colorScheme.errorContainer)
- .padding(16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- CircularAvatar(
- icon = Icons.Rounded.Warning,
- size = 40.dp,
- iconSize = 24.dp,
- backgroundColor = MaterialTheme.colorScheme.error.copy(alpha = 0.1f),
- iconTint = MaterialTheme.colorScheme.error
- )
- Spacer(modifier = Modifier.width(12.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Overlay or Accessibility required",
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onErrorContainer,
- style = MaterialTheme.typography.bodyMedium
- )
- Text(
- text = "Grant overlay permission or enable Accessibility Service to show the cursor",
- color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f),
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
- TextButton(
- onClick = onRequestOverlayPermission,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
- ) {
- Text("Grant Permission", color = MaterialTheme.colorScheme.primary)
- }
- }
- // Show Cursor toggle
- SettingsRow(
+ SectionHeader("Display")
+ if (!cursorAvailable) {
+ NoticeCard(
+ icon = Icons.Rounded.Warning,
+ title = "Overlay or Accessibility required",
+ body = "Grant overlay permission or enable Accessibility Service to show the cursor",
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ action = {
+ TextButton(onClick = onRequestOverlayPermission) { Text("Grant Permission") }
+ },
+ )
+ }
+ SettingsGroup {
+ row { shapes ->
+ SettingsSwitchRow(
icon = Icons.Rounded.Info,
title = "Show cursor overlay",
- subtitle = when {
- canDrawOverlays || accessibilityAvailable -> "Display cursor when active"
- else -> "Needs overlay permission or Accessibility"
- },
- trailingContent = {
- Switch(
- checked = showCursor,
- onCheckedChange = onShowCursorChange,
- enabled = cursorAvailable,
- )
- }
+ subtitle = if (cursorAvailable) "Display cursor when active" else "Needs overlay permission or Accessibility",
+ checked = showCursor,
+ onCheckedChange = onShowCursorChange,
+ enabled = cursorAvailable,
+ shapes = shapes,
)
- if (showCursor && cursorAvailable) {
+ }
+ if (showCursor && cursorAvailable) {
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.Edit,
title = "Cursor style",
subtitle = if (cursorStyle == "leaf") "Input Leaf custom" else "Android default",
+ shapes = shapes,
onClick = { showCursorStyleDialog = true }
)
- if (shizukuAvailable && (inputMethod == "auto" || inputMethod == "shizuku")) {
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 6.dp)
- .clip(RoundedCornerShape(12.dp)),
- color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- Icon(
- imageVector = Icons.Rounded.Info,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(20.dp)
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Cursor in notification panel",
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = "Enable Accessibility Service for cursor visibility over the notification shade.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
- } else if (!accessibilityAvailable) {
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 6.dp)
- .clip(RoundedCornerShape(12.dp))
- .clickable {
- try {
- val intent = Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- context.startActivity(intent)
- } catch (_: Exception) {
- Toast.makeText(context, "Unable to open Accessibility Settings", Toast.LENGTH_SHORT).show()
- }
- },
- color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- Icon(
- imageVector = Icons.Rounded.Info,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(20.dp)
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Cursor in notification panel",
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = "Enable Accessibility Service for cursor visibility over the notification shade.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(16.dp)
- )
- }
- }
- }
}
- // Theme setting
- SettingsRow(
- icon = Icons.Rounded.Settings,
- title = "Theme",
- subtitle = when (themeMode) {
- "LIGHT" -> "Light"
- "DARK" -> "Dark"
- else -> "System default"
+ }
+ row { shapes ->
+ SegmentedListItem(
+ shapes = shapes,
+ leadingContent = {
+ CircularAvatar(
+ icon = Icons.Rounded.Settings,
+ size = 40.dp,
+ iconSize = 22.dp,
+ backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
+ )
},
- onClick = { showThemeDialog = true }
- )
+ supportingContent = {
+ ConnectedChoiceRow(
+ options = listOf("SYSTEM" to "System", "LIGHT" to "Light", "DARK" to "Dark"),
+ selected = themeMode,
+ onSelect = onThemeModeChange,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ },
+ ) {
+ Text("Theme")
+ }
}
}
+ val usesPrivilegedInput = (shizukuAvailable || rootGranted) &&
+ (inputMethod == "auto" || inputMethod == "shizuku" || inputMethod == "root")
+ if (showCursor && cursorAvailable && (usesPrivilegedInput || !accessibilityAvailable)) {
+ val onAccessibilityShortcut: () -> Unit = {
+ try {
+ context.startActivity(
+ Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ } catch (_: Exception) {
+ Toast.makeText(context, "Unable to open Accessibility Settings", Toast.LENGTH_SHORT).show()
+ }
+ }
+ NoticeCard(
+ icon = Icons.Rounded.Info,
+ title = "Cursor in notification panel",
+ body = "Enable Accessibility Service for cursor visibility over the notification shade.",
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ onClick = if (usesPrivilegedInput) null else onAccessibilityShortcut,
+ )
+ }
- Spacer(modifier = Modifier.height(24.dp))
-
- SectionHeader("SECURITY")
-
- GradientCard(
- modifier = Modifier.fillMaxWidth(),
- backgroundColor = MaterialTheme.colorScheme.surface,
- cornerRadius = 24.dp,
- padding = 0.dp
- ) {
- Column {
+ SectionHeader("Security")
+ SettingsGroup {
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.Badge,
title = "This device's fingerprint",
subtitle = clientCertificateSummary?.let { summary ->
- TlsFingerprintManager.formatFingerprint(summary.fingerprint.take(16)) +
- "…"
+ TlsFingerprintManager.formatFingerprint(summary.fingerprint.take(16)) + "…"
} ?: "Creating a certificate for this device…",
+ shapes = shapes,
onClick = { if (clientCertificateSummary != null) showLocalFingerprint = true },
trailingContent = {
IconButton(
onClick = { showRegenerateConfirm = true },
enabled = clientCertificateSummary != null,
) {
- Icon(
- Icons.Rounded.Refresh,
- contentDescription = "Regenerate certificate",
- tint = MaterialTheme.colorScheme.outline,
- )
+ Icon(Icons.Rounded.Refresh, contentDescription = "Regenerate certificate")
}
}
)
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant,
- thickness = 1.dp,
- modifier = Modifier.padding(start = 72.dp)
- )
- // Trusted servers header
+ }
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.Lock,
title = "Trusted servers",
subtitle = "${fingerprints.size} server${if (fingerprints.size != 1) "s" else ""}",
+ shapes = shapes,
)
- // Server entries
- fingerprints.entries.forEachIndexed { index, (ip, fp) ->
- if (index > 0) {
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant,
- thickness = 1.dp,
- modifier = Modifier.padding(start = 72.dp)
- )
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalAlignment = Alignment.CenterVertically
+ }
+ fingerprints.forEach { (ip, fp) ->
+ row { shapes ->
+ SegmentedListItem(
+ shapes = shapes,
+ supportingContent = { Text(fp.take(16) + "...") },
+ trailingContent = {
+ IconButton(onClick = { onDeleteFingerprint(ip) }) {
+ Icon(Icons.Rounded.Delete, contentDescription = "Remove")
+ }
+ },
) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = ip,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.bodyMedium
- )
- Text(
- text = fp.take(16) + "...",
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- style = MaterialTheme.typography.bodySmall
- )
- }
- IconButton(onClick = { onDeleteFingerprint(ip) }) {
- Icon(
- Icons.Rounded.Delete,
- contentDescription = "Remove",
- tint = MaterialTheme.colorScheme.outline
- )
- }
+ Text(ip)
}
}
}
}
-
- Spacer(modifier = Modifier.height(24.dp))
- // About & Community Section
- SectionHeader("ABOUT & COMMUNITY")
-
- GradientCard(
- modifier = Modifier.fillMaxWidth(),
- backgroundColor = MaterialTheme.colorScheme.surface,
- cornerRadius = 24.dp,
- padding = 0.dp
- ) {
- Column {
- // App Info Header
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(20.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Image(
- painter = painterResource(id = com.inputleaf.android.R.drawable.ic_splash_logo),
- contentDescription = "Input Leaf Logo",
- modifier = Modifier
- .size(52.dp)
- .clip(RoundedCornerShape(12.dp))
- )
- Column(modifier = Modifier.weight(1f)) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ SectionHeader("About & Community")
+ SettingsGroup {
+ row { shapes ->
+ SegmentedListItem(
+ shapes = shapes,
+ leadingContent = {
+ Image(
+ painter = painterResource(id = R.drawable.ic_splash_logo),
+ contentDescription = "Input Leaf Logo",
+ modifier = Modifier
+ .size(52.dp)
+ .clip(MaterialTheme.shapes.medium)
+ )
+ },
+ supportingContent = { Text("Open-source Android client for Input Leap") },
+ trailingContent = {
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = MaterialTheme.shapes.small,
) {
Text(
- text = "Input Leaf",
- fontWeight = FontWeight.Bold,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface
+ text = "v$versionName",
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
- Surface(
- color = MaterialTheme.colorScheme.primaryContainer,
- shape = RoundedCornerShape(8.dp)
- ) {
- Text(
- text = "v$versionName",
- style = MaterialTheme.typography.labelSmall,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
- )
- }
}
- Spacer(modifier = Modifier.height(2.dp))
- Text(
- text = "Open-source Android client for Input Leap",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
+ },
+ ) {
+ Text("Input Leaf", style = MaterialTheme.typography.titleMediumEmphasized)
}
-
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant,
- thickness = 1.dp,
- modifier = Modifier.padding(start = 72.dp)
- )
-
- // Check for updates
+ }
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.Sync,
title = "Check for updates",
subtitle = if (isCheckingUpdate) "Checking latest release…" else "v$versionName · $installSourceLabel",
+ shapes = shapes,
onClick = onCheckForUpdates,
trailingContent = if (isCheckingUpdate) {
- {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.primary
- )
- }
+ { LoadingIndicator(Modifier.size(32.dp)) }
} else null
)
-
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant,
- thickness = 1.dp,
- modifier = Modifier.padding(start = 72.dp)
- )
-
- // GitHub Repository
+ }
+ row { shapes ->
SettingsRow(
- painter = painterResource(id = com.inputleaf.android.R.drawable.ic_brand_github),
+ painter = painterResource(id = R.drawable.ic_brand_github),
title = "GitHub Repository",
subtitle = "anasvhora284/input-leaf",
+ shapes = shapes,
onClick = { openUrl(context, "https://github.com/anasvhora284/input-leaf") }
)
-
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant,
- thickness = 1.dp,
- modifier = Modifier.padding(start = 72.dp)
- )
-
- // Contributors
+ }
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.Group,
title = "Contributors",
subtitle = "View contributors on GitHub",
+ shapes = shapes,
onClick = { openUrl(context, "https://github.com/anasvhora284/input-leaf/graphs/contributors") }
)
-
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant,
- thickness = 1.dp,
- modifier = Modifier.padding(start = 72.dp)
- )
-
- // Report an Issue
+ }
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.BugReport,
title = "Report an Issue",
subtitle = "GitHub issues & feature requests",
+ shapes = shapes,
onClick = { openUrl(context, "https://github.com/anasvhora284/input-leaf/issues") }
)
}
}
- Spacer(modifier = Modifier.height(24.dp))
-
- // Author Info Section
- SectionHeader("DEVELOPER")
-
- GradientCard(
- modifier = Modifier.fillMaxWidth(),
- backgroundColor = MaterialTheme.colorScheme.surface,
- cornerRadius = 24.dp,
- padding = 0.dp
- ) {
- Column {
+ SectionHeader("Developer")
+ SettingsGroup {
+ row { shapes ->
SettingsRow(
icon = Icons.Rounded.Person,
title = "Author Info",
subtitle = "Anas Vhora · Connect & Socials",
+ shapes = shapes,
onClick = { showAuthorDialog = true }
)
}
}
- Spacer(modifier = Modifier.height(28.dp))
-
- // Made with love footer
Column(
modifier = Modifier
.fillMaxWidth()
- .padding(bottom = 24.dp),
+ .padding(top = 20.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(4.dp)
) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center
- ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Made with ",
style = MaterialTheme.typography.bodyMedium,
@@ -617,7 +396,7 @@ fun SettingsScreen(
Icon(
imageVector = Icons.Rounded.Favorite,
contentDescription = "Love",
- tint = Color(0xFFE11D48),
+ tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Text(
@@ -626,37 +405,18 @@ fun SettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center
- ) {
- Text(
- text = "Anas Vhora",
- style = MaterialTheme.typography.bodyMedium.copy(
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary
- ),
- modifier = Modifier
- .clip(RoundedCornerShape(4.dp))
- .clickable { openUrl(context, "https://github.com/anasvhora284") }
- .padding(horizontal = 4.dp, vertical = 2.dp)
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ TextButton(onClick = { openUrl(context, "https://github.com/anasvhora284") }) {
+ Text("Anas Vhora")
+ }
Text(
- text = " & ",
+ text = "&",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- Text(
- text = "input-leaf contributors",
- style = MaterialTheme.typography.bodyMedium.copy(
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary
- ),
- modifier = Modifier
- .clip(RoundedCornerShape(4.dp))
- .clickable { openUrl(context, "https://github.com/anasvhora284/input-leaf/graphs/contributors") }
- .padding(horizontal = 4.dp, vertical = 2.dp)
- )
+ TextButton(onClick = { openUrl(context, "https://github.com/anasvhora284/input-leaf/graphs/contributors") }) {
+ Text("input-leaf contributors")
+ }
}
}
}
@@ -682,6 +442,7 @@ fun SettingsScreen(
if (showRegenerateConfirm) {
AlertDialog(
onDismissRequest = { showRegenerateConfirm = false },
+ icon = { Icon(Icons.Rounded.Refresh, contentDescription = null) },
title = { Text("Regenerate certificate?") },
text = {
Text(
@@ -707,135 +468,54 @@ fun SettingsScreen(
)
}
- if (showThemeDialog) {
- AlertDialog(
- onDismissRequest = { showThemeDialog = false },
- title = { Text("Select Theme") },
- text = {
- Column {
- ThemeModeOption(
- text = "System default",
- selected = themeMode == "SYSTEM",
- onClick = {
- onThemeModeChange("SYSTEM")
- showThemeDialog = false
- }
- )
- ThemeModeOption(
- text = "Light",
- selected = themeMode == "LIGHT",
- onClick = {
- onThemeModeChange("LIGHT")
- showThemeDialog = false
- }
- )
- ThemeModeOption(
- text = "Dark",
- selected = themeMode == "DARK",
- onClick = {
- onThemeModeChange("DARK")
- showThemeDialog = false
- }
- )
- }
- },
- confirmButton = {
- TextButton(onClick = { showThemeDialog = false }) {
- Text("Cancel")
- }
- }
- )
- }
-
if (showTransportPolicyDialog) {
- AlertDialog(
- onDismissRequest = { showTransportPolicyDialog = false },
- title = { Text("Connection Security") },
- text = {
- Column {
- SettingsChoiceOption(
- text = "Auto (Recommended)",
- selected = connectionTransportPolicy == ConnectionTransportPolicy.AUTO,
- status = "Use the last working mode, with fallback",
- statusColor = Color.Gray,
- onClick = {
- onConnectionTransportPolicyChange(ConnectionTransportPolicy.AUTO)
- showTransportPolicyDialog = false
- }
- )
- SettingsChoiceOption(
- text = "TLS only",
- selected = connectionTransportPolicy == ConnectionTransportPolicy.TLS_ONLY,
- status = "Require an encrypted Deskflow connection",
- statusColor = Color.Gray,
- onClick = {
- onConnectionTransportPolicyChange(ConnectionTransportPolicy.TLS_ONLY)
- showTransportPolicyDialog = false
- }
- )
- SettingsChoiceOption(
- text = "Plain only",
- selected = connectionTransportPolicy == ConnectionTransportPolicy.PLAIN_ONLY,
- status = "Never attempt TLS",
- statusColor = Color.Gray,
- onClick = {
- onConnectionTransportPolicyChange(ConnectionTransportPolicy.PLAIN_ONLY)
- showTransportPolicyDialog = false
- }
- )
- }
+ ChoiceDialog(
+ title = "Connection Security",
+ icon = Icons.Rounded.Lock,
+ options = listOf(
+ Choice(ConnectionTransportPolicy.AUTO, "Auto (Recommended)", "Use the last working mode, with fallback"),
+ Choice(ConnectionTransportPolicy.TLS_ONLY, "TLS only", "Require an encrypted Deskflow connection"),
+ Choice(ConnectionTransportPolicy.PLAIN_ONLY, "Plain only", "Never attempt TLS"),
+ ),
+ selected = connectionTransportPolicy,
+ onSelect = {
+ onConnectionTransportPolicyChange(it)
+ showTransportPolicyDialog = false
},
- confirmButton = {
- TextButton(onClick = { showTransportPolicyDialog = false }) {
- Text("Cancel")
- }
- }
+ onDismiss = { showTransportPolicyDialog = false },
)
}
if (showInputMethodDialog) {
- AlertDialog(
- onDismissRequest = { showInputMethodDialog = false },
- title = { Text("Select Input Method") },
- text = {
- Column {
- SettingsChoiceOption(
- text = "Auto (Recommended)",
- selected = inputMethod == "auto",
- status = "Available",
- statusColor = Color.Gray,
- onClick = {
- onInputMethodChange("auto")
- showInputMethodDialog = false
- }
- )
- SettingsChoiceOption(
- text = "Shizuku",
- selected = inputMethod == "shizuku",
- status = if (shizukuAvailable) "Available" else "Not running",
- statusColor = if (shizukuAvailable) Color(0xFF4CAF50) else Color.Red,
- onClick = {
- onInputMethodChange("shizuku")
- showInputMethodDialog = false
- }
- )
- SettingsChoiceOption(
- text = "Accessibility Service",
- selected = inputMethod == "accessibility",
- status = if (accessibilityAvailable) "Enabled" else "Disabled",
- statusColor = if (accessibilityAvailable) Color(0xFF4CAF50) else Color.Red,
- onClick = {
- onInputMethodChange("accessibility")
- showInputMethodDialog = false
- }
- )
- }
+ ChoiceDialog(
+ title = "Select Input Method",
+ icon = Icons.Rounded.Keyboard,
+ options = listOf(
+ Choice("auto", "Auto (Recommended)", "Shizuku, then root, then Accessibility"),
+ Choice("shizuku", "Shizuku", if (shizukuAvailable) "Available" else "Not running", available = shizukuAvailable),
+ Choice(
+ "root",
+ "Root",
+ when {
+ rootGranted -> "Granted"
+ rootUsable -> "su available"
+ else -> "Not available"
+ },
+ available = rootUsable,
+ ),
+ Choice(
+ "accessibility",
+ "Accessibility Service",
+ if (accessibilityAvailable) "Enabled" else "Disabled",
+ available = accessibilityAvailable,
+ ),
+ ),
+ selected = inputMethod,
+ onSelect = {
+ onInputMethodChange(it)
+ showInputMethodDialog = false
},
- confirmButton = {
- TextButton(onClick = { showInputMethodDialog = false }) {
- Text("Cancel")
- }
- }
+ onDismiss = { showInputMethodDialog = false },
)
}
@@ -843,6 +523,7 @@ fun SettingsScreen(
var newName by remember { mutableStateOf(screenName) }
AlertDialog(
onDismissRequest = { showEditNameDialog = false },
+ icon = { Icon(Icons.Rounded.Phone, contentDescription = null) },
title = { Text("Screen Name") },
text = {
OutlinedTextField(
@@ -877,141 +558,41 @@ fun SettingsScreen(
if (showCursorStyleDialog) {
AlertDialog(
onDismissRequest = { showCursorStyleDialog = false },
- title = {
- Text(
- text = "Select Cursor Style",
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onSurface
- )
- },
+ title = { Text("Select Cursor Style") },
text = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(16.dp)
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
- // Option 1: Android Default
- Card(
- modifier = Modifier
- .weight(1f)
- .height(140.dp)
- .clickable {
- onCursorStyleChange("default")
- showCursorStyleDialog = false
- },
- shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = if (cursorStyle == "default")
- MaterialTheme.colorScheme.primaryContainer
- else
- MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
- ),
- border = if (cursorStyle == "default")
- BorderStroke(2.2.dp, MaterialTheme.colorScheme.primary)
- else
- null
- ) {
- Column(
- modifier = Modifier.fillMaxSize().padding(12.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Box(
- modifier = Modifier
- .size(64.dp)
- .background(
- color = Color.LightGray.copy(alpha = 0.2f),
- shape = RoundedCornerShape(12.dp)
- ),
- contentAlignment = Alignment.Center
- ) {
- Image(
- painter = painterResource(id = com.inputleaf.android.R.drawable.ic_cursor_aosp),
- contentDescription = "Default Cursor",
- modifier = Modifier
- .size(36.dp)
- .graphicsLayer(scaleX = 1f)
- )
- }
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "Default",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Bold,
- color = if (cursorStyle == "default")
- MaterialTheme.colorScheme.onPrimaryContainer
- else
- MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-
- // Option 2: Input Leaf Custom
- Card(
- modifier = Modifier
- .weight(1f)
- .height(140.dp)
- .clickable {
- onCursorStyleChange("leaf")
- showCursorStyleDialog = false
- },
- shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = if (cursorStyle == "leaf")
- MaterialTheme.colorScheme.primaryContainer
- else
- MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
- ),
- border = if (cursorStyle == "leaf")
- BorderStroke(2.2.dp, MaterialTheme.colorScheme.primary)
- else
- null
- ) {
- Column(
- modifier = Modifier.fillMaxSize().padding(12.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Box(
- modifier = Modifier
- .size(64.dp)
- .background(
- brush = Brush.linearGradient(
- colors = listOf(
- Color(0xFF10B981).copy(alpha = 0.15f),
- Color(0xFF34D399).copy(alpha = 0.15f)
- )
- ),
- shape = RoundedCornerShape(12.dp)
- ),
- contentAlignment = Alignment.Center
- ) {
- Image(
- painter = painterResource(id = com.inputleaf.android.R.drawable.cursor),
- contentDescription = "Leaf Cursor",
- modifier = Modifier
- .size(36.dp)
- .graphicsLayer(scaleX = -1f)
- )
- }
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "Input Leaf",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Bold,
- color = if (cursorStyle == "leaf")
- MaterialTheme.colorScheme.onPrimaryContainer
- else
- MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
+ CursorStyleCard(
+ label = "Default",
+ painter = painterResource(id = R.drawable.ic_cursor_aosp),
+ mirrored = false,
+ selected = cursorStyle == "default",
+ onClick = {
+ onCursorStyleChange("default")
+ showCursorStyleDialog = false
+ },
+ modifier = Modifier.weight(1f),
+ )
+ CursorStyleCard(
+ label = "Input Leaf",
+ painter = painterResource(id = R.drawable.cursor),
+ mirrored = true,
+ selected = cursorStyle == "leaf",
+ onClick = {
+ onCursorStyleChange("leaf")
+ showCursorStyleDialog = false
+ },
+ modifier = Modifier.weight(1f),
+ )
}
},
confirmButton = {
TextButton(onClick = { showCursorStyleDialog = false }) {
- Text("Close", color = MaterialTheme.colorScheme.primary)
+ Text("Close")
}
}
)
@@ -1021,40 +602,17 @@ fun SettingsScreen(
AlertDialog(
onDismissRequest = { showAuthorDialog = false },
icon = {
- Box(
- modifier = Modifier
- .size(56.dp)
- .background(
- brush = Brush.linearGradient(
- colors = listOf(
- MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
- MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f)
- )
- ),
- shape = RoundedCornerShape(16.dp)
- ),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = Icons.Rounded.Person,
- contentDescription = "Author",
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(32.dp)
- )
- }
+ CircularAvatar(
+ icon = Icons.Rounded.Person,
+ size = 56.dp,
+ iconSize = 32.dp,
+ backgroundColor = MaterialTheme.colorScheme.primaryContainer,
+ shape = MaterialShapes.Cookie9Sided.toShape(),
+ )
},
title = {
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = "Anas Vhora",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface
- )
- Spacer(modifier = Modifier.height(2.dp))
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("Anas Vhora")
Text(
text = "Developer & Maintainer",
style = MaterialTheme.typography.bodySmall,
@@ -1063,170 +621,209 @@ fun SettingsScreen(
}
},
text = {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- // Personal Website
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(14.dp))
- .clickable { openUrl(context, "https://anasvhora.tech") },
- color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(14.dp)
- ) {
- Icon(
- imageVector = Icons.Rounded.Language,
- contentDescription = "Website",
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(24.dp)
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Personal Website",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = "anasvhora.tech",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.outline,
- modifier = Modifier.size(18.dp)
- )
- }
- }
-
- // LinkedIn Profile
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(14.dp))
- .clickable { openUrl(context, "https://www.linkedin.com/in/anas-vhora-28455a1a1/") },
- color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(14.dp)
- ) {
- Icon(
- painter = painterResource(id = com.inputleaf.android.R.drawable.ic_brand_linkedin),
- contentDescription = "LinkedIn",
- tint = Color(0xFF0A66C2),
- modifier = Modifier.size(24.dp)
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "LinkedIn",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = "Anas Vhora",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.outline,
- modifier = Modifier.size(18.dp)
- )
- }
- }
-
- // GitHub Profile
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(14.dp))
- .clickable { openUrl(context, "https://github.com/anasvhora284") },
- color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(14.dp)
+ val links = listOf(
+ AuthorLink("Personal Website", "anasvhora.tech", "https://anasvhora.tech", icon = Icons.Rounded.Language),
+ AuthorLink(
+ "LinkedIn",
+ "Anas Vhora",
+ "https://www.linkedin.com/in/anas-vhora-28455a1a1/",
+ painter = painterResource(id = R.drawable.ic_brand_linkedin),
+ ),
+ AuthorLink(
+ "GitHub",
+ "anasvhora284",
+ "https://github.com/anasvhora284",
+ painter = painterResource(id = R.drawable.ic_brand_github),
+ ),
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) {
+ links.forEachIndexed { index, link ->
+ SegmentedListItem(
+ onClick = { openUrl(context, link.url) },
+ shapes = ListItemDefaults.segmentedShapes(index, links.size),
+ leadingContent = {
+ if (link.icon != null) {
+ Icon(link.icon, contentDescription = link.title)
+ } else if (link.painter != null) {
+ Icon(link.painter, contentDescription = link.title)
+ }
+ },
+ supportingContent = { Text(link.subtitle) },
+ trailingContent = {
+ Icon(Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = null)
+ },
) {
- Icon(
- painter = painterResource(id = com.inputleaf.android.R.drawable.ic_brand_github),
- contentDescription = "GitHub",
- tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(24.dp)
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "GitHub",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = "anasvhora284",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.outline,
- modifier = Modifier.size(18.dp)
- )
+ Text(link.title)
}
}
}
},
confirmButton = {
TextButton(onClick = { showAuthorDialog = false }) {
- Text("Close", color = MaterialTheme.colorScheme.primary)
+ Text("Close")
}
}
)
}
}
+private class AuthorLink(
+ val title: String,
+ val subtitle: String,
+ val url: String,
+ val icon: ImageVector? = null,
+ val painter: Painter? = null,
+)
+
+private class Choice(val value: T, val label: String, val status: String, val available: Boolean? = null)
+
+@Composable
+private fun ChoiceDialog(
+ title: String,
+ icon: ImageVector,
+ options: List>,
+ selected: T,
+ onSelect: (T) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ icon = { Icon(icon, contentDescription = null) },
+ title = { Text(title) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) {
+ options.forEachIndexed { index, option ->
+ SegmentedListItem(
+ selected = option.value == selected,
+ onClick = { onSelect(option.value) },
+ shapes = ListItemDefaults.segmentedShapes(index, options.size),
+ leadingContent = {
+ RadioButton(selected = option.value == selected, onClick = null)
+ },
+ supportingContent = {
+ Text(
+ text = option.status,
+ color = when (option.available) {
+ true -> MaterialTheme.colorScheme.primary
+ false -> MaterialTheme.colorScheme.error
+ null -> Color.Unspecified
+ },
+ )
+ },
+ ) {
+ Text(option.label)
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
+
+@Composable
+private fun NoticeCard(
+ icon: ImageVector,
+ title: String,
+ body: String,
+ containerColor: Color,
+ onClick: (() -> Unit)? = null,
+ action: (@Composable () -> Unit)? = null,
+) {
+ val colors = CardDefaults.cardColors(
+ containerColor = containerColor,
+ contentColor = contentColorFor(containerColor),
+ )
+ val content: @Composable ColumnScope.() -> Unit = {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Icon(icon, contentDescription = null)
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.titleSmall)
+ Text(body, style = MaterialTheme.typography.bodySmall)
+ }
+ if (onClick != null) {
+ Icon(Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = null)
+ }
+ }
+ if (action != null) {
+ Box(Modifier.padding(start = 8.dp, bottom = 8.dp)) { action() }
+ }
+ }
+ if (onClick != null) {
+ Card(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ colors = colors,
+ content = content,
+ )
+ } else {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ colors = colors,
+ content = content,
+ )
+ }
+}
+
@Composable
-private fun SettingsChoiceOption(
- text: String,
+private fun CursorStyleCard(
+ label: String,
+ painter: Painter,
+ mirrored: Boolean,
selected: Boolean,
- status: String,
- statusColor: Color,
- onClick: () -> Unit
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable { onClick() }
- .padding(vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
+ OutlinedCard(
+ onClick = onClick,
+ modifier = modifier.height(140.dp),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.outlinedCardColors(
+ containerColor = if (selected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerHigh
+ },
+ ),
+ border = if (selected) {
+ BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
+ } else {
+ CardDefaults.outlinedCardBorder()
+ },
) {
- RadioButton(
- selected = selected,
- onClick = onClick
- )
- Spacer(modifier = Modifier.width(8.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(text = text, style = MaterialTheme.typography.bodyLarge)
- Text(
- text = status,
- color = statusColor,
- style = MaterialTheme.typography.bodySmall
- )
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Surface(
+ modifier = Modifier.size(64.dp),
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Image(
+ painter = painter,
+ contentDescription = "$label Cursor",
+ modifier = Modifier
+ .size(36.dp)
+ .graphicsLayer(scaleX = if (mirrored) -1f else 1f)
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(text = label, style = MaterialTheme.typography.labelLargeEmphasized)
}
}
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/SetupScreen.kt b/app/src/main/java/com/inputleaf/android/ui/SetupScreen.kt
index 1038e731..31dc6e96 100644
--- a/app/src/main/java/com/inputleaf/android/ui/SetupScreen.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/SetupScreen.kt
@@ -1,264 +1,86 @@
package com.inputleaf.android.ui
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Warning
-import androidx.compose.material.icons.rounded.Accessibility
-import androidx.compose.material.icons.rounded.CheckCircle
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-import androidx.compose.animation.*
-import androidx.compose.animation.core.tween
-import androidx.compose.ui.Alignment
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LargeFlexibleTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.input.nestedscroll.nestedScroll
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.ShizukuStatusCard
+import com.inputleaf.android.ui.components.PermissionMethodsAccordion
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetupScreen(
shizukuStatus: ShizukuStatus,
+ rootStatus: RootStatus,
accessibilityAvailable: Boolean,
canDrawOverlays: Boolean,
batteryOptimizationExempt: Boolean,
imeEnabledAndSelected: Boolean,
onRequestShizukuPermission: () -> Unit,
+ onRequestRootAccess: () -> Unit,
onRequestOverlayPermission: () -> Unit,
onRequestBatteryOptimization: () -> Unit,
onRequestAccessibilityService: () -> Unit,
- onRequestImeSetup: () -> Unit
+ onRequestImeSetup: () -> Unit,
+ activeMethod: PermissionMethod? = null,
) {
- val context = LocalContext.current
-
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
- TopAppBar(
- title = {
- Text(
- text = "Setup",
- fontWeight = FontWeight.Bold,
- style = MaterialTheme.typography.titleLarge,
- letterSpacing = 0.5.sp
- )
- }
+ LargeFlexibleTopAppBar(
+ title = { Text("Setup") },
+ subtitle = { Text("Permissions") },
+ scrollBehavior = scrollBehavior,
)
}
) { padding ->
LazyColumn(
- contentPadding = padding,
- modifier = Modifier
- .fillMaxSize()
- .padding(16.dp),
+ contentPadding = PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = padding.calculateTopPadding(),
+ bottom = padding.calculateBottomPadding() + 16.dp,
+ ),
+ modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text(
- text = "Permissions",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = "Grant all required permissions to use InputLeaf.",
+ text = "Pick one input method. Grant Shizuku or root for the system cursor and physical HID keyboard. Accessibility still works as a fallback. You do not need both Shizuku and root.",
style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- item {
- ShizukuStatusCard(
- status = shizukuStatus,
- onRequestPermission = onRequestShizukuPermission
- )
- }
-
- item {
- PermissionCard(
- icon = Icons.Rounded.Accessibility,
- title = "Accessibility Service",
- description = "Rootless touch & keyboard injection — works without Shizuku",
- buttonLabel = "Enable",
- isGranted = accessibilityAvailable,
- onRequestPermission = onRequestAccessibilityService
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
)
}
item {
- PermissionCard(
- icon = Icons.Default.Warning,
- title = "Virtual Keyboard",
- description = "Required for hardware shortcuts like Ctrl+C without Shizuku",
- buttonLabel = "Select Keyboard",
- isGranted = imeEnabledAndSelected,
- onRequestPermission = onRequestImeSetup
- )
- }
-
- item {
- PermissionCard(
- icon = Icons.Default.Warning,
- title = "Overlay Permission",
- description = "Required to show cursor overlay",
- isGranted = canDrawOverlays,
- onRequestPermission = onRequestOverlayPermission
- )
- }
-
- item {
- PermissionCard(
- icon = Icons.Default.Warning,
- title = "Battery Optimization",
- description = "Go to: Battery usage → Allow background activity",
- buttonLabel = "Open App Info",
- isGranted = batteryOptimizationExempt,
- onRequestPermission = onRequestBatteryOptimization
- )
- }
- }
- }
-}
-
-@Composable
-private fun PermissionCard(
- icon: ImageVector,
- title: String,
- description: String,
- buttonLabel: String = "Grant",
- isGranted: Boolean,
- onRequestPermission: () -> Unit
-) {
- val bgColor by animateColorAsState(
- targetValue = if (isGranted) MaterialTheme.colorScheme.tertiaryContainer
- else MaterialTheme.colorScheme.surfaceVariant,
- animationSpec = tween(300),
- label = "bg_color"
- )
- val avatarBgColor by animateColorAsState(
- targetValue = if (isGranted) MaterialTheme.colorScheme.tertiary
- else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f),
- animationSpec = tween(300),
- label = "avatar_bg_color"
- )
- val iconTintColor by animateColorAsState(
- targetValue = if (isGranted) MaterialTheme.colorScheme.onTertiary
- else MaterialTheme.colorScheme.onSurfaceVariant,
- animationSpec = tween(300),
- label = "icon_tint_color"
- )
- val titleColor by animateColorAsState(
- targetValue = if (isGranted) MaterialTheme.colorScheme.onTertiaryContainer
- else MaterialTheme.colorScheme.onSurfaceVariant,
- animationSpec = tween(300),
- label = "title_color"
- )
- val descColor by animateColorAsState(
- targetValue = if (isGranted) MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
- else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
- animationSpec = tween(300),
- label = "desc_color"
- )
-
- GradientCard(
- modifier = Modifier
- .fillMaxWidth()
- .clickable { onRequestPermission() }
- .animateContentSize(
- animationSpec = tween(300)
- ),
- backgroundColor = bgColor,
- cornerRadius = 24.dp,
- elevation = 0.dp,
- padding = 20.dp
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- CircularAvatar(
- icon = if (isGranted) Icons.Rounded.CheckCircle else icon,
- size = 48.dp,
- iconSize = 24.dp,
- backgroundColor = avatarBgColor,
- iconTint = iconTintColor
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = title,
- fontWeight = FontWeight.SemiBold,
- color = titleColor
- )
- Text(
- text = description,
- style = MaterialTheme.typography.bodySmall,
- color = descColor
- )
- AnimatedVisibility(
- visible = !isGranted,
- enter = fadeIn() + expandVertically(),
- exit = fadeOut() + shrinkVertically()
- ) {
- Button(
- onClick = onRequestPermission,
- modifier = Modifier.padding(top = 12.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary
- )
- ) {
- Text(buttonLabel)
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun SetupStep(
- number: String,
- title: String,
- description: String
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Surface(
- modifier = Modifier.size(32.dp),
- shape = MaterialTheme.shapes.small,
- color = MaterialTheme.colorScheme.primary
- ) {
- Box(contentAlignment = Alignment.Center) {
- Text(
- text = number,
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimary
+ PermissionMethodsAccordion(
+ activeMethod = activeMethod,
+ shizukuStatus = shizukuStatus,
+ rootStatus = rootStatus,
+ accessibilityAvailable = accessibilityAvailable,
+ canDrawOverlays = canDrawOverlays,
+ batteryOptimizationExempt = batteryOptimizationExempt,
+ imeEnabledAndSelected = imeEnabledAndSelected,
+ onRequestShizukuPermission = onRequestShizukuPermission,
+ onRequestRootAccess = onRequestRootAccess,
+ onRequestOverlayPermission = onRequestOverlayPermission,
+ onRequestBatteryOptimization = onRequestBatteryOptimization,
+ onRequestAccessibilityService = onRequestAccessibilityService,
+ onRequestImeSetup = onRequestImeSetup,
)
}
}
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.SemiBold
- )
- Text(
- text = description,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
}
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/CircularAvatar.kt b/app/src/main/java/com/inputleaf/android/ui/components/CircularAvatar.kt
index 3540f0a6..514c260d 100644
--- a/app/src/main/java/com/inputleaf/android/ui/components/CircularAvatar.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/components/CircularAvatar.kt
@@ -1,18 +1,17 @@
package com.inputleaf.android.ui.components
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
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.graphics.Shape
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
@@ -24,34 +23,13 @@ fun CircularAvatar(
modifier: Modifier = Modifier,
size: Dp = 56.dp,
iconSize: Dp = 28.dp,
- background: Brush? = null,
- backgroundColor: Color? = null,
- iconTint: Color? = null,
- elevation: Dp = 0.dp
+ backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ iconTint: Color = contentColorFor(backgroundColor),
+ elevation: Dp = 0.dp,
+ shape: Shape = CircleShape,
) {
- val bgColor = backgroundColor ?: MaterialTheme.colorScheme.surfaceContainerHighest
- val fgColor = iconTint ?: MaterialTheme.colorScheme.onSurfaceVariant
-
- Box(
- modifier = modifier
- .size(size)
- .shadow(elevation, CircleShape)
- .clip(CircleShape)
- .then(
- if (background != null) {
- Modifier.background(background)
- } else {
- Modifier.background(bgColor)
- }
- ),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- modifier = Modifier.size(iconSize),
- tint = fgColor
- )
+ AvatarSurface(modifier, size, backgroundColor, iconTint, elevation, shape) {
+ Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(iconSize))
}
}
@@ -61,33 +39,33 @@ fun CircularAvatar(
modifier: Modifier = Modifier,
size: Dp = 56.dp,
iconSize: Dp = 28.dp,
- background: Brush? = null,
- backgroundColor: Color? = null,
- iconTint: Color? = null,
- elevation: Dp = 0.dp
+ backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ iconTint: Color = contentColorFor(backgroundColor),
+ elevation: Dp = 0.dp,
+ shape: Shape = CircleShape,
) {
- val bgColor = backgroundColor ?: MaterialTheme.colorScheme.surfaceContainerHighest
- val fgColor = iconTint ?: MaterialTheme.colorScheme.onSurfaceVariant
+ AvatarSurface(modifier, size, backgroundColor, iconTint, elevation, shape) {
+ Icon(painter = painter, contentDescription = null, modifier = Modifier.size(iconSize))
+ }
+}
- Box(
- modifier = modifier
- .size(size)
- .shadow(elevation, CircleShape)
- .clip(CircleShape)
- .then(
- if (background != null) {
- Modifier.background(background)
- } else {
- Modifier.background(bgColor)
- }
- ),
- contentAlignment = Alignment.Center
+@Composable
+private fun AvatarSurface(
+ modifier: Modifier,
+ size: Dp,
+ backgroundColor: Color,
+ iconTint: Color,
+ elevation: Dp,
+ shape: Shape,
+ content: @Composable () -> Unit,
+) {
+ Surface(
+ modifier = modifier.size(size),
+ shape = shape,
+ color = backgroundColor,
+ contentColor = iconTint,
+ shadowElevation = elevation,
) {
- Icon(
- painter = painter,
- contentDescription = null,
- modifier = Modifier.size(iconSize),
- tint = fgColor
- )
+ Box(contentAlignment = Alignment.Center) { content() }
}
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/FeatureToggleCard.kt b/app/src/main/java/com/inputleaf/android/ui/components/FeatureToggleCard.kt
index 0086033e..c54f5f2f 100644
--- a/app/src/main/java/com/inputleaf/android/ui/components/FeatureToggleCard.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/components/FeatureToggleCard.kt
@@ -1,25 +1,28 @@
package com.inputleaf.android.ui.components
import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@Composable
@@ -30,52 +33,38 @@ fun FeatureToggleCard(
enabled: Boolean,
onToggle: () -> Unit,
) {
- val bgColor by animateColorAsState(
- targetValue = if (enabled) MaterialTheme.colorScheme.primaryContainer
- else MaterialTheme.colorScheme.surfaceVariant,
- animationSpec = tween(300),
+ val colors = MaterialTheme.colorScheme
+ val containerColor by animateColorAsState(
+ targetValue = if (enabled) colors.primaryContainer else colors.surfaceContainerHigh,
+ animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
label = "toggle_bg",
)
- val contentColor = if (enabled) MaterialTheme.colorScheme.onPrimaryContainer
- else MaterialTheme.colorScheme.onSurfaceVariant
+ val contentColor by animateColorAsState(
+ targetValue = if (enabled) colors.onPrimaryContainer else colors.onSurfaceVariant,
+ animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
+ label = "toggle_fg",
+ )
- GradientCard(
- modifier = modifier.clickable { onToggle() },
- backgroundColor = bgColor,
- cornerRadius = 20.dp,
- elevation = 0.dp,
- padding = 16.dp,
+ Card(
+ onClick = onToggle,
+ modifier = modifier.semantics { role = Role.Switch },
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(containerColor = containerColor, contentColor = contentColor),
) {
- Column {
+ Column(Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
+ horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
- Icon(
- imageVector = icon,
- contentDescription = label,
- modifier = Modifier.size(28.dp),
- tint = contentColor,
- )
- Surface(
- modifier = Modifier.size(8.dp),
- shape = RoundedCornerShape(4.dp),
- color = if (enabled) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.outlineVariant,
- ) {}
+ Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
+ Switch(checked = enabled, onCheckedChange = null)
}
Spacer(Modifier.height(12.dp))
- Text(
- text = label,
- fontWeight = FontWeight.SemiBold,
- style = MaterialTheme.typography.bodyMedium,
- color = contentColor,
- )
+ Text(label, style = MaterialTheme.typography.titleMediumEmphasized)
Text(
text = if (enabled) "Enabled" else "Disabled",
style = MaterialTheme.typography.bodySmall,
- color = contentColor.copy(alpha = 0.7f),
)
}
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/GradientCard.kt b/app/src/main/java/com/inputleaf/android/ui/components/GradientCard.kt
deleted file mode 100644
index ddd0858b..00000000
--- a/app/src/main/java/com/inputleaf/android/ui/components/GradientCard.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.inputleaf.android.ui.components
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxScope
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-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
-import androidx.compose.ui.unit.dp
-
-@Composable
-fun GradientCard(
- modifier: Modifier = Modifier,
- gradient: Brush? = null,
- backgroundColor: Color? = null,
- cornerRadius: Dp = 28.dp,
- elevation: Dp = 1.dp,
- padding: Dp = 20.dp,
- content: @Composable BoxScope.() -> Unit
-) {
- val surfaceColor = backgroundColor ?: MaterialTheme.colorScheme.surface
-
- Box(
- modifier = modifier
- .shadow(
- elevation = elevation,
- shape = RoundedCornerShape(cornerRadius),
- ambientColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.08f),
- spotColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.08f)
- )
- .clip(RoundedCornerShape(cornerRadius))
- .then(
- if (gradient != null) {
- Modifier.background(gradient)
- } else {
- Modifier.background(surfaceColor)
- }
- )
- .padding(padding)
- ) {
- content()
- }
-}
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 7d455e32..d22deecc 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
@@ -12,7 +12,6 @@ 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
@@ -287,7 +286,7 @@ fun MarkdownContent(
markdown: String,
modifier: Modifier = Modifier,
linkColor: Color = MaterialTheme.colorScheme.primary,
- codeBackground: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
+ codeBackground: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
val blocks = remember(markdown) { MarkdownParser.parseBlocks(markdown) }
@@ -299,14 +298,13 @@ fun MarkdownContent(
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
+ 1 -> MaterialTheme.typography.titleMediumEmphasized to 8.dp
+ 2 -> MaterialTheme.typography.titleSmallEmphasized to 6.dp
+ else -> MaterialTheme.typography.labelLargeEmphasized 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)
)
@@ -323,8 +321,7 @@ fun MarkdownContent(
if (block.isOrdered) {
Text(
text = block.marker,
- style = MaterialTheme.typography.bodySmall,
- fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.bodySmallEmphasized,
color = MaterialTheme.colorScheme.primary,
lineHeight = 18.sp
)
@@ -360,7 +357,7 @@ fun MarkdownContent(
Box(
modifier = Modifier
.fillMaxWidth()
- .clip(RoundedCornerShape(8.dp))
+ .clip(MaterialTheme.shapes.small)
.background(codeBackground)
.padding(8.dp)
.horizontalScroll(rememberScrollState())
@@ -379,8 +376,8 @@ fun MarkdownContent(
Row(
modifier = Modifier
.fillMaxWidth()
- .clip(RoundedCornerShape(4.dp))
- .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
+ .clip(MaterialTheme.shapes.extraSmall)
+ .background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/PermissionCard.kt b/app/src/main/java/com/inputleaf/android/ui/components/PermissionCard.kt
new file mode 100644
index 00000000..8cf76faa
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/ui/components/PermissionCard.kt
@@ -0,0 +1,107 @@
+package com.inputleaf.android.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.Arrangement
+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.material.icons.Icons
+import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.toShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun PermissionCard(
+ icon: ImageVector,
+ title: String,
+ description: String,
+ isGranted: Boolean,
+ onRequestPermission: () -> Unit,
+ buttonLabel: String = "Grant",
+ modifier: Modifier = Modifier,
+) {
+ val bgColor by animateColorAsState(
+ targetValue = if (isGranted) MaterialTheme.colorScheme.tertiaryContainer
+ else MaterialTheme.colorScheme.surfaceVariant,
+ animationSpec = tween(300),
+ label = "bg_color",
+ )
+ val contentColor by animateColorAsState(
+ targetValue = if (isGranted) MaterialTheme.colorScheme.onTertiaryContainer
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ animationSpec = tween(300),
+ label = "content_color",
+ )
+ val avatarBgColor by animateColorAsState(
+ targetValue = if (isGranted) MaterialTheme.colorScheme.tertiary
+ else MaterialTheme.colorScheme.secondaryContainer,
+ animationSpec = tween(300),
+ label = "avatar_bg_color",
+ )
+ val iconTintColor by animateColorAsState(
+ targetValue = if (isGranted) MaterialTheme.colorScheme.onTertiary
+ else MaterialTheme.colorScheme.onSecondaryContainer,
+ animationSpec = tween(300),
+ label = "icon_tint_color",
+ )
+
+ Card(
+ onClick = onRequestPermission,
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(containerColor = bgColor, contentColor = contentColor),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ CircularAvatar(
+ icon = if (isGranted) Icons.Rounded.CheckCircle else icon,
+ size = 48.dp,
+ iconSize = 24.dp,
+ backgroundColor = avatarBgColor,
+ iconTint = iconTintColor,
+ shape = if (isGranted) MaterialShapes.Cookie9Sided.toShape() else MaterialShapes.Circle.toShape(),
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text = title, style = MaterialTheme.typography.titleSmall)
+ Text(text = description, style = MaterialTheme.typography.bodySmall)
+ AnimatedVisibility(
+ visible = !isGranted,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically(),
+ ) {
+ Button(
+ onClick = onRequestPermission,
+ modifier = Modifier.padding(top = 12.dp),
+ ) {
+ Text(buttonLabel)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/PermissionMethodsAccordion.kt b/app/src/main/java/com/inputleaf/android/ui/components/PermissionMethodsAccordion.kt
new file mode 100644
index 00000000..025eda6d
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/ui/components/PermissionMethodsAccordion.kt
@@ -0,0 +1,317 @@
+package com.inputleaf.android.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Accessibility
+import androidx.compose.material.icons.rounded.ExpandLess
+import androidx.compose.material.icons.rounded.ExpandMore
+import androidx.compose.material.icons.rounded.FlashOn
+import androidx.compose.material.icons.rounded.Security
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+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.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.unit.dp
+import com.inputleaf.android.ui.PermissionMethod
+import com.inputleaf.android.ui.PermissionMethodAccordionState
+import com.inputleaf.android.ui.activePermissionMethod
+import com.inputleaf.android.ui.RootStatus
+import com.inputleaf.android.ui.ShizukuStatus
+
+@Composable
+fun PermissionMethodsAccordion(
+ shizukuStatus: ShizukuStatus,
+ rootStatus: RootStatus,
+ accessibilityAvailable: Boolean,
+ canDrawOverlays: Boolean,
+ batteryOptimizationExempt: Boolean,
+ imeEnabledAndSelected: Boolean,
+ onRequestShizukuPermission: () -> Unit,
+ onRequestRootAccess: () -> Unit,
+ onRequestOverlayPermission: () -> Unit,
+ onRequestBatteryOptimization: () -> Unit,
+ onRequestAccessibilityService: () -> Unit,
+ onRequestImeSetup: () -> Unit,
+ modifier: Modifier = Modifier,
+ activeMethod: PermissionMethod? = null,
+) {
+ var accordion by rememberSaveable(stateSaver = permissionMethodAccordionSaver) {
+ mutableStateOf(PermissionMethodAccordionState())
+ }
+
+ val inUse = activePermissionMethod(
+ active = activeMethod,
+ shizukuReady = shizukuStatus == ShizukuStatus.READY,
+ rootGranted = rootStatus == RootStatus.GRANTED,
+ accessibilityAvailable = accessibilityAvailable,
+ )
+ LaunchedEffect(inUse) {
+ accordion = accordion.onActiveMethodChanged(inUse)
+ }
+
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ PermissionMethodSection(
+ title = "Shizuku",
+ subtitle = methodSubtitle(shizukuMethodSubtitle(shizukuStatus), inUse == PermissionMethod.SHIZUKU),
+ icon = Icons.Rounded.FlashOn,
+ expanded = accordion.isExpanded(PermissionMethod.SHIZUKU),
+ onHeaderClick = { accordion = accordion.onHeaderClick(PermissionMethod.SHIZUKU) },
+ ) {
+ ShizukuStatusCard(
+ status = shizukuStatus,
+ onRequestPermission = onRequestShizukuPermission,
+ )
+ }
+
+ PermissionMethodSection(
+ title = "Root",
+ subtitle = methodSubtitle(rootMethodSubtitle(rootStatus), inUse == PermissionMethod.ROOT),
+ icon = Icons.Rounded.Security,
+ expanded = accordion.isExpanded(PermissionMethod.ROOT),
+ onHeaderClick = { accordion = accordion.onHeaderClick(PermissionMethod.ROOT) },
+ ) {
+ if (rootStatus == RootStatus.MISSING || rootStatus == RootStatus.CHECKING) {
+ RootUnavailableCard(status = rootStatus)
+ } else {
+ RootStatusCard(
+ status = rootStatus,
+ onRequestRootAccess = onRequestRootAccess,
+ )
+ }
+ }
+
+ PermissionMethodSection(
+ title = "Non-root Accessibility",
+ subtitle = methodSubtitle(
+ accessibilityMethodSubtitle(accessibilityAvailable, canDrawOverlays, imeEnabledAndSelected),
+ inUse == PermissionMethod.ACCESSIBILITY,
+ ),
+ icon = Icons.Rounded.Accessibility,
+ expanded = accordion.isExpanded(PermissionMethod.ACCESSIBILITY),
+ onHeaderClick = {
+ accordion = accordion.onHeaderClick(PermissionMethod.ACCESSIBILITY)
+ },
+ ) {
+ PermissionCard(
+ icon = Icons.Rounded.Accessibility,
+ title = "Accessibility Service",
+ description = "Touch fallback when Shizuku or root cannot attach a native HID mouse",
+ buttonLabel = "Enable",
+ isGranted = accessibilityAvailable,
+ onRequestPermission = onRequestAccessibilityService,
+ )
+ PermissionCard(
+ icon = Icons.Rounded.Warning,
+ title = "Virtual Keyboard",
+ description = "Required for hardware shortcuts like Ctrl+C without Shizuku or root",
+ buttonLabel = "Select Keyboard",
+ isGranted = imeEnabledAndSelected,
+ onRequestPermission = onRequestImeSetup,
+ )
+ PermissionCard(
+ icon = Icons.Rounded.Warning,
+ title = "Overlay Permission",
+ description = "Required to show cursor overlay",
+ isGranted = canDrawOverlays,
+ onRequestPermission = onRequestOverlayPermission,
+ )
+ }
+
+ PermissionCard(
+ icon = Icons.Rounded.Warning,
+ title = "Battery Optimization",
+ description = "Go to: Battery usage → Allow background activity",
+ buttonLabel = "Open App Info",
+ isGranted = batteryOptimizationExempt,
+ onRequestPermission = onRequestBatteryOptimization,
+ )
+ }
+}
+
+@Composable
+private fun PermissionMethodSection(
+ title: String,
+ subtitle: String,
+ icon: ImageVector,
+ expanded: Boolean,
+ onHeaderClick: () -> Unit,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val stateLabel = if (expanded) "expanded" else "collapsed"
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics(mergeDescendants = true) {
+ role = Role.Button
+ contentDescription = title
+ stateDescription = stateLabel
+ }
+ .clickable(onClick = onHeaderClick)
+ .padding(20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ CircularAvatar(
+ icon = icon,
+ size = 40.dp,
+ iconSize = 22.dp,
+ backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Text(
+ text = subtitle,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Icon(
+ imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ AnimatedVisibility(
+ visible = expanded,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column(
+ modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ content = content,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RootUnavailableCard(status: RootStatus) {
+ val title = if (status == RootStatus.CHECKING) {
+ "Checking for root…"
+ } else {
+ "Root not available"
+ }
+ val description = if (status == RootStatus.CHECKING) {
+ "Looking for su on this device."
+ } else {
+ "No su binary was found. You can still use Shizuku or Accessibility."
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularAvatar(
+ icon = Icons.Rounded.Warning,
+ size = 40.dp,
+ iconSize = 24.dp,
+ backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = title,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = description,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
+
+private fun shizukuMethodSubtitle(status: ShizukuStatus): String = when (status) {
+ ShizukuStatus.READY -> "Ready"
+ ShizukuStatus.NOT_INSTALLED -> "Not installed"
+ ShizukuStatus.NOT_RUNNING -> "Not running"
+ ShizukuStatus.PERMISSION_REQUIRED -> "Permission needed"
+ ShizukuStatus.CHECKING -> "Checking…"
+}
+
+private fun rootMethodSubtitle(status: RootStatus): String = when (status) {
+ RootStatus.GRANTED -> "Ready"
+ RootStatus.AVAILABLE -> "Grant su"
+ RootStatus.DENIED -> "Denied"
+ RootStatus.MISSING -> "Not available"
+ RootStatus.CHECKING -> "Checking…"
+}
+
+private fun accessibilityMethodSubtitle(
+ accessibilityAvailable: Boolean,
+ canDrawOverlays: Boolean,
+ imeEnabledAndSelected: Boolean,
+): String {
+ val granted = listOf(accessibilityAvailable, canDrawOverlays, imeEnabledAndSelected).count { it }
+ return if (granted == 3) "Ready" else "$granted of 3 set up"
+}
+
+private fun methodSubtitle(base: String, inUse: Boolean): String =
+ if (inUse) "$base · in use" else base
+
+private val permissionMethodAccordionSaver =
+ Saver(
+ save = { "${it.expanded.name}|${it.userChosen}" },
+ restore = {
+ val parts = it.split("|")
+ PermissionMethodAccordionState(
+ expanded = PermissionMethod.valueOf(parts[0]),
+ userChosen = parts.getOrNull(1).toBoolean(),
+ )
+ },
+ )
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/RootStatusCard.kt b/app/src/main/java/com/inputleaf/android/ui/components/RootStatusCard.kt
new file mode 100644
index 00000000..e62f27e7
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/ui/components/RootStatusCard.kt
@@ -0,0 +1,122 @@
+package com.inputleaf.android.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.Security
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.toShape
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import com.inputleaf.android.ui.RootStatus
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun RootStatusCard(
+ status: RootStatus,
+ onRequestRootAccess: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ when (status) {
+ RootStatus.MISSING, RootStatus.CHECKING -> return
+ RootStatus.GRANTED -> {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ CircularAvatar(
+ icon = Icons.Rounded.CheckCircle,
+ size = 48.dp,
+ iconSize = 28.dp,
+ backgroundColor = MaterialTheme.colorScheme.tertiary,
+ iconTint = MaterialTheme.colorScheme.onTertiary,
+ shape = MaterialShapes.Cookie9Sided.toShape(),
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text = "Root Ready", style = MaterialTheme.typography.titleSmall)
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = "System cursor and HID keyboard can use su instead of Shizuku",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
+ }
+ RootStatus.AVAILABLE, RootStatus.DENIED -> {
+ val icon: ImageVector = if (status == RootStatus.DENIED) {
+ Icons.Rounded.Warning
+ } else {
+ Icons.Rounded.Security
+ }
+ val title = if (status == RootStatus.DENIED) "Root Denied" else "Root Available"
+ val description = if (status == RootStatus.DENIED) {
+ "Input Leaf was not granted su. You can still use Shizuku or Accessibility."
+ } else {
+ "Grant the su prompt to use the system cursor and physical HID keyboard without Shizuku."
+ }
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularAvatar(
+ icon = icon,
+ size = 40.dp,
+ iconSize = 24.dp,
+ backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = title,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = description,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ TextButton(onClick = onRequestRootAccess) {
+ Text("Grant root")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/SettingsPrimitives.kt b/app/src/main/java/com/inputleaf/android/ui/components/SettingsPrimitives.kt
index c688f68f..4a4d4bd5 100644
--- a/app/src/main/java/com/inputleaf/android/ui/components/SettingsPrimitives.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/components/SettingsPrimitives.kt
@@ -1,164 +1,192 @@
package com.inputleaf.android.ui.components
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
+import androidx.compose.material3.ButtonGroupDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.ListItemShapes
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.RadioButton
+import androidx.compose.material3.SegmentedListItem
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
+import androidx.compose.material3.ToggleButton
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
@Composable
fun SectionHeader(text: String) {
Text(
text = text,
- fontSize = 12.sp,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 4.dp, bottom = 12.dp, top = 8.dp),
+ style = MaterialTheme.typography.labelLargeEmphasized,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp),
)
}
+class SettingsGroupScope {
+ internal val rows = mutableListOf<@Composable (ListItemShapes) -> Unit>()
+
+ fun row(content: @Composable (shapes: ListItemShapes) -> Unit) {
+ rows += content
+ }
+}
+
+/** A connected group of [SegmentedListItem]s; each row gets its position-aware shape. */
+@Composable
+fun SettingsGroup(modifier: Modifier = Modifier, content: SettingsGroupScope.() -> Unit) {
+ val rows = SettingsGroupScope().apply(content).rows
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
+ ) {
+ rows.forEachIndexed { index, row -> row(ListItemDefaults.segmentedShapes(index, rows.size)) }
+ }
+}
+
@Composable
fun SettingsRow(
icon: ImageVector,
title: String,
+ shapes: ListItemShapes,
subtitle: String? = null,
onClick: (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
-) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier)
- .padding(16.dp),
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- CircularAvatar(
- icon = icon,
- size = 40.dp,
- iconSize = 22.dp,
- backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = title,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.bodyMedium,
- )
- if (subtitle != null) {
- Spacer(modifier = Modifier.height(2.dp))
- Text(
- text = subtitle,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- style = MaterialTheme.typography.bodySmall,
- )
- }
- }
- if (trailingContent != null) {
- trailingContent()
- } else if (onClick != null) {
- androidx.compose.material3.Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
- contentDescription = "Navigate",
- tint = MaterialTheme.colorScheme.outline,
- )
- }
- }
- }
-}
+) = SettingsRowImpl(
+ leadingContent = { RowIcon(icon) },
+ title = title,
+ shapes = shapes,
+ subtitle = subtitle,
+ onClick = onClick,
+ trailingContent = trailingContent,
+)
@Composable
fun SettingsRow(
painter: Painter,
title: String,
+ shapes: ListItemShapes,
subtitle: String? = null,
onClick: (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
+) = SettingsRowImpl(
+ leadingContent = {
+ CircularAvatar(
+ painter = painter,
+ size = 40.dp,
+ iconSize = 22.dp,
+ backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
+ )
+ },
+ title = title,
+ shapes = shapes,
+ subtitle = subtitle,
+ onClick = onClick,
+ trailingContent = trailingContent,
+)
+
+@Composable
+fun SettingsSwitchRow(
+ icon: ImageVector,
+ title: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ shapes: ListItemShapes,
+ subtitle: String? = null,
+ enabled: Boolean = true,
) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier)
- .padding(16.dp),
+ SegmentedListItem(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ shapes = shapes,
+ enabled = enabled,
+ leadingContent = { RowIcon(icon) },
+ supportingContent = subtitle?.let { { Text(it) } },
+ trailingContent = { Switch(checked = checked, onCheckedChange = null, enabled = enabled) },
) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- CircularAvatar(
- painter = painter,
- size = 40.dp,
- iconSize = 22.dp,
- backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
- iconTint = MaterialTheme.colorScheme.onSecondaryContainer,
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = title,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.bodyMedium,
- )
- if (subtitle != null) {
- Spacer(modifier = Modifier.height(2.dp))
- Text(
- text = subtitle,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- style = MaterialTheme.typography.bodySmall,
- )
- }
- }
- if (trailingContent != null) {
- trailingContent()
- } else if (onClick != null) {
- androidx.compose.material3.Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
- contentDescription = "Navigate",
- tint = MaterialTheme.colorScheme.outline,
- )
- }
- }
+ Text(title)
}
}
+/** Single-choice connected button group, e.g. System / Light / Dark. */
@Composable
-fun ThemeModeOption(
- text: String,
- selected: Boolean,
- onClick: () -> Unit,
+fun ConnectedChoiceRow(
+ options: List>,
+ selected: T,
+ onSelect: (T) -> Unit,
+ modifier: Modifier = Modifier,
) {
Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable { onClick() }
- .padding(vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
) {
- RadioButton(selected = selected, onClick = onClick)
- Spacer(modifier = Modifier.width(8.dp))
- Text(text = text)
+ options.forEachIndexed { index, (value, label) ->
+ ToggleButton(
+ checked = value == selected,
+ onCheckedChange = { onSelect(value) },
+ modifier = Modifier
+ .weight(1f)
+ .semantics { role = Role.RadioButton },
+ shapes = when (index) {
+ 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
+ options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
+ else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
+ },
+ ) {
+ Text(label)
+ }
+ }
+ }
+}
+
+@Composable
+private fun RowIcon(icon: ImageVector) {
+ CircularAvatar(
+ icon = icon,
+ size = 40.dp,
+ iconSize = 22.dp,
+ backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
+ )
+}
+
+@Composable
+private fun SettingsRowImpl(
+ leadingContent: @Composable () -> Unit,
+ title: String,
+ shapes: ListItemShapes,
+ subtitle: String?,
+ onClick: (() -> Unit)?,
+ trailingContent: @Composable (() -> Unit)?,
+) {
+ val supporting: (@Composable () -> Unit)? = subtitle?.let { { Text(it) } }
+ val trailing: (@Composable () -> Unit)? = trailingContent ?: onClick?.let {
+ { Icon(Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = "Navigate") }
+ }
+ if (onClick != null) {
+ SegmentedListItem(
+ onClick = onClick,
+ shapes = shapes,
+ leadingContent = leadingContent,
+ supportingContent = supporting,
+ trailingContent = trailing,
+ ) { Text(title) }
+ } else {
+ SegmentedListItem(
+ shapes = shapes,
+ leadingContent = leadingContent,
+ supportingContent = supporting,
+ trailingContent = trailing,
+ ) { Text(title) }
}
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/components/ShizukuStatusCard.kt b/app/src/main/java/com/inputleaf/android/ui/components/ShizukuStatusCard.kt
index d7a6c5dc..3c815129 100644
--- a/app/src/main/java/com/inputleaf/android/ui/components/ShizukuStatusCard.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/components/ShizukuStatusCard.kt
@@ -2,23 +2,34 @@ package com.inputleaf.android.ui.components
import android.content.Intent
import android.net.Uri
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.rounded.CheckCircle
-import androidx.compose.material3.*
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.toShape
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import com.inputleaf.android.ui.MainViewModel
import com.inputleaf.android.ui.ShizukuStatus
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ShizukuStatusCard(
status: ShizukuStatus,
@@ -29,19 +40,23 @@ fun ShizukuStatusCard(
when (status) {
ShizukuStatus.READY -> {
- GradientCard(
- modifier = modifier.fillMaxWidth().clickable {
+ Card(
+ onClick = {
context.packageManager.getLaunchIntentForPackage("moe.shizuku.privileged.api")?.let {
context.startActivity(it)
}
},
- backgroundColor = MaterialTheme.colorScheme.tertiaryContainer,
- cornerRadius = 24.dp,
- elevation = 0.dp,
- padding = 20.dp
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ ),
) {
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -50,28 +65,19 @@ fun ShizukuStatusCard(
size = 48.dp,
iconSize = 28.dp,
backgroundColor = MaterialTheme.colorScheme.tertiary,
- iconTint = MaterialTheme.colorScheme.onTertiary
+ iconTint = MaterialTheme.colorScheme.onTertiary,
+ shape = MaterialShapes.Cookie9Sided.toShape(),
)
Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Shizuku Ready",
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onTertiaryContainer,
- style = MaterialTheme.typography.titleSmall
- )
+ Text(text = "Shizuku Ready", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(2.dp))
- Text(
- text = "Input injection enabled",
- color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
- style = MaterialTheme.typography.bodySmall
- )
+ Text(text = "Input injection enabled", style = MaterialTheme.typography.bodySmall)
}
}
}
}
else -> {
val icon: ImageVector?
- val color: Color
val title: String
val description: String
val actionLabel: String?
@@ -79,13 +85,13 @@ fun ShizukuStatusCard(
when (status) {
ShizukuStatus.CHECKING -> {
- icon = null; color = MaterialTheme.colorScheme.onSurfaceVariant; title = "Checking Shizuku..."
+ icon = null; title = "Checking Shizuku..."
description = ""; actionLabel = null; action = null
}
ShizukuStatus.NOT_INSTALLED -> {
- icon = Icons.Default.Warning; color = MaterialTheme.colorScheme.secondary
+ icon = Icons.Rounded.Warning
title = "Shizuku Not Installed"
- description = "Install Shizuku from Play Store to enable mouse/keyboard input."
+ description = "Install Shizuku, or grant root on a rooted device, to enable system-level mouse and keyboard."
actionLabel = "Install Shizuku"
action = {
context.startActivity(Intent(Intent.ACTION_VIEW,
@@ -93,9 +99,9 @@ fun ShizukuStatusCard(
}
}
ShizukuStatus.NOT_RUNNING -> {
- icon = Icons.Default.Warning; color = MaterialTheme.colorScheme.secondary
+ icon = Icons.Rounded.Warning
title = "Shizuku Not Running"
- description = "Open Shizuku app and start it via Wireless Debugging (Android 11+) or ADB."
+ description = "Open Shizuku and start it via Wireless Debugging (Android 11+) or ADB. Rooted devices can grant su instead."
actionLabel = "Open Shizuku"
action = {
context.packageManager.getLaunchIntentForPackage("moe.shizuku.privileged.api")?.let {
@@ -104,39 +110,33 @@ fun ShizukuStatusCard(
}
}
ShizukuStatus.PERMISSION_REQUIRED -> {
- icon = Icons.Default.Warning; color = MaterialTheme.colorScheme.secondary
+ icon = Icons.Rounded.Warning
title = "Permission Required"
- description = "Grant Input Leaf permission to use Shizuku for input injection."
+ description = "Grant Input Leaf permission to use Shizuku, or grant root, for system-level input."
actionLabel = "Grant Permission"; action = onRequestPermission
}
- else -> {
- icon = null; color = MaterialTheme.colorScheme.onSurfaceVariant; title = "Unknown"
- description = ""; actionLabel = null; action = null
- }
}
- GradientCard(
+ Card(
modifier = modifier.fillMaxWidth(),
- backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
- cornerRadius = 24.dp,
- elevation = 0.dp,
- padding = 20.dp
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
) {
- Column {
+ Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
CircularAvatar(
icon = icon,
size = 40.dp,
iconSize = 24.dp,
- backgroundColor = color.copy(alpha = 0.1f),
- iconTint = color
+ backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
)
Spacer(modifier = Modifier.width(16.dp))
}
Text(
text = title,
- fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall
)
@@ -152,7 +152,7 @@ fun ShizukuStatusCard(
if (actionLabel != null && action != null) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = action) {
- Text(actionLabel, color = MaterialTheme.colorScheme.primary)
+ Text(actionLabel)
}
}
}
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 6437999f..76760b9c 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
@@ -1,13 +1,10 @@
package com.inputleaf.android.ui.components
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.NewReleases
import androidx.compose.material.icons.rounded.Shop
@@ -15,11 +12,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import com.inputleaf.android.update.VersionChangelog
@Composable
@@ -32,63 +25,38 @@ fun UpdateAvailableDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
+ icon = { Icon(Icons.Rounded.Download, contentDescription = "Update") },
title = {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Box(
- modifier = Modifier
- .size(40.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primaryContainer),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = Icons.Rounded.Download,
- contentDescription = "Update",
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
- modifier = Modifier.size(24.dp)
- )
- }
- Column {
- Text(
- text = "Update Available",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = "v$latestVersion is now ready",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("Update Available")
+ Text(
+ text = "v$latestVersion is now ready",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
},
text = {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp)
- ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
if (changelog.isNotBlank()) {
Text(
text = "What's changed:",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
+ style = MaterialTheme.typography.labelLargeEmphasized,
)
- Spacer(modifier = Modifier.height(6.dp))
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(max = 220.dp)
- .clip(RoundedCornerShape(12.dp))
- .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
- .padding(12.dp)
- .verticalScroll(rememberScrollState())
+ Spacer(modifier = Modifier.height(8.dp))
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
) {
- MarkdownContent(markdown = changelog)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 220.dp)
+ .verticalScroll(rememberScrollState())
+ .padding(12.dp)
+ ) {
+ MarkdownContent(markdown = changelog)
+ }
}
Spacer(modifier = Modifier.height(12.dp))
}
@@ -104,16 +72,13 @@ fun UpdateAvailableDialog(
}
},
confirmButton = {
- Button(
- onClick = onUpdate,
- shape = RoundedCornerShape(12.dp)
- ) {
+ Button(onClick = onUpdate) {
Icon(
imageVector = if (isFdroid) Icons.Rounded.Shop else Icons.Rounded.Download,
contentDescription = null,
- modifier = Modifier.size(18.dp)
+ modifier = Modifier.size(ButtonDefaults.IconSize)
)
- Spacer(modifier = Modifier.width(8.dp))
+ Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(text = if (isFdroid) "Open in F-Droid" else "Download from GitHub")
}
},
@@ -132,37 +97,15 @@ fun WhatsNewDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
+ icon = { Icon(Icons.Rounded.NewReleases, contentDescription = "New") },
title = {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Box(
- modifier = Modifier
- .size(40.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.secondaryContainer),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = Icons.Rounded.NewReleases,
- contentDescription = "New",
- tint = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.size(24.dp)
- )
- }
- Column {
- Text(
- text = "What's New",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = "Version ${changelog.versionName}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("What's New")
+ Text(
+ text = "Version ${changelog.versionName}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
},
text = {
@@ -170,45 +113,24 @@ fun WhatsNewDialog(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
- .verticalScroll(rememberScrollState())
- .padding(vertical = 8.dp),
+ .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
changelog.highlights.forEach { highlight ->
- Row(
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- verticalAlignment = Alignment.Top
- ) {
- Box(
- modifier = Modifier
- .padding(top = 4.dp)
- .size(18.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primaryContainer),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = Icons.Rounded.Check,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
- modifier = Modifier.size(12.dp)
- )
- }
- Text(
- text = highlight,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface,
- lineHeight = 20.sp
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Icon(
+ imageVector = Icons.Rounded.CheckCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp)
)
+ Text(text = highlight, style = MaterialTheme.typography.bodyMedium)
}
}
}
},
confirmButton = {
- Button(
- onClick = onDismiss,
- shape = RoundedCornerShape(12.dp)
- ) {
+ Button(onClick = onDismiss) {
Text("Got it!")
}
}
diff --git a/app/src/main/java/com/inputleaf/android/ui/theme/Color.kt b/app/src/main/java/com/inputleaf/android/ui/theme/Color.kt
index e0d9a347..b29d2ea0 100644
--- a/app/src/main/java/com/inputleaf/android/ui/theme/Color.kt
+++ b/app/src/main/java/com/inputleaf/android/ui/theme/Color.kt
@@ -1,61 +1,80 @@
package com.inputleaf.android.ui.theme
-import androidx.compose.ui.graphics.Brush
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
-// Input Leaf brand (teal + leaf green)
-val TealDeep = Color(0xFF0C3244)
-val TealNavy = Color(0xFF002F35)
-val TealDark = Color(0xFF001A20)
-val LeafLight = Color(0xFFAEEA00)
-val LeafBright = Color(0xFF76FF03)
-val LeafGreen = Color(0xFF2E7D32)
-val LeafForest = Color(0xFF1B5E20)
-val MistGreen = Color(0xFF7CB8A8)
-val MistGreenDim = Color(0xFF5A9A8A)
+// Leaf-green M3 schemes for devices without dynamic color (Android 11 and below).
+val LeafLightColorScheme = lightColorScheme(
+ primary = Color(0xFF386A20),
+ onPrimary = Color(0xFFFFFFFF),
+ primaryContainer = Color(0xFFB8F397),
+ onPrimaryContainer = Color(0xFF205107),
+ secondary = Color(0xFF55624C),
+ onSecondary = Color(0xFFFFFFFF),
+ secondaryContainer = Color(0xFFD9E7CB),
+ onSecondaryContainer = Color(0xFF3E4A36),
+ tertiary = Color(0xFF386667),
+ onTertiary = Color(0xFFFFFFFF),
+ tertiaryContainer = Color(0xFFBBEBEC),
+ onTertiaryContainer = Color(0xFF1E4E4E),
+ error = Color(0xFFBA1A1A),
+ onError = Color(0xFFFFFFFF),
+ errorContainer = Color(0xFFFFDAD6),
+ onErrorContainer = Color(0xFF93000A),
+ background = Color(0xFFF8FAF0),
+ onBackground = Color(0xFF191D16),
+ surface = Color(0xFFF8FAF0),
+ onSurface = Color(0xFF191D16),
+ surfaceVariant = Color(0xFFE0E4D6),
+ onSurfaceVariant = Color(0xFF43483E),
+ outline = Color(0xFF74796D),
+ outlineVariant = Color(0xFFC4C8BA),
+ inverseSurface = Color(0xFF2E312B),
+ inverseOnSurface = Color(0xFFEFF2E7),
+ inversePrimary = Color(0xFF9DD67D),
+ surfaceDim = Color(0xFFD8DBD1),
+ surfaceBright = Color(0xFFF8FAF0),
+ surfaceContainerLowest = Color(0xFFFFFFFF),
+ surfaceContainerLow = Color(0xFFF2F5EA),
+ surfaceContainer = Color(0xFFECEFE4),
+ surfaceContainerHigh = Color(0xFFE7E9DF),
+ surfaceContainerHighest = Color(0xFFE1E4D9),
+)
-// Legacy aliases (purple) — migrate callers to brand greens
-val Purple300 = LeafLight
-val Purple400 = LeafBright
-val Purple500 = LeafGreen
-val Purple600 = LeafForest
-val Purple700 = LeafForest
-val Purple100 = Color(0xFFC8E6C9)
-val Purple50 = Color(0xFFE8F5E9)
-
-// Semantic colors
-val Success400 = Color(0xFFA7F3D0)
-val Success500 = Color(0xFF10B981)
-val Success600 = Color(0xFF059669)
-val Success100 = Color(0xFFD1FAE5)
-val Warning500 = Color(0xFFFF9800)
-val Warning600 = Color(0xFFF57C00)
-
-// Surface colors
-val Surface = Color(0xFFF4F8F7)
-val SurfaceVariant = Color(0xFFE8F0EE)
-val Background = Color(0xFFEEF4F2)
-
-// Text colors
-val TextPrimary = Color(0xFF0D1F1A)
-val TextSecondary = Color(0xFF4A635C)
-val TextTertiary = Color(0xFF7A948C)
-
-// Gradients
-object Gradients {
- val Primary = Brush.linearGradient(
- colors = listOf(LeafBright, LeafGreen),
- )
-
- val Accent = Brush.linearGradient(
- colors = listOf(MistGreen, Color(0xFFB2DFDB)),
- )
-
- val Success = Brush.linearGradient(
- colors = listOf(Success100, Success400),
- )
-
- val Splash = Brush.verticalGradient(
- colors = listOf(TealNavy, TealDeep, TealDark),
- )
-}
+val LeafDarkColorScheme = darkColorScheme(
+ primary = Color(0xFF9DD67D),
+ onPrimary = Color(0xFF0B3900),
+ primaryContainer = Color(0xFF205107),
+ onPrimaryContainer = Color(0xFFB8F397),
+ secondary = Color(0xFFBDCBB0),
+ onSecondary = Color(0xFF283420),
+ secondaryContainer = Color(0xFF3E4A36),
+ onSecondaryContainer = Color(0xFFD9E7CB),
+ tertiary = Color(0xFFA0CFD0),
+ onTertiary = Color(0xFF003738),
+ tertiaryContainer = Color(0xFF1E4E4E),
+ onTertiaryContainer = Color(0xFFBBEBEC),
+ error = Color(0xFFFFB4AB),
+ onError = Color(0xFF690005),
+ errorContainer = Color(0xFF93000A),
+ onErrorContainer = Color(0xFFFFDAD6),
+ background = Color(0xFF11140E),
+ onBackground = Color(0xFFE1E4D9),
+ surface = Color(0xFF11140E),
+ onSurface = Color(0xFFE1E4D9),
+ surfaceVariant = Color(0xFF43483E),
+ onSurfaceVariant = Color(0xFFC4C8BA),
+ outline = Color(0xFF8E9286),
+ outlineVariant = Color(0xFF43483E),
+ inverseSurface = Color(0xFFE1E4D9),
+ inverseOnSurface = Color(0xFF2E312B),
+ inversePrimary = Color(0xFF386A20),
+ surfaceDim = Color(0xFF11140E),
+ surfaceBright = Color(0xFF373A33),
+ surfaceContainerLowest = Color(0xFF0C0F09),
+ surfaceContainerLow = Color(0xFF191D16),
+ surfaceContainer = Color(0xFF1D211A),
+ surfaceContainerHigh = Color(0xFF282B24),
+ surfaceContainerHighest = Color(0xFF32362F),
+)
diff --git a/app/src/main/java/com/inputleaf/android/ui/theme/Shape.kt b/app/src/main/java/com/inputleaf/android/ui/theme/Shape.kt
deleted file mode 100644
index c7e11dd1..00000000
--- a/app/src/main/java/com/inputleaf/android/ui/theme/Shape.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.inputleaf.android.ui.theme
-
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Shapes
-import androidx.compose.ui.unit.dp
-
-val InputLeafShapes = Shapes(
- // Buttons and small elements
- small = RoundedCornerShape(20.dp),
- // Cards and containers
- medium = RoundedCornerShape(28.dp),
- // Large cards and sheets
- large = RoundedCornerShape(32.dp)
-)
-
-// Custom shapes for specific components
-object CustomShapes {
- val BottomNav = RoundedCornerShape(32.dp)
- val Card = RoundedCornerShape(28.dp)
- val CardLarge = RoundedCornerShape(32.dp)
- val Button = RoundedCornerShape(28.dp)
- val Pill = RoundedCornerShape(24.dp)
- val Toggle = RoundedCornerShape(16.dp)
-}
diff --git a/app/src/main/java/com/inputleaf/android/ui/theme/Theme.kt b/app/src/main/java/com/inputleaf/android/ui/theme/Theme.kt
new file mode 100644
index 00000000..93fddcfe
--- /dev/null
+++ b/app/src/main/java/com/inputleaf/android/ui/theme/Theme.kt
@@ -0,0 +1,25 @@
+package com.inputleaf.android.ui.theme
+
+import android.os.Build
+import androidx.compose.material3.MaterialExpressiveTheme
+import androidx.compose.material3.MotionScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun InputLeafTheme(darkTheme: Boolean, content: @Composable () -> Unit) {
+ val context = LocalContext.current
+ val colorScheme = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ darkTheme -> LeafDarkColorScheme
+ else -> LeafLightColorScheme
+ }
+ MaterialExpressiveTheme(
+ colorScheme = colorScheme,
+ motionScheme = MotionScheme.expressive(),
+ content = content,
+ )
+}
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 9a4d8538..316c068b 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.3",
+ versionCode = 9,
+ highlights = listOf(
+ "Rooted devices can grant su and inject through a uid-0 HID service, without Shizuku.",
+ "New Material 3 Expressive design with dynamic color, edge-to-edge screens, and a refreshed settings layout.",
+ ),
+ ),
VersionChangelog(
versionName = "1.4.2",
versionCode = 8,
@@ -235,10 +243,10 @@ internal fun resolveInstallSource(installerPackage: String?): InstallSource {
}
internal fun versionNameFrom(packageInfo: PackageInfo?): String =
- packageInfo?.versionName ?: "1.4.2"
+ packageInfo?.versionName ?: "1.4.3"
internal fun versionCodeFrom(packageInfo: PackageInfo?, sdkInt: Int = Build.VERSION.SDK_INT): Long {
- if (packageInfo == null) return 8L
+ if (packageInfo == null) return 9L
return if (sdkInt >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 1f1f19c6..271c4228 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,7 +1,4 @@
-
+
diff --git a/app/src/test/java/com/inputleaf/android/inject/InputInjectorTest.kt b/app/src/test/java/com/inputleaf/android/inject/InputInjectorTest.kt
index 8f3be9af..55d4bfd3 100644
--- a/app/src/test/java/com/inputleaf/android/inject/InputInjectorTest.kt
+++ b/app/src/test/java/com/inputleaf/android/inject/InputInjectorTest.kt
@@ -2,6 +2,7 @@ package com.inputleaf.android.inject
import com.google.common.truth.Truth.assertThat
import com.inputleaf.android.model.InputLeapEvent
+import com.inputleaf.android.privilege.PrivilegeKind
import org.junit.Test
class InputInjectorTest {
@@ -25,5 +26,6 @@ class InputInjectorTest {
injector.updatePointerSpeed(2)
injector.onHidMouseEnter(100, 200)
injector.onHidMouseLeave()
+ assertThat(injector.privilegeKind()).isEqualTo(PrivilegeKind.NONE)
}
}
diff --git a/app/src/test/java/com/inputleaf/android/privilege/PrivilegeSelectorTest.kt b/app/src/test/java/com/inputleaf/android/privilege/PrivilegeSelectorTest.kt
new file mode 100644
index 00000000..0e175d59
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/privilege/PrivilegeSelectorTest.kt
@@ -0,0 +1,156 @@
+package com.inputleaf.android.privilege
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class PrivilegeSelectorTest {
+
+ @Test
+ fun `prefers Shizuku when both Shizuku and root are ready`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = true,
+ root = RootAvailability.GRANTED,
+ )
+ assertThat(PrivilegeSelector.select(snapshot)).isEqualTo(PrivilegeKind.SHIZUKU)
+ assertThat(InjectorMethodResolver.resolve("auto", snapshot, accessibilityAvailable = true))
+ .isEqualTo(ResolvedInjector.PRIVILEGED_SHIZUKU)
+ assertThat(InjectorMethodResolver.resolve("shizuku", snapshot, accessibilityAvailable = false))
+ .isEqualTo(ResolvedInjector.PRIVILEGED_SHIZUKU)
+ }
+
+ @Test
+ fun `uses root when Shizuku is missing and su is granted`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = false,
+ root = RootAvailability.GRANTED,
+ )
+ assertThat(PrivilegeSelector.select(snapshot)).isEqualTo(PrivilegeKind.ROOT)
+ assertThat(InjectorMethodResolver.resolve("auto", snapshot, accessibilityAvailable = true))
+ .isEqualTo(ResolvedInjector.PRIVILEGED_ROOT)
+ }
+
+ @Test
+ fun `uses root when Shizuku is missing and su has not been prompted yet`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = false,
+ root = RootAvailability.UNKNOWN,
+ )
+ assertThat(PrivilegeSelector.select(snapshot)).isEqualTo(PrivilegeKind.ROOT)
+ assertThat(InjectorMethodResolver.resolve("auto", snapshot, accessibilityAvailable = false))
+ .isEqualTo(ResolvedInjector.PRIVILEGED_ROOT)
+ }
+
+ @Test
+ fun `falls back to accessibility when neither privilege path is usable`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = false,
+ root = RootAvailability.MISSING,
+ )
+ assertThat(PrivilegeSelector.select(snapshot)).isEqualTo(PrivilegeKind.NONE)
+ assertThat(InjectorMethodResolver.resolve("auto", snapshot, accessibilityAvailable = true))
+ .isEqualTo(ResolvedInjector.ACCESSIBILITY)
+ }
+
+ @Test
+ fun `returns none when no injector can attach`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = false,
+ root = RootAvailability.DENIED,
+ )
+ assertThat(PrivilegeSelector.select(snapshot)).isEqualTo(PrivilegeKind.NONE)
+ assertThat(InjectorMethodResolver.resolve("auto", snapshot, accessibilityAvailable = false))
+ .isEqualTo(ResolvedInjector.NONE)
+ }
+
+ @Test
+ fun `explicit Shizuku does not silently fall back to root`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = false,
+ root = RootAvailability.GRANTED,
+ )
+ assertThat(InjectorMethodResolver.resolve("shizuku", snapshot, accessibilityAvailable = true))
+ .isEqualTo(ResolvedInjector.NONE)
+ }
+
+ @Test
+ fun `explicit root does not require Shizuku`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = false,
+ root = RootAvailability.GRANTED,
+ )
+ assertThat(InjectorMethodResolver.resolve("root", snapshot, accessibilityAvailable = false))
+ .isEqualTo(ResolvedInjector.PRIVILEGED_ROOT)
+ }
+
+ @Test
+ fun `explicit root is used even when Shizuku is ready`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = true,
+ root = RootAvailability.GRANTED,
+ )
+ assertThat(InjectorMethodResolver.resolve("root", snapshot, accessibilityAvailable = false))
+ .isEqualTo(ResolvedInjector.PRIVILEGED_ROOT)
+ assertThat(PrivilegedInjectorFactory.kindFor("root", snapshot))
+ .isEqualTo(PrivilegeKind.ROOT)
+ }
+
+ @Test
+ fun `kindFor auto prefers Shizuku then root and never invents enter leave`() {
+ val both = PrivilegeSnapshot(shizukuReady = true, root = RootAvailability.GRANTED)
+ val rootOnly = PrivilegeSnapshot(shizukuReady = false, root = RootAvailability.GRANTED)
+ assertThat(PrivilegedInjectorFactory.kindFor("auto", both)).isEqualTo(PrivilegeKind.SHIZUKU)
+ assertThat(PrivilegedInjectorFactory.kindFor("auto", rootOnly)).isEqualTo(PrivilegeKind.ROOT)
+ assertThat(PrivilegeKind.entries.toList()).containsExactly(
+ PrivilegeKind.SHIZUKU,
+ PrivilegeKind.ROOT,
+ PrivilegeKind.NONE,
+ ).inOrder()
+ }
+
+ @Test
+ fun `an unusable explicit preference never silently becomes root`() {
+ val rooted = PrivilegeSnapshot(shizukuReady = false, root = RootAvailability.GRANTED)
+ // "shizuku" with Shizuku down must not pop an su prompt the user never asked for.
+ assertThat(PrivilegedInjectorFactory.kindFor("shizuku", rooted))
+ .isEqualTo(PrivilegeKind.NONE)
+ assertThat(PrivilegedInjectorFactory.kindFor("accessibility", rooted))
+ .isEqualTo(PrivilegeKind.NONE)
+ }
+
+ @Test
+ fun `explicit root or accessibility that cannot attach resolves to none`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = false,
+ root = RootAvailability.MISSING,
+ )
+ assertThat(InjectorMethodResolver.resolve("root", snapshot, accessibilityAvailable = true))
+ .isEqualTo(ResolvedInjector.NONE)
+ assertThat(
+ InjectorMethodResolver.resolve("accessibility", snapshot, accessibilityAvailable = false),
+ ).isEqualTo(ResolvedInjector.NONE)
+ }
+
+ @Test
+ fun `latent accessibility hid ignores root that has never been asked`() {
+ val neverAsked = PrivilegeSnapshot(shizukuReady = false, root = RootAvailability.UNKNOWN)
+ assertThat(PrivilegedInjectorFactory.kindFor("auto", snapshotForLatentHid(neverAsked)))
+ .isEqualTo(PrivilegeKind.NONE)
+ val granted = PrivilegeSnapshot(shizukuReady = false, root = RootAvailability.GRANTED)
+ assertThat(PrivilegedInjectorFactory.kindFor("auto", snapshotForLatentHid(granted)))
+ .isEqualTo(PrivilegeKind.ROOT)
+ val shizuku = PrivilegeSnapshot(shizukuReady = true, root = RootAvailability.UNKNOWN)
+ assertThat(PrivilegedInjectorFactory.kindFor("auto", snapshotForLatentHid(shizuku)))
+ .isEqualTo(PrivilegeKind.SHIZUKU)
+ }
+
+ @Test
+ fun `explicit accessibility keeps overlay path even when root is present`() {
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = false,
+ root = RootAvailability.GRANTED,
+ )
+ assertThat(
+ InjectorMethodResolver.resolve("accessibility", snapshot, accessibilityAvailable = true),
+ ).isEqualTo(ResolvedInjector.ACCESSIBILITY)
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/privilege/PrivilegedUserServiceHostTest.kt b/app/src/test/java/com/inputleaf/android/privilege/PrivilegedUserServiceHostTest.kt
new file mode 100644
index 00000000..7f603545
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/privilege/PrivilegedUserServiceHostTest.kt
@@ -0,0 +1,186 @@
+package com.inputleaf.android.privilege
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.ServiceConnection
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.IBinder
+import android.os.Looper
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import com.inputleaf.android.inject.AccessibilityInputInjector
+import com.topjohnwu.superuser.Shell
+import com.topjohnwu.superuser.ipc.RootService
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito.mockStatic
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import rikka.shizuku.Shizuku
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
+class PrivilegedUserServiceHostTest {
+
+ @Test
+ fun `root bind intent uses daemon mode so uid-0 injector stays warm`() {
+ val context = ApplicationProvider.getApplicationContext()
+ val intent = RootInputInjectorService.bindIntent(context)
+ assertThat(intent.hasCategory(RootService.CATEGORY_DAEMON_MODE)).isTrue()
+ assertThat(intent.hasCategory(RootInputInjectorService.CATEGORY_VERSION)).isTrue()
+ assertThat(intent.component?.className)
+ .isEqualTo(RootInputInjectorService::class.java.name)
+ }
+
+ @Test
+ fun `hostFor root does not require Shizuku`() {
+ val context = ApplicationProvider.getApplicationContext()
+ val host = PrivilegedInjectorFactory.hostFor(context, PrivilegeKind.ROOT)
+ assertThat(host.kind).isEqualTo(PrivilegeKind.ROOT)
+ assertThat(host.displayName).contains("HID")
+ }
+
+ @Test
+ fun `create with explicit root keeps privilege attach-only`() {
+ val context = ApplicationProvider.getApplicationContext()
+ val snapshot = PrivilegeSnapshot(
+ shizukuReady = true,
+ root = RootAvailability.GRANTED,
+ )
+ val injector = PrivilegedInjectorFactory.create(
+ context,
+ screenWidth = 1080,
+ screenHeight = 1920,
+ snapshot = snapshot,
+ method = "root",
+ )
+ assertThat(injector.privilegeKind()).isEqualTo(PrivilegeKind.ROOT)
+ assertThat(injector.name).contains("HID")
+ }
+
+ @Test
+ fun `root service bind unbind and destroy stay on the shared stub`() {
+ val service = RootInputInjectorService()
+ assertThat(service.onBind(Intent())).isNotNull()
+ assertThat(service.onUnbind(Intent())).isTrue()
+ service.onDestroy()
+ }
+
+ @Test
+ fun `shizuku host reports availability and survives a failed bind`() {
+ val context = ApplicationProvider.getApplicationContext()
+ mockStatic(Shizuku::class.java).use { shizuku ->
+ shizuku.`when` { Shizuku.pingBinder() }.thenReturn(true)
+ shizuku.`when` { Shizuku.checkSelfPermission() }
+ .thenReturn(PackageManager.PERMISSION_GRANTED)
+ val host = PrivilegedInjectorFactory.hostFor(context, PrivilegeKind.SHIZUKU)
+ assertThat(host.kind).isEqualTo(PrivilegeKind.SHIZUKU)
+ assertThat(host.bindTimeoutMs).isEqualTo(10_000L)
+ assertThat(host.bindAttempts).isEqualTo(3)
+ assertThat(host.isAvailable()).isTrue()
+ assertThat(host.bind(connection)).isTrue()
+ host.unbind(connection, destroy = true)
+
+ shizuku.`when` { Shizuku.checkSelfPermission() }
+ .thenReturn(PackageManager.PERMISSION_DENIED)
+ assertThat(host.isAvailable()).isFalse()
+
+ shizuku.`when` { Shizuku.pingBinder() }
+ .thenThrow(IllegalStateException("binder dead"))
+ assertThat(host.isAvailable()).isFalse()
+
+ shizuku.`when` { Shizuku.pingBinder() }.thenReturn(true)
+ shizuku.`when` {
+ Shizuku.bindUserService(
+ any(Shizuku.UserServiceArgs::class.java),
+ any(ServiceConnection::class.java),
+ )
+ }.thenThrow(IllegalStateException("bind"))
+ assertThat(host.bind(connection)).isFalse()
+ }
+ }
+
+ @Test
+ fun `root host binds on the main thread and posts when called off it`() {
+ val context = ApplicationProvider.getApplicationContext()
+ val granted = object : RootAccess {
+ override fun availability() = RootAvailability.GRANTED
+ override fun requestAccess() = true
+ }
+ val denied = object : RootAccess {
+ override fun availability() = RootAvailability.DENIED
+ override fun requestAccess() = false
+ }
+ val host = PrivilegedInjectorFactory.hostFor(context, PrivilegeKind.ROOT, granted)
+ assertThat(host.isAvailable()).isTrue()
+ assertThat(host.bindTimeoutMs).isEqualTo(30_000L)
+ assertThat(host.bindAttempts).isEqualTo(2)
+ assertThat(host.bind(connection)).isTrue()
+ mockStatic(RootService::class.java).use { root ->
+ root.`when` {
+ RootService.bind(any(Intent::class.java), any(ServiceConnection::class.java))
+ }.thenThrow(IllegalStateException("bind"))
+ assertThat(host.bind(connection)).isTrue()
+ }
+ host.unbind(connection, destroy = false)
+ host.unbind(connection, destroy = true)
+ assertThat(
+ PrivilegedInjectorFactory.hostFor(context, PrivilegeKind.ROOT, denied).isAvailable(),
+ ).isFalse()
+ val bare = object : ContextWrapper(context) {
+ override fun getApplicationContext(): Context? = null
+ }
+ assertThat(PrivilegedInjectorFactory.hostFor(bare, PrivilegeKind.ROOT, granted).kind)
+ .isEqualTo(PrivilegeKind.ROOT)
+
+ val worker = Thread { host.bind(connection) }
+ worker.start()
+ worker.join()
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+
+ @Test
+ fun `factory falls back to a Shizuku host when nothing is granted`() {
+ val context = ApplicationProvider.getApplicationContext()
+ assertThat(PrivilegedInjectorFactory.hostFor(context, PrivilegeKind.NONE).kind)
+ .isEqualTo(PrivilegeKind.SHIZUKU)
+ val created = PrivilegedInjectorFactory.create(context, 10, 20)
+ assertThat(created.name).contains("Shizuku")
+ val snapshot = PrivilegedInjectorFactory.currentSnapshot(object : RootAccess {
+ override fun availability() = RootAvailability.GRANTED
+ override fun requestAccess() = false
+ })
+ assertThat(snapshot.root).isEqualTo(RootAvailability.GRANTED)
+ assertThat(snapshot.shizukuReady).isFalse()
+ }
+
+ @Test
+ fun `accessibility latent hid does not bind root that was never asked`() {
+ val context = ApplicationProvider.getApplicationContext()
+ val previous = LibSuRootAccess.pathExists
+ try {
+ LibSuRootAccess.pathExists = { it == "/proc/ksu" }
+ mockStatic(Shell::class.java).use { shells ->
+ shells.`when` { Shell.isAppGrantedRoot() }.thenReturn(null)
+ val neverAsked = AccessibilityInputInjector(context, 1080, 1920)
+ assertThat(neverAsked.privilegeKind()).isNotEqualTo(PrivilegeKind.ROOT)
+
+ shells.`when` { Shell.isAppGrantedRoot() }.thenReturn(true)
+ val granted = AccessibilityInputInjector(context, 1080, 1920)
+ assertThat(granted.privilegeKind()).isEqualTo(PrivilegeKind.ROOT)
+ }
+ } finally {
+ LibSuRootAccess.pathExists = previous
+ }
+ }
+
+ private val connection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) = Unit
+ override fun onServiceDisconnected(name: ComponentName?) = Unit
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/privilege/RootAccessTest.kt b/app/src/test/java/com/inputleaf/android/privilege/RootAccessTest.kt
new file mode 100644
index 00000000..18462d1b
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/privilege/RootAccessTest.kt
@@ -0,0 +1,99 @@
+package com.inputleaf.android.privilege
+
+import com.google.common.truth.Truth.assertThat
+import com.topjohnwu.superuser.Shell
+import org.junit.Test
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.mockStatic
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+
+class RootAccessTest {
+
+ @Test
+ fun `su present when any known path exists`() {
+ assertThat(
+ LibSuRootAccess.isSuPresent { path -> path == "/debug_ramdisk/su" },
+ ).isTrue()
+ }
+
+ @Test
+ fun `su missing when no known path exists`() {
+ assertThat(LibSuRootAccess.isSuPresent { false }).isFalse()
+ }
+
+ @Test
+ fun `unknown and granted root can be used for HID`() {
+ assertThat(RootAvailability.UNKNOWN.canUse()).isTrue()
+ assertThat(RootAvailability.GRANTED.canUse()).isTrue()
+ assertThat(RootAvailability.DENIED.canUse()).isFalse()
+ assertThat(RootAvailability.MISSING.canUse()).isFalse()
+ }
+
+ @Test
+ fun `KernelSU sysfs marker counts as rooted even when su is hidden`() {
+ assertThat(
+ LibSuRootAccess.hasRootMarker { path -> path == "/sys/module/kernelsu" },
+ ).isTrue()
+ assertThat(LibSuRootAccess.hasRootMarker { false }).isFalse()
+ }
+
+ @Test
+ fun `default path probe finds nothing on a machine without su`() {
+ assertThat(LibSuRootAccess.isSuPresent()).isFalse()
+ assertThat(LibSuRootAccess.hasRootMarker()).isFalse()
+ }
+
+ @Test
+ fun `availability follows the shell grant and a marker when grant is unknown`() {
+ val previous = LibSuRootAccess.pathExists
+ try {
+ mockStatic(Shell::class.java).use { shells ->
+ shells.`when` { Shell.isAppGrantedRoot() }.thenReturn(true)
+ assertThat(LibSuRootAccess.availability()).isEqualTo(RootAvailability.GRANTED)
+
+ shells.`when` { Shell.isAppGrantedRoot() }.thenReturn(false)
+ assertThat(LibSuRootAccess.availability()).isEqualTo(RootAvailability.DENIED)
+
+ shells.`when` { Shell.isAppGrantedRoot() }.thenReturn(null)
+ LibSuRootAccess.pathExists = { false }
+ assertThat(LibSuRootAccess.availability()).isEqualTo(RootAvailability.MISSING)
+
+ LibSuRootAccess.pathExists = { it == "/proc/ksu" }
+ assertThat(LibSuRootAccess.availability()).isEqualTo(RootAvailability.UNKNOWN)
+
+ shells.`when` { Shell.isAppGrantedRoot() }
+ .thenThrow(IllegalStateException("no shell"))
+ assertThat(LibSuRootAccess.availability()).isEqualTo(RootAvailability.UNKNOWN)
+ }
+ } finally {
+ LibSuRootAccess.pathExists = previous
+ }
+ }
+
+ @Test
+ fun `requestAccess drops a cached non-root shell then reports the new shell`() {
+ mockStatic(Shell::class.java).use { shells ->
+ val cached = mock(Shell::class.java)
+ val fresh = mock(Shell::class.java)
+ `when`(cached.isRoot).thenReturn(false)
+ `when`(fresh.isRoot).thenReturn(true)
+ shells.`when` { Shell.getCachedShell() }.thenReturn(cached)
+ shells.`when` { Shell.getShell() }.thenReturn(fresh)
+
+ assertThat(LibSuRootAccess.requestAccess()).isTrue()
+ verify(cached).close()
+
+ val alreadyRoot = mock(Shell::class.java)
+ `when`(alreadyRoot.isRoot).thenReturn(true)
+ shells.`when` { Shell.getCachedShell() }.thenReturn(alreadyRoot)
+ assertThat(LibSuRootAccess.requestAccess()).isTrue()
+ verify(alreadyRoot, never()).close()
+
+ shells.`when` { Shell.getCachedShell() }.thenReturn(null)
+ shells.`when` { Shell.getShell() }.thenThrow(IllegalStateException("su denied"))
+ assertThat(LibSuRootAccess.requestAccess()).isFalse()
+ }
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/service/ShizukuActiveSessionPolicyTest.kt b/app/src/test/java/com/inputleaf/android/service/ShizukuActiveSessionPolicyTest.kt
index 5e1a2f49..28b09f84 100644
--- a/app/src/test/java/com/inputleaf/android/service/ShizukuActiveSessionPolicyTest.kt
+++ b/app/src/test/java/com/inputleaf/android/service/ShizukuActiveSessionPolicyTest.kt
@@ -1,7 +1,16 @@
package com.inputleaf.android.service
+import android.content.Context
+import android.content.ServiceConnection
import com.google.common.truth.Truth.assertThat
+import com.inputleaf.android.inject.AccessibilityInputInjector
+import com.inputleaf.android.privilege.PrivilegeKind
+import com.inputleaf.android.privilege.PrivilegeSnapshot
+import com.inputleaf.android.privilege.PrivilegedUserServiceHost
+import com.inputleaf.android.privilege.RootAvailability
+import com.inputleaf.android.shizuku.ShizukuInputInjector
import org.junit.Test
+import org.mockito.Mockito.mock
class ShizukuActiveSessionPolicyTest {
@@ -16,4 +25,46 @@ class ShizukuActiveSessionPolicyTest {
assertThat(shouldClearActiveSession(ConnectAttemptOutcome.Rejected)).isTrue()
assertThat(shouldClearActiveSession(ConnectAttemptOutcome.TerminalFailure)).isTrue()
}
+
+ @Test
+ fun `shizuku restart is ignored only for the root injector itself`() {
+ val root = ShizukuInputInjector(10, 10, StubHost(PrivilegeKind.ROOT))
+ val shizuku = ShizukuInputInjector(10, 10, StubHost(PrivilegeKind.SHIZUKU))
+ val accessibility = AccessibilityInputInjector(
+ mock(Context::class.java),
+ 10,
+ 10,
+ hidKeyboard = root,
+ )
+
+ assertThat(shouldIgnoreShizukuRestart(root)).isTrue()
+ assertThat(shouldIgnoreShizukuRestart(shizuku)).isFalse()
+ assertThat(accessibility.privilegeKind()).isEqualTo(PrivilegeKind.ROOT)
+ assertThat(shouldIgnoreShizukuRestart(accessibility)).isFalse()
+ assertThat(shouldIgnoreShizukuRestart(null)).isFalse()
+ }
+
+ @Test
+ fun `explicit root recovery does not reconnect as Shizuku`() {
+ val shizukuUpRootDenied = PrivilegeSnapshot(
+ shizukuReady = true,
+ root = RootAvailability.DENIED,
+ )
+ assertThat(privilegedRecoveryKind("root", shizukuUpRootDenied)).isNull()
+ assertThat(privilegedRecoveryKind("auto", shizukuUpRootDenied)).isEqualTo(PrivilegeKind.SHIZUKU)
+ assertThat(
+ privilegedRecoveryKind(
+ "root",
+ PrivilegeSnapshot(shizukuReady = false, root = RootAvailability.GRANTED),
+ ),
+ ).isEqualTo(PrivilegeKind.ROOT)
+ }
+}
+
+private class StubHost(override val kind: PrivilegeKind) : PrivilegedUserServiceHost {
+ override val displayName: String = "stub"
+ override val bindTimeoutMs: Long = 1L
+ override fun isAvailable(): Boolean = false
+ override fun bind(connection: ServiceConnection): Boolean = false
+ override fun unbind(connection: ServiceConnection, destroy: Boolean) = Unit
}
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
index 33e3a6f1..97c32ade 100644
--- a/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresenceTest.kt
+++ b/app/src/test/java/com/inputleaf/android/shizuku/uhid/HidSysfsPresenceTest.kt
@@ -23,6 +23,7 @@ class HidSysfsPresenceTest {
name = "Input Leaf Keyboard HID",
uniq = "inputleaf-kbd",
root = tmp.root,
+ procDevices = File(tmp.root, "missing-proc"),
),
).isTrue()
assertThat(
@@ -30,6 +31,39 @@ class HidSysfsPresenceTest {
name = "other",
uniq = "inputleaf-mouse",
root = tmp.root,
+ procDevices = File(tmp.root, "missing-proc"),
+ ),
+ ).isFalse()
+ }
+
+ @Test
+ fun `matches uniq in proc bus input devices`() {
+ val proc = File(tmp.root, "devices")
+ proc.writeText(
+ """
+ I: Bus=0003 Vendor=1209 Product=0002 Version=0000
+ N: Name="Input Leaf Mouse HID"
+ P: Phys=
+ S: Sysfs=/devices/virtual/misc/uhid/0003:1209:0002.0001/input/input12
+ U: Uniq=inputleaf-mouse
+ H: Handlers=mouse0 event12
+
+ """.trimIndent(),
+ )
+
+ assertThat(
+ HidSysfsPresence.present(
+ name = "Input Leaf Mouse HID",
+ uniq = "inputleaf-mouse",
+ root = File(tmp.root, "empty-sysfs").apply { mkdir() },
+ procDevices = proc,
+ ),
+ ).isTrue()
+ assertThat(
+ HidSysfsPresence.presentInProc(
+ name = "other",
+ uniq = "missing",
+ procDevices = proc,
),
).isFalse()
}
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 8a2281cf..db5e3843 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
@@ -121,6 +121,41 @@ class UhidProtocolTest {
.containsExactly(0x01.toByte(), 0x02.toByte(), 0x03.toByte()).inOrder()
}
+ @Test
+ fun `writeInput2Into reuses a buffer without allocating a new packet`() {
+ val dest = ByteArray(UhidProtocol.INPUT2_PACKET_SIZE)
+ UhidProtocol.writeInput2Into(dest, byteArrayOf(0x11, 0x22))
+ val buffer = ByteBuffer.wrap(dest).order(ByteOrder.LITTLE_ENDIAN)
+ assertThat(buffer.getInt(0)).isEqualTo(UhidProtocol.UHID_INPUT2)
+ assertThat(buffer.getShort(4).toInt()).isEqualTo(2)
+ assertThat(dest[6]).isEqualTo(0x11.toByte())
+ assertThat(dest[7]).isEqualTo(0x22.toByte())
+
+ UhidProtocol.writeInput2Into(dest, byteArrayOf(0x33))
+ assertThat(ByteBuffer.wrap(dest).order(ByteOrder.LITTLE_ENDIAN).getShort(4).toInt())
+ .isEqualTo(1)
+ assertThat(dest[6]).isEqualTo(0x33.toByte())
+ }
+
+ @Test
+ fun `writeInput2Into encodes a six-byte mouse click and an eight-byte key`() {
+ val mouseDest = ByteArray(UhidProtocol.INPUT2_PACKET_SIZE)
+ val click = byteArrayOf(HidMouse.BUTTON_LEFT.toByte(), 0, 0, 0, 0, 0)
+ UhidProtocol.writeInput2Into(mouseDest, click)
+ assertThat(mouseDest[6].toInt() and 0xFF).isEqualTo(HidMouse.BUTTON_LEFT)
+ assertThat(
+ ByteBuffer.wrap(mouseDest).order(ByteOrder.LITTLE_ENDIAN).getShort(4).toInt(),
+ ).isEqualTo(HidMouse.REPORT_SIZE)
+
+ val keyDest = ByteArray(UhidProtocol.INPUT2_PACKET_SIZE)
+ val key = ByteArray(HidKeyboard.REPORT_SIZE).also { it[2] = 0x04 }
+ UhidProtocol.writeInput2Into(keyDest, key)
+ assertThat(keyDest[8]).isEqualTo(0x04.toByte())
+ assertThat(
+ ByteBuffer.wrap(keyDest).order(ByteOrder.LITTLE_ENDIAN).getShort(4).toInt(),
+ ).isEqualTo(HidKeyboard.REPORT_SIZE)
+ }
+
@Test
fun `destroy packet is a full event with DESTROY type`() {
val packet = UhidProtocol.destroyPacket()
diff --git a/app/src/test/java/com/inputleaf/android/ui/PermissionMethodAccordionStateTest.kt b/app/src/test/java/com/inputleaf/android/ui/PermissionMethodAccordionStateTest.kt
new file mode 100644
index 00000000..20305d2a
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/ui/PermissionMethodAccordionStateTest.kt
@@ -0,0 +1,126 @@
+package com.inputleaf.android.ui
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class PermissionMethodAccordionStateTest {
+
+ @Test
+ fun `defaults to Shizuku expanded`() {
+ val state = PermissionMethodAccordionState()
+ assertThat(state.expanded).isEqualTo(PermissionMethod.SHIZUKU)
+ assertThat(state.isExpanded(PermissionMethod.SHIZUKU)).isTrue()
+ assertThat(state.isExpanded(PermissionMethod.ROOT)).isFalse()
+ assertThat(state.isExpanded(PermissionMethod.ACCESSIBILITY)).isFalse()
+ }
+
+ @Test
+ fun `expanding Root collapses Shizuku`() {
+ val state = PermissionMethodAccordionState()
+ .onHeaderClick(PermissionMethod.ROOT)
+
+ assertThat(state.expanded).isEqualTo(PermissionMethod.ROOT)
+ assertThat(state.isExpanded(PermissionMethod.SHIZUKU)).isFalse()
+ assertThat(state.isExpanded(PermissionMethod.ROOT)).isTrue()
+ assertThat(state.isExpanded(PermissionMethod.ACCESSIBILITY)).isFalse()
+ }
+
+ @Test
+ fun `tapping the open header keeps that method expanded`() {
+ val state = PermissionMethodAccordionState()
+ .onHeaderClick(PermissionMethod.SHIZUKU)
+
+ assertThat(state.expanded).isEqualTo(PermissionMethod.SHIZUKU)
+ assertThat(state.isExpanded(PermissionMethod.ROOT)).isFalse()
+ assertThat(state.isExpanded(PermissionMethod.ACCESSIBILITY)).isFalse()
+ }
+
+ @Test
+ fun `switching methods never leaves zero expanded`() {
+ val afterRoot = PermissionMethodAccordionState()
+ .onHeaderClick(PermissionMethod.ROOT)
+ val afterAccessibility = afterRoot.onHeaderClick(PermissionMethod.ACCESSIBILITY)
+ val afterShizuku = afterAccessibility.onHeaderClick(PermissionMethod.SHIZUKU)
+
+ assertThat(afterRoot.expanded).isEqualTo(PermissionMethod.ROOT)
+ assertThat(afterAccessibility.expanded).isEqualTo(PermissionMethod.ACCESSIBILITY)
+ assertThat(afterShizuku.expanded).isEqualTo(PermissionMethod.SHIZUKU)
+ }
+
+ @Test
+ fun `rooted-device start still expands Shizuku first`() {
+ val state = PermissionMethodAccordionState()
+ assertThat(state.expanded).isEqualTo(PermissionMethod.SHIZUKU)
+ }
+
+ @Test
+ fun `opens on the method a live session is using`() {
+ val state = PermissionMethodAccordionState()
+ .onActiveMethodChanged(PermissionMethod.ROOT)
+
+ assertThat(state.expanded).isEqualTo(PermissionMethod.ROOT)
+ assertThat(state.isExpanded(PermissionMethod.SHIZUKU)).isFalse()
+ }
+
+ @Test
+ fun `a user tap wins over any later active-method change`() {
+ val state = PermissionMethodAccordionState()
+ .onHeaderClick(PermissionMethod.ACCESSIBILITY)
+ .onActiveMethodChanged(PermissionMethod.ROOT)
+
+ assertThat(state.expanded).isEqualTo(PermissionMethod.ACCESSIBILITY)
+ }
+
+ @Test
+ fun `no in-use method leaves the current card alone`() {
+ val state = PermissionMethodAccordionState(expanded = PermissionMethod.ROOT)
+ .onActiveMethodChanged(null)
+
+ assertThat(state.expanded).isEqualTo(PermissionMethod.ROOT)
+ }
+
+ @Test
+ fun `falls back to what would be picked when nothing is connected`() {
+ // Same order PrivilegeSelector uses: Shizuku, then root, then accessibility.
+ assertThat(
+ activePermissionMethod(null, shizukuReady = true, rootGranted = true, accessibilityAvailable = true),
+ ).isEqualTo(PermissionMethod.SHIZUKU)
+ assertThat(
+ activePermissionMethod(null, shizukuReady = false, rootGranted = true, accessibilityAvailable = true),
+ ).isEqualTo(PermissionMethod.ROOT)
+ assertThat(
+ activePermissionMethod(null, shizukuReady = false, rootGranted = false, accessibilityAvailable = true),
+ ).isEqualTo(PermissionMethod.ACCESSIBILITY)
+ assertThat(
+ activePermissionMethod(null, shizukuReady = false, rootGranted = false, accessibilityAvailable = false),
+ ).isNull()
+ }
+
+ @Test
+ fun `a live session outranks what would otherwise be picked`() {
+ // Connected over accessibility while root is granted: show accessibility.
+ assertThat(
+ activePermissionMethod(
+ PermissionMethod.ACCESSIBILITY,
+ shizukuReady = true,
+ rootGranted = true,
+ accessibilityAvailable = true,
+ ),
+ ).isEqualTo(PermissionMethod.ACCESSIBILITY)
+ }
+
+ @Test
+ fun `root granted but only detected later still opens Root`() {
+ // The silent re-probe resolves UNKNOWN after first paint; the card must follow.
+ val atFirstPaint = PermissionMethodAccordionState()
+ .onActiveMethodChanged(
+ activePermissionMethod(null, shizukuReady = false, rootGranted = false, accessibilityAvailable = false),
+ )
+ assertThat(atFirstPaint.expanded).isEqualTo(PermissionMethod.SHIZUKU)
+
+ val afterProbe = atFirstPaint.onActiveMethodChanged(
+ activePermissionMethod(null, shizukuReady = false, rootGranted = true, accessibilityAvailable = false),
+ )
+ assertThat(afterProbe.expanded).isEqualTo(PermissionMethod.ROOT)
+ }
+}
diff --git a/app/src/test/java/com/inputleaf/android/ui/RootGrantRememberedTest.kt b/app/src/test/java/com/inputleaf/android/ui/RootGrantRememberedTest.kt
new file mode 100644
index 00000000..cfe8d506
--- /dev/null
+++ b/app/src/test/java/com/inputleaf/android/ui/RootGrantRememberedTest.kt
@@ -0,0 +1,34 @@
+package com.inputleaf.android.ui
+
+import android.app.Application
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import com.inputleaf.android.storage.AppPreferences
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+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 RootGrantRememberedTest {
+
+ @Test
+ fun `connect time grant is remembered and a denial is forgotten`() = runTest {
+ val app = ApplicationProvider.getApplicationContext()
+ val prefs = AppPreferences(app)
+ val provider = PermissionStatusProvider(app, prefs)
+ try {
+ provider.rememberRootGrant(true)
+ assertThat(prefs.rootGrantRemembered.first()).isTrue()
+
+ provider.rememberRootGrant(false)
+ assertThat(prefs.rootGrantRemembered.first()).isFalse()
+ } finally {
+ provider.cleanup()
+ }
+ }
+}
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 d9c0fda7..0ccda46d 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.2")
+ assertThat(UpdateService.getCurrentVersion(context)).isEqualTo("1.4.3")
}
@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 34f6642f..20fc9eed 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.2")
+ assertThat(versionNameFrom(null)).isEqualTo("1.4.3")
}
@Test
@@ -98,7 +98,7 @@ class UpdateServiceTest {
versionCode = 42
}
assertThat(versionCodeFrom(packageInfo)).isEqualTo(42L)
- assertThat(versionCodeFrom(null)).isEqualTo(8L)
+ assertThat(versionCodeFrom(null)).isEqualTo(9L)
}
@Test
@@ -430,7 +430,7 @@ class UpdateServiceTest {
@Test
fun changelogProvider_fallsBackToLatestKnownRelease() {
val changelog = UpdateService.getChangelog("9.9.9")
- assertThat(changelog.versionName).isEqualTo("1.4.2")
+ assertThat(changelog.versionName).isEqualTo("1.4.3")
}
@Test
diff --git a/codecov.yml b/codecov.yml
index 9b4b5724..ffc3e580 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -8,6 +8,16 @@ parsers:
jacoco:
# Kotlin inline/lambda line maps often show as partials even when the branch ran.
partials_as_hits: true
+ # There is no Jacoco parser flag for a line with no probes. Codecov counts
+ # an added source line missing from the report as a patch miss, and the
+ # Android upload's miss wins when the JVM report has no counters for that
+ # line. fast-jvm uploads scripts/codecov_skip_unprobed.py next to the Kover
+ # XML: coverage -1 is skipped and leaves the patch denominator. The script
+ # emits -1 only for lines with no counters (absent from the report, or
+ # mi, ci, mb, and cb all zero). A missed instruction or a fully missed
+ # branch is not marked, so it still fails the 100% target.
+ # The Android job must not upload -1; a skip on either side would hide the
+ # other job's miss.
coverage:
status:
diff --git a/docs/01_splash_screen.jpg b/docs/01_splash_screen.jpg
deleted file mode 100644
index bdaa6822..00000000
Binary files a/docs/01_splash_screen.jpg and /dev/null differ
diff --git a/docs/02_setup_flow.jpg b/docs/02_setup_flow.jpg
deleted file mode 100644
index 4f0ca4c1..00000000
Binary files a/docs/02_setup_flow.jpg and /dev/null differ
diff --git a/docs/03_shizuku_setup.jpg b/docs/03_shizuku_setup.jpg
deleted file mode 100644
index 15decc1b..00000000
Binary files a/docs/03_shizuku_setup.jpg and /dev/null differ
diff --git a/docs/04_overlay_permission.jpg b/docs/04_overlay_permission.jpg
deleted file mode 100644
index e906a24a..00000000
Binary files a/docs/04_overlay_permission.jpg and /dev/null differ
diff --git a/docs/05_allow_bg_activity.jpg b/docs/05_allow_bg_activity.jpg
deleted file mode 100644
index 2a8db425..00000000
Binary files a/docs/05_allow_bg_activity.jpg and /dev/null differ
diff --git a/docs/06_home_screen.jpg b/docs/06_home_screen.jpg
deleted file mode 100644
index d48f5279..00000000
Binary files a/docs/06_home_screen.jpg and /dev/null differ
diff --git a/docs/07_settings_screen.jpg b/docs/07_settings_screen.jpg
deleted file mode 100644
index 00044c71..00000000
Binary files a/docs/07_settings_screen.jpg and /dev/null differ
diff --git a/docs/screenshots/01_onboarding_welcome.jpg b/docs/screenshots/01_onboarding_welcome.jpg
new file mode 100644
index 00000000..876b7a51
Binary files /dev/null and b/docs/screenshots/01_onboarding_welcome.jpg differ
diff --git a/docs/screenshots/02_onboarding_input_method.jpg b/docs/screenshots/02_onboarding_input_method.jpg
new file mode 100644
index 00000000..66c780cc
Binary files /dev/null and b/docs/screenshots/02_onboarding_input_method.jpg differ
diff --git a/docs/screenshots/03_onboarding_accessibility.jpg b/docs/screenshots/03_onboarding_accessibility.jpg
new file mode 100644
index 00000000..d770d872
Binary files /dev/null and b/docs/screenshots/03_onboarding_accessibility.jpg differ
diff --git a/docs/screenshots/04_home_disconnected.jpg b/docs/screenshots/04_home_disconnected.jpg
new file mode 100644
index 00000000..f0ed2b2a
Binary files /dev/null and b/docs/screenshots/04_home_disconnected.jpg differ
diff --git a/docs/screenshots/05_home_connecting.jpg b/docs/screenshots/05_home_connecting.jpg
new file mode 100644
index 00000000..faffe254
Binary files /dev/null and b/docs/screenshots/05_home_connecting.jpg differ
diff --git a/docs/screenshots/06_home_connected.jpg b/docs/screenshots/06_home_connected.jpg
new file mode 100644
index 00000000..cba0acc7
Binary files /dev/null and b/docs/screenshots/06_home_connected.jpg differ
diff --git a/docs/screenshots/07_servers_discovered.jpg b/docs/screenshots/07_servers_discovered.jpg
new file mode 100644
index 00000000..0b5e22fd
Binary files /dev/null and b/docs/screenshots/07_servers_discovered.jpg differ
diff --git a/docs/screenshots/08_servers_favorite.jpg b/docs/screenshots/08_servers_favorite.jpg
new file mode 100644
index 00000000..2da3b5a5
Binary files /dev/null and b/docs/screenshots/08_servers_favorite.jpg differ
diff --git a/docs/screenshots/09_servers_add_manual.jpg b/docs/screenshots/09_servers_add_manual.jpg
new file mode 100644
index 00000000..6ce8c900
Binary files /dev/null and b/docs/screenshots/09_servers_add_manual.jpg differ
diff --git a/docs/screenshots/10_servers_trust_fingerprint.jpg b/docs/screenshots/10_servers_trust_fingerprint.jpg
new file mode 100644
index 00000000..43e6ac92
Binary files /dev/null and b/docs/screenshots/10_servers_trust_fingerprint.jpg differ
diff --git a/docs/screenshots/11_servers_connected.jpg b/docs/screenshots/11_servers_connected.jpg
new file mode 100644
index 00000000..a5efe187
Binary files /dev/null and b/docs/screenshots/11_servers_connected.jpg differ
diff --git a/docs/screenshots/12_permissions_shizuku.jpg b/docs/screenshots/12_permissions_shizuku.jpg
new file mode 100644
index 00000000..72996632
Binary files /dev/null and b/docs/screenshots/12_permissions_shizuku.jpg differ
diff --git a/docs/screenshots/13_permissions_root.jpg b/docs/screenshots/13_permissions_root.jpg
new file mode 100644
index 00000000..b237d089
Binary files /dev/null and b/docs/screenshots/13_permissions_root.jpg differ
diff --git a/docs/screenshots/14_settings_connection_display.jpg b/docs/screenshots/14_settings_connection_display.jpg
new file mode 100644
index 00000000..8b1286e0
Binary files /dev/null and b/docs/screenshots/14_settings_connection_display.jpg differ
diff --git a/docs/screenshots/15_settings_security.jpg b/docs/screenshots/15_settings_security.jpg
new file mode 100644
index 00000000..96319821
Binary files /dev/null and b/docs/screenshots/15_settings_security.jpg differ
diff --git a/docs/screenshots/16_settings_about.jpg b/docs/screenshots/16_settings_about.jpg
new file mode 100644
index 00000000..bbaa5274
Binary files /dev/null and b/docs/screenshots/16_settings_about.jpg differ
diff --git a/docs/screenshots/17_dialog_connection_security.jpg b/docs/screenshots/17_dialog_connection_security.jpg
new file mode 100644
index 00000000..da45ba17
Binary files /dev/null and b/docs/screenshots/17_dialog_connection_security.jpg differ
diff --git a/docs/screenshots/18_dialog_input_method.jpg b/docs/screenshots/18_dialog_input_method.jpg
new file mode 100644
index 00000000..495c632a
Binary files /dev/null and b/docs/screenshots/18_dialog_input_method.jpg differ
diff --git a/docs/screenshots/19_dialog_cursor_style.jpg b/docs/screenshots/19_dialog_cursor_style.jpg
new file mode 100644
index 00000000..5440ee6c
Binary files /dev/null and b/docs/screenshots/19_dialog_cursor_style.jpg differ
diff --git a/docs/screenshots/20_dialog_screen_name.jpg b/docs/screenshots/20_dialog_screen_name.jpg
new file mode 100644
index 00000000..676cbbe7
Binary files /dev/null and b/docs/screenshots/20_dialog_screen_name.jpg differ
diff --git a/docs/screenshots/21_dialog_author.jpg b/docs/screenshots/21_dialog_author.jpg
new file mode 100644
index 00000000..d67977ab
Binary files /dev/null and b/docs/screenshots/21_dialog_author.jpg differ
diff --git a/docs/screenshots/22_light_home.jpg b/docs/screenshots/22_light_home.jpg
new file mode 100644
index 00000000..130b6901
Binary files /dev/null and b/docs/screenshots/22_light_home.jpg differ
diff --git a/docs/screenshots/23_light_servers.jpg b/docs/screenshots/23_light_servers.jpg
new file mode 100644
index 00000000..b2325b03
Binary files /dev/null and b/docs/screenshots/23_light_servers.jpg differ
diff --git a/docs/screenshots/24_light_permissions.jpg b/docs/screenshots/24_light_permissions.jpg
new file mode 100644
index 00000000..ac93f435
Binary files /dev/null and b/docs/screenshots/24_light_permissions.jpg differ
diff --git a/docs/screenshots/25_light_settings.jpg b/docs/screenshots/25_light_settings.jpg
new file mode 100644
index 00000000..a7920ef3
Binary files /dev/null and b/docs/screenshots/25_light_settings.jpg differ
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 012a0b01..42049bfb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,6 +11,7 @@ activityCompose = "1.13.0"
datastore = "1.2.1"
coroutines = "1.11.0"
shizuku = "13.1.5"
+libsu = "6.0.0"
deviceNames = "2.1.1"
androidStub = "4.1.1.4"
junit = "4.13.2"
@@ -30,7 +31,7 @@ robolectric = "4.14.1"
[libraries]
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
-compose-material3 = { module = "androidx.compose.material3:material3" }
+compose-material3 = { module = "androidx.compose.material3:material3", version = "1.5.0-alpha29" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
@@ -43,6 +44,8 @@ coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-androi
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" }
shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" }
+libsu-core = { module = "com.github.topjohnwu.libsu:core", version.ref = "libsu" }
+libsu-service = { module = "com.github.topjohnwu.libsu:service", version.ref = "libsu" }
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" }
diff --git a/scripts/codecov_skip_unprobed.py b/scripts/codecov_skip_unprobed.py
new file mode 100644
index 00000000..48469d46
--- /dev/null
+++ b/scripts/codecov_skip_unprobed.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""Write Codecov JSON that skips JaCoCo lines with no instruction counters.
+
+Codecov's Jacoco parser has no setting for a line that produced no probes.
+An added source line missing from the report is still a patch miss, and a
+line uploaded by another job as a miss stays a miss. Coverage -1 is
+Codecov's skipped line and is left out of the patch denominator. It also
+wins a merge, so this file is uploaded only with the JVM report: a line
+the JVM report did not probe is skipped, including when the Android report
+lists it as uncovered. A line with a missed instruction or a fully missed
+branch is omitted here and still fails the 100% patch target.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+
+def skipped_lines(report_path: Path, root: Path) -> dict[str, dict[str, int]]:
+ coverage: dict[str, dict[str, int]] = {}
+ tree = ET.parse(report_path)
+ for package in tree.getroot().iter("package"):
+ package_name = package.attrib.get("name", "")
+ for source in package.iter("sourcefile"):
+ probed: set[int] = set()
+ for line in source.iter("line"):
+ counters = [
+ int(line.attrib.get(name, "0"))
+ for name in ("mi", "ci", "mb", "cb")
+ ]
+ if any(counters):
+ probed.add(int(line.attrib["nr"]))
+ path = resolve_source(root, package_name, source.attrib["name"])
+ if path is None:
+ continue
+ line_count = len(path.read_text(encoding="utf-8").splitlines())
+ skipped = {
+ str(number): -1
+ for number in range(1, line_count + 1)
+ if number not in probed
+ }
+ if skipped:
+ coverage[path.relative_to(root).as_posix()] = skipped
+ return coverage
+
+
+def resolve_source(root: Path, package_name: str, filename: str) -> Path | None:
+ relative = Path(package_name) / filename
+ matches = [
+ path
+ for path in root.glob(f"**/{relative.as_posix()}")
+ if "build" not in path.parts
+ ]
+ main = [path for path in matches if "src/main" in path.as_posix()]
+ found = main or matches
+ if len(found) == 1:
+ return found[0]
+ return None
+
+
+def main() -> None:
+ if len(sys.argv) != 3:
+ raise SystemExit(f"usage: {sys.argv[0]} report.xml skipped.json")
+ report_path = Path(sys.argv[1])
+ output_path = Path(sys.argv[2])
+ coverage = skipped_lines(report_path, Path.cwd())
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(
+ json.dumps({"coverage": coverage}, separators=(",", ":")),
+ encoding="utf-8",
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/settings.gradle.kts b/settings.gradle.kts
index b43518a6..7312cf06 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -4,7 +4,11 @@ pluginManagement {
}
}
dependencyResolutionManagement {
- repositories { google(); mavenCentral() }
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri("https://jitpack.io") }
+ }
}
rootProject.name = "input-leaf-android"
include(":app")