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 + +
- Input Leaf Splash Screen
- Splash Screen + Onboarding: Welcome
+ Welcome
- Guided Setup Flow
- Guided Onboarding + Onboarding: Choose an Input Method
+ Choose an Input Method
- Shizuku Authorization
- Shizuku Permission + Onboarding: Accessibility Fallback
+ Accessibility Fallback
+ +### Home + + + + +
- Overlay Permission
- System Overlay + Home: Disconnected
+ Disconnected +
+ Home: Connecting
+ Connecting
- Main Connection Screen
+ Home: Connected & Ready
Connected & Ready
+ +### Servers + + + + + + + + + + + +
+ Servers: Discovered Servers
+ Discovered Servers +
+ Servers: Favorites
+ Favorites +
+ Servers: Connected Server
+ Connected Server +
+ Servers: Add Manually
+ Add Manually +
+ Servers: Trust on First Use
+ Trust on First Use +
+ +### Permissions + + + + + + +
+ Permissions: Shizuku Ready
+ Shizuku Ready +
+ Permissions: Root Access
+ Root Access +
+ +### Settings + + + + + + + + + + + + + + + + +
+ Settings: Connection & Display
+ Connection & Display +
+ Settings: Security
+ Security +
+ Settings: About & Community
+ About & Community +
+ Settings: Connection Security
+ Connection Security +
+ Settings: Input Method
+ Input Method +
+ Settings: Cursor Style
+ Cursor Style +
+ Settings: Screen Name
+ Screen Name +
+ Settings: Author Info
+ Author Info +
+ +### Light Theme + + + + + + + +
+ Light Theme: Home
+ Home +
+ Light Theme: Servers
+ Servers +
+ Light Theme: Permissions
+ Permissions +
- Settings Screen
- Custom Settings + Light Theme: 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 @@ - +