feat: terminal
- 重构 NativeAdbService,增强连接处理并简化 shell 命令执行 - 引入 TerminalInputView,管理终端输入交互
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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("'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user