From 696e434b25da3e5b4aef33df0ca71531c5673cd1 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Tue, 21 Apr 2026 14:29:12 +0800 Subject: [PATCH] feat: terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 NativeAdbService,增强连接处理并简化 shell 命令执行 - 引入 TerminalInputView,管理终端输入交互 --- README.md | 11 +- TODO.md | 2 - app/build.gradle.kts | 4 +- .../LockscreenPasswordActivity.kt | 29 +- .../nativecore/NativeAdbService.kt | 39 +- .../scrcpyforandroid/pages/DeviceTabScreen.kt | 63 +- .../pages/FullscreenControlScreen.kt | 6 - .../scrcpyforandroid/pages/MainScreen.kt | 22 +- .../pages/ScrcpyAllOptionsScreen.kt | 14 +- .../scrcpyforandroid/pages/SettingsScreen.kt | 18 +- .../scrcpyforandroid/pages/TerminalScreen.kt | 616 ++++++++++++++++++ .../password/PasswordPickerPopup.kt | 48 +- .../scrcpyforandroid/widgets/PopupMenuItem.kt | 46 ++ .../widgets/TerminalInputView.kt | 94 +++ 14 files changed, 879 insertions(+), 133 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/PopupMenuItem.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt diff --git a/README.md b/README.md index 5ae388f..74872a4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ - 利用 mDNS 服务实现自动连接启用无线调试的设备、自动发现等待配对设备的IP与端口 - 自动横竖屏切换(算吗 - 画中画 +- 流式 adb 终端 ## 已知问题 @@ -45,19 +46,19 @@ `keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为 `System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启 -## [TODO](TODO.md) +## TODO \> [TODO.md](TODO.md) +## NOT-TODO + +应该有东西的但我不记得要写什么了 + ## 建议搭配模块 - 密码锁屏无法捕获: [LSPosed/DisableFlagSecure](https://github.com/LSPosed/DisableFlagSecure) - 开机自动启用 adb: [gist/906291](https://gist.github.com/Miuzarte/9062915f1615d5eebd363c759fda496c) -## NOT-TODO - -应该有东西的但我不记得要写什么了 - ## FAQ 1. 虚拟屏不显示输入法 / 输入法显示在主屏幕 diff --git a/TODO.md b/TODO.md index 867ad0c..e8f4a7c 100644 --- a/TODO.md +++ b/TODO.md @@ -2,8 +2,6 @@ # TODO -adb shell page (in nav dock) - ## PARAMS ### LOWER PRIORITY diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d5b4f1..de7aa3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,8 +54,8 @@ android { applicationId = "io.github.miuzarte.scrcpyforandroid" minSdk = 26 targetSdk = 37 - versionCode = 15 - versionName = "0.1.9" + versionCode = 16 + versionName = "0.2.0" externalNativeBuild { cmake { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt index abe73a7..d2f3e59 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt @@ -320,8 +320,11 @@ private fun LockscreenPasswordScreen( OverlayDialog( show = true, title = "关闭验证后密码将失去保护", - summary = "关闭后每次填充密码时将不再强制认证" + - "\n同时会熔断当前经认证创建的密码", + summary = + """ + 关闭后每次填充密码时将不再强制认证 + 同时会熔断当前经认证创建的密码 + """.trimIndent(), defaultWindowInsetsPadding = false, onDismissRequest = { showDisableDialog = false }, ) { @@ -468,8 +471,11 @@ private fun LockscreenPasswordPage( SwitchPreference( title = "填充密码时需要验证", summary = - if (canAuthenticate) "关闭后将允许直接填充锁屏密码" + - "\n同时会熔断当前经认证创建的密码" + if (canAuthenticate) + """ + 关闭后将允许直接填充锁屏密码 + 同时会熔断当前经认证创建的密码 + """.trimIndent() else "当前设备无认证认证能力", checked = requireAuth, enabled = canAuthenticate || requireAuth, @@ -528,12 +534,15 @@ private fun LockscreenPasswordPage( item { Text( - text = "免责声明" + - "\n0. 无法保证没有 bug" + - "\n1. 本功能的防护边界仅包括加密存储、按需认证和使用后内存清理,不构成绝对安全保证" + - "\n2. 在 root / posed / hook / 调试器 / 恶意输入法 等环境下,密码仍可能泄露" + - "\n3. 本功能不会绕过系统锁屏认证,仅用于你已合法授权控制的设备" + - "\n4. 关闭“填充密码时需要验证”会显著降低安全性,请谨慎选择", + text = + """ + 免责声明 + 0. 无法保证没有 bug + 1. 本功能的防护边界仅包括加密存储、按需认证和使用后内存清理,不构成绝对安全保证 + 2. 在 root / posed / hook / 调试器 / 恶意输入法 等环境下,密码仍可能泄露 + 3. 本功能不会绕过系统锁屏认证,仅用于你已合法授权控制的设备 + 4. 关闭“填充密码时需要验证”会显著降低安全性,请谨慎选择 + """.trimIndent(), fontSize = textStyles.body2.fontSize, color = colorScheme.onSurfaceVariantSummary, modifier = Modifier diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt index 7e15833..0d10850 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt @@ -13,8 +13,9 @@ import kotlin.time.Duration * Higher-level ADB service that wraps `DirectAdbTransport` and provides * coroutine-based connect/disconnect/shell helpers for callers. * - * Methods use Mutex for thread-safety because the underlying transport is single-connection - * and may be accessed from multiple coroutines. + * The mutex protects connection replacement and lifecycle transitions. + * Once a live connection reference is obtained, stream I/O is performed outside + * the mutex so long-running operations do not block disconnect or other calls. * * All network operations are executed on Dispatchers.IO. */ @@ -127,27 +128,29 @@ object NativeAdbService { /** * Execute a shell command on the connected device and return stdout text. */ - suspend fun shell(command: String): String = mutex.withLock { - val response = requireConnection().shell(command) + suspend fun shell(command: String): String { + val conn = snapshotConnection() + val response = conn.shell(command) Log.d(TAG, "command: $command, response: $response") - response + return response } suspend fun startApp( packageName: String, displayId: Int? = null, forceStop: Boolean = false, - ): String = mutex.withLock { + ): String { + val conn = snapshotConnection() val normalizedPackageName = packageName.trim() require(normalizedPackageName.isNotBlank()) { "package name is blank" } if (forceStop) { - requireConnection().shell( + conn.shell( "am force-stop ${quoteShellArg(normalizedPackageName)}" ) } - val resolveOutput = requireConnection().shell( + val resolveOutput = conn.shell( "cmd package resolve-activity --brief ${quoteShellArg(normalizedPackageName)}" ) val componentName = resolveOutput @@ -163,21 +166,21 @@ object NativeAdbService { ?.let { " --display $it" } .orEmpty() val command = "am start-activity$displayArg -n ${quoteShellArg(componentName)}" - val response = requireConnection().shell(command) + val response = conn.shell(command) Log.d(TAG, "startApp(): package=$normalizedPackageName component=$componentName") - response + return response } - suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock { - requireConnection().openStream("shell:$command") + suspend fun openShellStream(command: String): AdbSocketStream { + return snapshotConnection().openStream("shell:$command") } - suspend fun push(localPath: Path, remotePath: String) = mutex.withLock { - requireConnection().push(localPath.toFile().readBytes(), remotePath) + suspend fun push(localPath: Path, remotePath: String) { + snapshotConnection().push(localPath.toFile().readBytes(), remotePath) } - suspend fun openAbstractSocket(name: String): AdbSocketStream = mutex.withLock { - requireConnection().openStream("localabstract:$name") + suspend fun openAbstractSocket(name: String): AdbSocketStream { + return snapshotConnection().openStream("localabstract:$name") } suspend fun close() { @@ -196,6 +199,10 @@ object NativeAdbService { ?: throw IllegalStateException("ADB not connected") } + private suspend fun snapshotConnection(): DirectAdbConnection = mutex.withLock { + requireConnection() + } + private fun quoteShellArg(value: String): String { return "'" + value.replace("'", "'\\''") + "'" } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index 668ec2b..8b34592 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -64,6 +64,7 @@ import io.github.miuzarte.scrcpyforandroid.widgets.AppListEntry import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard +import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard @@ -78,7 +79,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.DropdownImpl import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.ListPopupColumn @@ -175,7 +175,6 @@ fun DeviceTabPage( bottomInnerPadding: Dp, ) { val activity = LocalActivity.current - val fragmentActivity = remember(activity) { activity as? FragmentActivity } val context = LocalContext.current val scope = rememberCoroutineScope() @@ -1092,7 +1091,7 @@ fun DeviceTabPage( } }, onOpenAdvanced = { navigator.push(RootScreen.Advanced) }, - onStartStopHaptic = { haptics.contextClick() }, + onStartStopHaptic = haptics.contextClick, onStart = { runBusy("启动 scrcpy") { startScrcpySession() @@ -1128,9 +1127,7 @@ fun DeviceTabPage( context.startActivity(StreamActivity.createIntent(context)) }, imeRequestToken = imeRequestToken, - onImeCommitText = { text -> - commitImeText(text) - }, + onImeCommitText = { text -> commitImeText(text) }, onImeDeleteSurroundingText = { beforeLength, _ -> withContext(Dispatchers.IO) { repeat(beforeLength.coerceAtLeast(1)) { @@ -1195,16 +1192,11 @@ fun DeviceTabPage( } } }, - passwordPopupContent = - if (fragmentActivity == null) null - else { onDismissRequest -> - PasswordPickerPopupContent( - onDismissRequest = onDismissRequest, - onMessage = { message -> - scope.launch { snackbar.show(message) } - }, - ) - }, + passwordPopupContent = { onDismissRequest -> + PasswordPickerPopupContent( + onDismissRequest = onDismissRequest, + ) + }, ) } } @@ -1341,19 +1333,19 @@ private fun DeviceMenuPopup( onDismissRequest = onDismissRequest, ) { ListPopupColumn { - DeviceMenuPopupItem( + PopupMenuItem( text = "快速设备排序", optionSize = 3, index = 0, onSelectedIndexChange = { onReorderDevices() }, ) - DeviceMenuPopupItem( + PopupMenuItem( text = "虚拟按钮排序", optionSize = 3, index = 1, onSelectedIndexChange = { onOpenVirtualButtonOrder() }, ) - DeviceMenuPopupItem( + PopupMenuItem( text = "清空日志", optionSize = 3, index = 2, @@ -1364,36 +1356,3 @@ private fun DeviceMenuPopup( } } -@Composable -private fun DeviceMenuPopupItem( - text: String, - optionSize: Int, - index: Int, - enabled: Boolean = true, - onSelectedIndexChange: (Int) -> Unit, -) { - if (enabled) { - DropdownImpl( - text = text, - optionSize = optionSize, - isSelected = false, - index = index, - onSelectedIndexChange = onSelectedIndexChange, - ) - return - } - - val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem - val additionalBottomPadding = - if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem - Text( - text = text, - fontSize = textStyles.body1.fontSize, - fontWeight = FontWeight.Medium, - color = colorScheme.disabledOnSecondaryVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.PopupHorizontal) - .padding(top = additionalTopPadding, bottom = additionalBottomPadding), - ) -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 59cb575..e7f27bb 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -325,9 +325,6 @@ fun FullscreenControlScreen( { onDismissRequest -> PasswordPickerPopupContent( onDismissRequest = onDismissRequest, - onMessage = { message -> - taskScope.launch(Dispatchers.Main) { snackbar.show(message) } - }, ) } }, @@ -374,9 +371,6 @@ fun FullscreenControlScreen( { onDismissRequest -> PasswordPickerPopupContent( onDismissRequest = onDismissRequest, - onMessage = { message -> - taskScope.launch(Dispatchers.Main) { snackbar.show(message) } - }, ) } }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index 3c56755..035b940 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Devices import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Terminal import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -99,7 +100,8 @@ private enum class MainBottomTabDestination( val label: String, val icon: ImageVector, ) { - Device(label = "设备", icon = Icons.Rounded.Devices), + Devices(label = "设备", icon = Icons.Rounded.Devices), + Terminal(label = "终端", icon = Icons.Rounded.Terminal), Settings(label = "设置", icon = Icons.Rounded.Settings); } @@ -135,7 +137,7 @@ fun MainScreen() { val saveableStateHolder = rememberSaveableStateHolder() val tabs = remember { MainBottomTabDestination.entries } val pagerState = rememberPagerState( - initialPage = MainBottomTabDestination.Device.ordinal, + initialPage = MainBottomTabDestination.Devices.ordinal, pageCount = { tabs.size }) val currentTab = tabs[pagerState.currentPage] val rootBackStack = remember { mutableStateListOf(RootScreen.Home) } @@ -145,7 +147,9 @@ fun MainScreen() { // Scroll behaviors val devicesPageScrollBehavior = MiuixScrollBehavior( - canScroll = { currentTab == MainBottomTabDestination.Device }) + canScroll = { currentTab == MainBottomTabDestination.Devices }) + val terminalPageScrollBehavior = MiuixScrollBehavior( + canScroll = { currentTab == MainBottomTabDestination.Terminal }) val settingsPageScrollBehavior = MiuixScrollBehavior( canScroll = { currentTab == MainBottomTabDestination.Settings }) val advancedPageScrollBehavior = MiuixScrollBehavior( @@ -270,7 +274,7 @@ fun MainScreen() { // Derived flags val canNavigateBack = rootBackStack.size > 1 - || pagerState.currentPage != MainBottomTabDestination.Device.ordinal + || pagerState.currentPage != MainBottomTabDestination.Devices.ordinal LaunchedEffect(asBundle.lastUpdateCheckAt) { val now = System.currentTimeMillis() @@ -284,10 +288,10 @@ fun MainScreen() { fun handleBackNavigation() { if (rootBackStack.size > 1) { rootNavigator.pop() - } else if (pagerState.currentPage != MainBottomTabDestination.Device.ordinal) { + } else if (pagerState.currentPage != MainBottomTabDestination.Devices.ordinal) { scope.launch { pagerState.animateScrollToPage( - page = MainBottomTabDestination.Device.ordinal, + page = MainBottomTabDestination.Devices.ordinal, animationSpec = spring( dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO, stiffness = UiMotion.PAGE_SWITCH_STIFFNESS, @@ -415,13 +419,17 @@ fun MainScreen() { val tab = tabs[page] saveableStateHolder.SaveableStateProvider(tab.name) { when (tab) { - MainBottomTabDestination.Device -> DeviceTabScreen( + MainBottomTabDestination.Devices -> DeviceTabScreen( scrollBehavior = devicesPageScrollBehavior, scrcpy = scrcpy, bottomInnerPadding = bottomInnerPadding, onOpenReorderDevices = { showReorderDevices = true }, ) + MainBottomTabDestination.Terminal -> TerminalScreen( + bottomInnerPadding = bottomInnerPadding, + ) + MainBottomTabDestination.Settings -> SettingsScreen( scrollBehavior = settingsPageScrollBehavior, bottomInnerPadding = bottomInnerPadding, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index 9255f4a..9a53f7e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -1588,9 +1588,10 @@ internal fun ScrcpyAllOptionsPage( downsizeOnError = !it ) if (it) snackbar.show( - "默认情况下,在 MediaCodec 出错时," + - "scrcpy 会自动尝试使用更低的分辨率重新开始" + - "\n此选项将禁用此行为" + """ + 默认情况下,在 MediaCodec 出错时,scrcpy 会自动尝试使用更低的分辨率重新开始 + 此选项将禁用此行为 + """.trimIndent() ) }, ) @@ -1623,9 +1624,10 @@ internal fun ScrcpyAllOptionsPage( cleanup = !it ) if (it) snackbar.show( - "默认情况下,scrcpy 会从设备中移除服务器二进制文件," + - "并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)" + - "\n此选项将禁用此清理操作" + """ + 默认情况下,scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式) + 此选项将禁用此清理操作 + """.trimIndent() ) }, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index 9f319b0..256908c 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -268,9 +268,12 @@ fun SettingsPage( Card { SwitchPreference( title = "低延迟音频(实验性)", - summary = "启用后将尝试使用低延迟音频路径" + - "\n推荐配合 RAW PCM 编解码" + - "\n修改后建议划卡重启应用", + summary = + """ + 启用后将尝试使用低延迟音频路径 + 推荐配合 RAW PCM 编解码 + 修改后建议划卡重启应用 + """.trimIndent(), checked = asBundle.lowLatency, onCheckedChange = { if (!isScrcpyStreaming) @@ -353,9 +356,12 @@ fun SettingsPage( ) SwitchPreference( title = "实时同步剪贴板到受控机", - summary = "本机剪贴板更新后会自动同步到受控机" + - "\n禁用后需要使用虚拟按钮中的粘贴才能粘贴本机内容" + - "\nMIUI 完全不允许后台监听剪贴板,因此该选项在小米设备上可能无效", + summary = + """ + 本机剪贴板更新后会自动同步到受控机 + 禁用后需要使用虚拟按钮中的粘贴才能粘贴本机内容 + MIUI 完全不允许后台监听剪贴板,因此该选项在小米设备上可能无效 + """.trimIndent(), checked = asBundle.realtimeClipboardSyncToDevice, onCheckedChange = { asBundle = asBundle.copy(realtimeClipboardSyncToDevice = it) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt new file mode 100644 index 0000000..e74241a --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt @@ -0,0 +1,616 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.ViewConfiguration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream +import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService +import io.github.miuzarte.scrcpyforandroid.services.LocalInputService +import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController +import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar +import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur +import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop +import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem +import io.github.miuzarte.scrcpyforandroid.widgets.TerminalInputView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.ListPopupColumn +import top.yukonga.miuix.kmp.basic.ListPopupDefaults +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SmallTopAppBar +import top.yukonga.miuix.kmp.basic.SnackbarDuration +import top.yukonga.miuix.kmp.basic.SnackbarResult +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.blur.layerBackdrop +import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet +import top.yukonga.miuix.kmp.overlay.OverlayListPopup +import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme +import java.nio.charset.StandardCharsets +import kotlin.math.max +import kotlin.math.roundToInt +import android.view.KeyEvent as AndroidKeyEvent + +private const val DEFAULT_TERMINAL_FONT_SIZE_SP = 14f +private const val MIN_TERMINAL_FONT_SIZE_SP = 1f +private const val MAX_TERMINAL_FONT_SIZE_SP = 32f +private const val FONT_SCALE_STEP_THRESHOLD = 1.08f + +@Composable +fun TerminalScreen( + bottomInnerPadding: Dp, +) { + val context = LocalContext.current + val snackbar = LocalSnackbarController.current + val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current) + val blurActive = blurBackdrop != null + var showMenu by rememberSaveable { mutableStateOf(false) } + var showOutputSheet by rememberSaveable { mutableStateOf(false) } + var output by rememberSaveable { mutableStateOf("") } + + Scaffold( + topBar = { + BlurredBar(backdrop = blurBackdrop) { + SmallTopAppBar( + title = "终端", + color = if (blurActive) Color.Transparent else colorScheme.surface, + actions = { + Box { + IconButton( + onClick = { showMenu = true }, + holdDownState = showMenu, + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = "更多", + ) + } + + OverlayListPopup( + show = showMenu, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + onDismissRequest = { showMenu = false }, + ) { + ListPopupColumn { + PopupMenuItem( + text = "自由复制", + optionSize = 2, + index = 0, + enabled = output.isNotBlank(), + onSelectedIndexChange = { + showMenu = false + showOutputSheet = true + }, + ) + PopupMenuItem( + text = "清空输出", + optionSize = 2, + index = 1, + onSelectedIndexChange = { + showMenu = false + output = "" + }, + ) + } + } + } + }, + ) + } + }, + ) { pagePadding -> + Box( + modifier = + if (blurActive) Modifier.layerBackdrop(blurBackdrop) + else Modifier, + ) { + TerminalPage( + contentPadding = pagePadding, + bottomInnerPadding = bottomInnerPadding, + output = output, + onOutputChange = { output = it }, + ) + + TerminalOutputBottomSheet( + show = showOutputSheet, + output = output, + onDismissRequest = { showOutputSheet = false }, + onCopyAll = { + showMenu = false + LocalInputService.setClipboardText(context, output) + snackbar.show("已复制所有终端输出") + }, + ) + } + } +} + +@SuppressLint("ClickableViewAccessibility") +@Composable +private fun TerminalPage( + contentPadding: PaddingValues, + bottomInnerPadding: Dp, + output: String, + onOutputChange: (String) -> Unit, +) { + val context = LocalContext.current + val snackbar = LocalSnackbarController.current + val density = LocalDensity.current + val uiScope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + val touchSlop = remember(context) { ViewConfiguration.get(context).scaledTouchSlop } + val outputScrollState = rememberScrollState() + var outputBuffer by rememberSaveable { mutableStateOf(output) } + var terminalFontSizeSp by rememberSaveable { mutableFloatStateOf(DEFAULT_TERMINAL_FONT_SIZE_SP) } + var shellReady by remember { mutableStateOf(false) } + var shellConnecting by remember { mutableStateOf(false) } + var shellStream by remember { mutableStateOf(null) } + var terminalInputView by remember { mutableStateOf(null) } + var showKeyboardWhenReady by remember { mutableStateOf(false) } + var pinchInProgress by remember { mutableStateOf(false) } + var touchDownX by remember { mutableFloatStateOf(0f) } + var touchDownY by remember { mutableFloatStateOf(0f) } + var tapPending by remember { mutableStateOf(false) } + val imeBottomDp = with(density) { WindowInsets.ime.getBottom(this).toDp() } + + fun commitOutput(text: String) { + outputBuffer = text + onOutputChange(text) + } + + fun appendRawOutput(text: String) { + if (text.isEmpty()) { + return + } + val builder = StringBuilder(outputBuffer) + text.forEach { ch -> + when (ch) { + '\b', + '\u007F' -> if (builder.isNotEmpty()) builder.deleteCharAt(builder.lastIndex) + + else -> builder.append(ch) + } + } + commitOutput(builder.toString()) + } + + fun appendOutputLine(text: String) { + commitOutput( + buildString { + append(outputBuffer) + if (isNotEmpty() && !endsWith("\n")) { + append("\n") + } + append(text) + if (!endsWith("\n")) { + append("\n") + } + } + ) + } + + fun writeToShell(text: String) { + val stream = shellStream + if (stream == null || !shellReady || stream.closed) { + snackbar.show("终端会话尚未就绪") + return + } + taskScope.launch { + val result = runCatching { + stream.outputStream.write(text.toByteArray(StandardCharsets.UTF_8)) + stream.outputStream.flush() + } + withContext(Dispatchers.Main) { + result.onFailure { error -> + appendOutputLine("错误: ${error.message ?: error.javaClass.simpleName}") + snackbar.show("终端输入失败") + } + } + } + } + + fun mapKeyEventToShellText(event: AndroidKeyEvent): String? { + if (event.action != AndroidKeyEvent.ACTION_DOWN) { + return "" + } + return when (event.keyCode) { + AndroidKeyEvent.KEYCODE_ENTER -> "\n" + AndroidKeyEvent.KEYCODE_DEL -> "\b" + AndroidKeyEvent.KEYCODE_TAB -> "\t" + AndroidKeyEvent.KEYCODE_DPAD_UP -> "\u001B[A" + AndroidKeyEvent.KEYCODE_DPAD_DOWN -> "\u001B[B" + AndroidKeyEvent.KEYCODE_DPAD_RIGHT -> "\u001B[C" + AndroidKeyEvent.KEYCODE_DPAD_LEFT -> "\u001B[D" + else -> null + } + } + + fun showKeyboard() { + val inputView = terminalInputView ?: return + LocalInputService.showSoftKeyboard(inputView) + } + + fun showFontSizeSnackbar() { + uiScope.launch { + snackbar.hostState.newestSnackbarData()?.dismiss() + val result = snackbar.hostState.showSnackbar( + message = "终端字号 ${terminalFontSizeSp.roundToInt()}sp", + actionLabel = "恢复默认", + withDismissAction = true, + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) { + terminalFontSizeSp = DEFAULT_TERMINAL_FONT_SIZE_SP + } + } + } + + val scaleGestureDetector = remember { + var scaleAccumulator = 1f + ScaleGestureDetector( + context, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + pinchInProgress = true + scaleAccumulator = 1f + return true + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + scaleAccumulator *= detector.scaleFactor + var updated = false + while (scaleAccumulator >= FONT_SCALE_STEP_THRESHOLD) { + val nextSize = (terminalFontSizeSp + 1f) + .coerceAtMost(MAX_TERMINAL_FONT_SIZE_SP) + if (nextSize == terminalFontSizeSp) { + scaleAccumulator = 1f + break + } + terminalFontSizeSp = nextSize + scaleAccumulator /= FONT_SCALE_STEP_THRESHOLD + updated = true + } + while (scaleAccumulator <= 1f / FONT_SCALE_STEP_THRESHOLD) { + val nextSize = (terminalFontSizeSp - 1f) + .coerceAtLeast(MIN_TERMINAL_FONT_SIZE_SP) + if (nextSize == terminalFontSizeSp) { + scaleAccumulator = 1f + break + } + terminalFontSizeSp = nextSize + scaleAccumulator *= FONT_SCALE_STEP_THRESHOLD + updated = true + } + if (updated) { + showFontSizeSnackbar() + } + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) { + pinchInProgress = false + } + }, + ) + } + + fun openShellSession(showKeyboardAfterConnect: Boolean) { + if (showKeyboardAfterConnect) { + showKeyboardWhenReady = true + } + if (shellStream != null || shellConnecting) { + if (shellReady && showKeyboardAfterConnect) { + showKeyboard() + showKeyboardWhenReady = false + } + return + } + shellConnecting = true + taskScope.launch { + val streamResult = runCatching { + NativeAdbService.openShellStream("") + } + val stream = streamResult.getOrElse { error -> + withContext(Dispatchers.Main) { + shellConnecting = false + shellReady = false + showKeyboardWhenReady = false + appendOutputLine("错误: ${error.message ?: error.javaClass.simpleName}") + snackbar.show("终端会话创建失败") + } + return@launch + } + + withContext(Dispatchers.Main) { + shellStream = stream + shellReady = true + shellConnecting = false + if (showKeyboardWhenReady) { + showKeyboard() + showKeyboardWhenReady = false + } + } + + val buffer = ByteArray(4096) + try { + while (!stream.closed) { + val count = stream.inputStream.read(buffer) + if (count <= 0) { + break + } + val chunk = String(buffer, 0, count, StandardCharsets.UTF_8) + withContext(Dispatchers.Main) { + appendRawOutput(chunk) + } + } + } catch (error: Throwable) { + withContext(Dispatchers.Main) { + appendOutputLine("错误: ${error.message ?: error.javaClass.simpleName}") + } + } finally { + runCatching { stream.close() } + withContext(Dispatchers.Main) { + if (shellStream === stream) { + shellStream = null + } + shellReady = false + shellConnecting = false + showKeyboardWhenReady = false + } + } + } + } + + LaunchedEffect(output) { + if (output != outputBuffer) { + outputBuffer = output + } + } + + LaunchedEffect(outputBuffer) { + outputScrollState.scrollTo(outputScrollState.maxValue) + } + + LaunchedEffect(imeBottomDp.value) { + if (imeBottomDp > 0.dp) { + outputScrollState.scrollTo(outputScrollState.maxValue) + } + } + + DisposableEffect(Unit) { + onDispose { + runCatching { shellStream?.close() } + taskScope.cancel() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding( + start = UiSpacing.PageHorizontal, + top = UiSpacing.PageHorizontal, + end = UiSpacing.PageVertical, + bottom = UiSpacing.PageVertical + + max(bottomInnerPadding.value, imeBottomDp.value).dp, + ), + ) { + Card( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(outputScrollState) + .padding(16.dp), + verticalArrangement = Arrangement.Bottom, + ) { + Text( + text = outputBuffer.ifBlank { + """ + 可用缩放手势调整字体大小 + 轻触以连接终端 + """.trimIndent() + }, + modifier = Modifier.fillMaxWidth(), + fontSize = terminalFontSizeSp.sp, + color = + if (outputBuffer.isBlank()) colorScheme.onSurfaceVariantSummary + else colorScheme.onSurface, + ) + } + + AndroidView( + factory = { + TerminalInputView(it).apply { + alpha = 0f + setInputEnabled(true) + setOnTouchListener { _, event -> + scaleGestureDetector.onTouchEvent(event) + when { + event.actionMasked == MotionEvent.ACTION_DOWN -> { + pinchInProgress = false + tapPending = true + touchDownX = event.x + touchDownY = event.y + parent?.requestDisallowInterceptTouchEvent(false) + true + } + + event.pointerCount >= 2 -> { + pinchInProgress = true + tapPending = false + parent?.requestDisallowInterceptTouchEvent(true) + true + } + + event.actionMasked == MotionEvent.ACTION_MOVE -> { + val movedFar = + kotlin.math.abs(event.x - touchDownX) > touchSlop || + kotlin.math.abs(event.y - touchDownY) > touchSlop + if (movedFar) { + tapPending = false + } + if (pinchInProgress) { + parent?.requestDisallowInterceptTouchEvent(true) + } else { + parent?.requestDisallowInterceptTouchEvent(false) + } + true + } + + event.actionMasked == MotionEvent.ACTION_UP -> { + if (!pinchInProgress && tapPending) { + performClick() + openShellSession(showKeyboardAfterConnect = true) + } + pinchInProgress = false + tapPending = false + parent?.requestDisallowInterceptTouchEvent(false) + true + } + + event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL -> { + pinchInProgress = false + tapPending = false + parent?.requestDisallowInterceptTouchEvent(false) + true + } + + else -> true + } + } + inputCallbacks = object : TerminalInputView.InputCallbacks { + override fun handleCommitText(text: CharSequence): Boolean { + if (text.isEmpty()) { + return true + } + writeToShell(text.toString()) + return true + } + + override fun handleDeleteSurroundingText( + beforeLength: Int, + afterLength: Int, + ): Boolean { + val deleteCount = beforeLength.coerceAtLeast(1) + writeToShell("\b".repeat(deleteCount)) + return true + } + + override fun handleKeyEvent(event: AndroidKeyEvent): Boolean { + val shellText = mapKeyEventToShellText(event) ?: return false + if (shellText.isNotEmpty()) { + writeToShell(shellText) + } + return true + } + } + terminalInputView = this + } + }, + update = { + terminalInputView = it + if (showKeyboardWhenReady && shellReady) { + LocalInputService.showSoftKeyboard(it) + showKeyboardWhenReady = false + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@Composable +private fun TerminalOutputBottomSheet( + show: Boolean, + output: String, + onDismissRequest: () -> Unit, + onCopyAll: () -> Unit, +) { + OverlayBottomSheet( + show = show, + title = "自由复制", + defaultWindowInsetsPadding = false, + onDismissRequest = onDismissRequest, + endAction = { + IconButton(onClick = onCopyAll) { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = "复制全部", + ) + } + }, + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(2f / 3f), + ) { + item { + TextField( + value = output.ifBlank { "当前没有输出" }, + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = UiSpacing.PageVertical), + readOnly = true, + label = "终端输出", + useLabelAsPlaceholder = true, + ) + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt index b9ffe70..30b5ddf 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/password/PasswordPickerPopup.kt @@ -15,7 +15,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.fragment.app.FragmentActivity import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.ListPopupColumn @@ -26,16 +30,16 @@ import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme @Composable -fun PasswordPickerPopupContent( - onDismissRequest: () -> Unit, - onMessage: (String) -> Unit, -) { - val activity = LocalActivity.current as? FragmentActivity +fun PasswordPickerPopupContent(onDismissRequest: () -> Unit) { + val fragActivity = LocalActivity.current as? FragmentActivity + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val scope = rememberCoroutineScope() val passwordUseCase = remember { PasswordUseCase() } val entries by PasswordRepository.entriesState.collectAsState() val appSettingsBundle by appSettings.bundleState.collectAsState() + val snackbar = LocalSnackbarController.current + val spinnerEntries = remember(entries) { entries.map { entry -> SpinnerEntry( @@ -60,6 +64,22 @@ fun PasswordPickerPopupContent( } } + fun fillPassword(index: Int) { + val entry = entries[index] + scope.launch { + passwordUseCase.preparePassword( + activity = fragActivity!!, + entry = entry, + globalRequiresAuth = appSettingsBundle.passwordRequireAuth, + ).onSuccess { password -> + InjectionController.inject(password) + onDismissRequest() + }.onFailure { + taskScope.launch { snackbar.show(it.message ?: "密码填充失败") } + } + } + } + ListPopupColumn { if (spinnerEntries.isEmpty()) { Text( @@ -73,7 +93,7 @@ fun PasswordPickerPopupContent( return@ListPopupColumn } - if (activity == null) { + if (fragActivity == null) { Text( text = "当前页面无法拉起验证", modifier = Modifier @@ -93,21 +113,7 @@ fun PasswordPickerPopupContent( index = index, spinnerColors = SpinnerDefaults.spinnerColors(), dialogMode = false, - onSelectedIndexChange = { selectedIndex -> - val target = entries[selectedIndex] - scope.launch { - passwordUseCase.preparePassword( - activity = activity, - entry = target, - globalRequiresAuth = appSettingsBundle.passwordRequireAuth, - ).onSuccess { password -> - InjectionController.inject(password) - onDismissRequest() - }.onFailure { - onMessage(it.message ?: "密码填充失败") - } - } - }, + onSelectedIndexChange = ::fillPassword, ) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/PopupMenuItem.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/PopupMenuItem.kt new file mode 100644 index 0000000..14d4d05 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/PopupMenuItem.kt @@ -0,0 +1,46 @@ +package io.github.miuzarte.scrcpyforandroid.widgets + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import top.yukonga.miuix.kmp.basic.DropdownImpl +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme +import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles + +@Composable +fun PopupMenuItem( + text: String, + optionSize: Int, + index: Int, + enabled: Boolean = true, + onSelectedIndexChange: (Int) -> Unit, +) { + if (enabled) { + DropdownImpl( + text = text, + optionSize = optionSize, + isSelected = false, + index = index, + onSelectedIndexChange = onSelectedIndexChange, + ) + return + } + + val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem + val additionalBottomPadding = + if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem + Text( + text = text, + fontSize = textStyles.body1.fontSize, + fontWeight = FontWeight.Medium, + color = colorScheme.disabledOnSecondaryVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.PopupHorizontal) + .padding(top = additionalTopPadding, bottom = additionalBottomPadding), + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt new file mode 100644 index 0000000..6c7ae21 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt @@ -0,0 +1,94 @@ +package io.github.miuzarte.scrcpyforandroid.widgets + +import android.content.Context +import android.text.InputType +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.BaseInputConnection +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection + +class TerminalInputView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : View(context, attrs, defStyleAttr) { + interface InputCallbacks { + fun handleCommitText(text: CharSequence): Boolean + fun handleDeleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean + fun handleKeyEvent(event: KeyEvent): Boolean + } + + var inputCallbacks: InputCallbacks? = null + private var inputEnabled = true + + init { + isFocusable = true + isFocusableInTouchMode = true + } + + fun setInputEnabled(enabled: Boolean) { + inputEnabled = enabled + isFocusable = enabled + isFocusableInTouchMode = enabled + if (!enabled) { + clearFocus() + } + } + + override fun onCheckIsTextEditor(): Boolean { + return inputEnabled || super.onCheckIsTextEditor() + } + + override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { + if (inputCallbacks?.handleKeyEvent(event) == true) return true + return super.onKeyPreIme(keyCode, event) + } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + if (!inputEnabled) return super.onCreateInputConnection(outAttrs) + + outAttrs.inputType = InputType.TYPE_CLASS_TEXT + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI + return object : BaseInputConnection(this, false) { + override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean { + if (inputCallbacks?.handleCommitText(text) == true) return true + return super.commitText(text, newCursorPosition) + } + + override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean { + return true + } + + override fun finishComposingText(): Boolean { + return true + } + + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + if (inputCallbacks?.handleDeleteSurroundingText(beforeLength, afterLength) == true) { + return true + } + return super.deleteSurroundingText(beforeLength, afterLength) + } + + override fun sendKeyEvent(event: KeyEvent): Boolean { + if (inputCallbacks?.handleKeyEvent(event) == true) return true + return super.sendKeyEvent(event) + } + + override fun performEditorAction(actionCode: Int): Boolean { + return when (actionCode) { + EditorInfo.IME_ACTION_DONE, + EditorInfo.IME_ACTION_GO, + EditorInfo.IME_ACTION_NEXT, + EditorInfo.IME_ACTION_SEARCH, + EditorInfo.IME_ACTION_SEND, + -> inputCallbacks?.handleCommitText("\n") == true + + else -> super.performEditorAction(actionCode) + } + } + } + } +}