feat: terminal

- 重构 NativeAdbService,增强连接处理并简化 shell 命令执行
- 引入 TerminalInputView,管理终端输入交互
This commit is contained in:
Miuzarte
2026-04-21 14:29:12 +08:00
parent d031839a3a
commit 696e434b25
14 changed files with 879 additions and 133 deletions

View File

@@ -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. 虚拟屏不显示输入法 / 输入法显示在主屏幕

View File

@@ -2,8 +2,6 @@
# TODO
adb shell page (in nav dock)
## PARAMS
### LOWER PRIORITY

View File

@@ -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 {

View File

@@ -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

View File

@@ -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("'", "'\\''") + "'"
}

View File

@@ -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),
)
}

View File

@@ -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) }
},
)
}
},

View File

@@ -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<NavKey>(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,

View File

@@ -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()
)
},
)

View File

@@ -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)

View File

@@ -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<AdbSocketStream?>(null) }
var terminalInputView by remember { mutableStateOf<TerminalInputView?>(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,
)
}
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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),
)
}

View File

@@ -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)
}
}
}
}
}